mirror of
https://github.com/trezor/trezor-firmware.git
synced 2025-01-08 14:31:06 +00:00
feat(core/ui): T3T1 share words animation
[no changelog]
This commit is contained in:
parent
e5e8e27abc
commit
30ca8bdd62
@ -1,7 +1,7 @@
|
||||
use crate::ui::{
|
||||
component::{Component, Event, EventCtx},
|
||||
event::TouchEvent,
|
||||
geometry::{Point, Rect},
|
||||
geometry::{Offset, Point, Rect},
|
||||
shape::Renderer,
|
||||
};
|
||||
|
||||
@ -13,6 +13,17 @@ pub enum SwipeDirection {
|
||||
Right,
|
||||
}
|
||||
|
||||
impl SwipeDirection {
|
||||
pub fn as_offset(self, size: Offset) -> Offset {
|
||||
match self {
|
||||
SwipeDirection::Up => Offset::y(-size.y),
|
||||
SwipeDirection::Down => Offset::y(size.y),
|
||||
SwipeDirection::Left => Offset::x(-size.x),
|
||||
SwipeDirection::Right => Offset::x(size.x),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy of `model_tt/component/swipe.rs` but without the backlight handling.
|
||||
pub struct Swipe {
|
||||
pub area: Rect,
|
||||
|
@ -1,20 +1,6 @@
|
||||
use crate::ui::{
|
||||
component::{EventCtx, SwipeDirection},
|
||||
geometry::Offset,
|
||||
};
|
||||
use crate::ui::component::{EventCtx, SwipeDirection};
|
||||
use num_traits::ToPrimitive;
|
||||
|
||||
impl SwipeDirection {
|
||||
pub fn as_offset(self, size: Offset) -> Offset {
|
||||
match self {
|
||||
SwipeDirection::Up => Offset::y(-size.y),
|
||||
SwipeDirection::Down => Offset::y(size.y),
|
||||
SwipeDirection::Left => Offset::x(-size.x),
|
||||
SwipeDirection::Right => Offset::x(size.x),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Component must implement this trait in order to be part of swipe-based flow.
|
||||
///
|
||||
/// Default implementation ignores every swipe.
|
||||
|
@ -1,11 +1,11 @@
|
||||
use crate::{
|
||||
micropython::gc::Gc,
|
||||
time::{Duration, Instant},
|
||||
time::Instant,
|
||||
ui::{
|
||||
animation::Animation,
|
||||
component::{Component, Event, EventCtx, Paginate, SwipeDirection},
|
||||
flow::base::Swipable,
|
||||
geometry::{Axis, Offset, Rect},
|
||||
geometry::{Axis, Rect},
|
||||
shape::Renderer,
|
||||
util,
|
||||
},
|
||||
@ -15,13 +15,11 @@ pub struct Transition<T> {
|
||||
/// Clone of the component before page change.
|
||||
cloned: Gc<T>,
|
||||
/// Animation progress.
|
||||
animation: Animation<Offset>,
|
||||
animation: Animation<f32>,
|
||||
/// Direction of the slide animation.
|
||||
direction: SwipeDirection,
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION: Duration = Duration::from_millis(333);
|
||||
|
||||
/// Allows any implementor of `Paginate` to be part of `Swipable` UI flow.
|
||||
/// Renders sliding animation when changing pages.
|
||||
pub struct SwipePage<T> {
|
||||
@ -74,16 +72,13 @@ impl<T: Component + Paginate + Clone> SwipePage<T> {
|
||||
transition: &'s Transition<T>,
|
||||
target: &mut impl Renderer<'s>,
|
||||
) {
|
||||
let off = transition.animation.value(Instant::now());
|
||||
target.in_clip(self.bounds, &|target| {
|
||||
target.with_origin(off, &|target| {
|
||||
transition.cloned.render(target);
|
||||
});
|
||||
target.with_origin(
|
||||
off - transition.direction.as_offset(self.bounds.size()),
|
||||
&|target| {
|
||||
self.inner.render(target);
|
||||
},
|
||||
util::render_slide(
|
||||
|target| transition.cloned.render(target),
|
||||
|target| self.inner.render(target),
|
||||
transition.animation.value(Instant::now()),
|
||||
transition.direction,
|
||||
target,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -147,12 +142,7 @@ impl<T: Component + Paginate + Clone> Swipable for SwipePage<T> {
|
||||
}
|
||||
self.transition = Some(Transition {
|
||||
cloned: unwrap!(Gc::new(self.inner.clone())),
|
||||
animation: Animation::new(
|
||||
Offset::zero(),
|
||||
direction.as_offset(self.bounds.size()),
|
||||
ANIMATION_DURATION,
|
||||
Instant::now(),
|
||||
),
|
||||
animation: Animation::new(0.0f32, 1.0f32, util::SLIDE_DURATION, Instant::now()),
|
||||
direction,
|
||||
});
|
||||
self.inner.change_page(self.current);
|
||||
|
@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
error,
|
||||
time::{Duration, Instant},
|
||||
time::Instant,
|
||||
ui::{
|
||||
animation::Animation,
|
||||
component::{Component, Event, EventCtx, Swipe, SwipeDirection},
|
||||
@ -11,8 +11,6 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
const ANIMATION_DURATION: Duration = Duration::from_millis(333);
|
||||
|
||||
/// Given a state enum and a corresponding FlowStore, create a Component that
|
||||
/// implements a swipe navigation between the states with animated transitions.
|
||||
///
|
||||
@ -28,8 +26,6 @@ pub struct SwipeFlow<Q, S> {
|
||||
transition: Transition<Q>,
|
||||
/// Swipe detector.
|
||||
swipe: Swipe,
|
||||
/// Animation parameter.
|
||||
anim_offset: Offset,
|
||||
}
|
||||
|
||||
enum Transition<Q> {
|
||||
@ -38,7 +34,7 @@ enum Transition<Q> {
|
||||
/// State we are transitioning _from_.
|
||||
prev_state: Q,
|
||||
/// Animation progress.
|
||||
animation: Animation<Offset>,
|
||||
animation: Animation<f32>,
|
||||
/// Direction of the slide animation.
|
||||
direction: SwipeDirection,
|
||||
},
|
||||
@ -55,7 +51,6 @@ impl<Q: FlowState, S: FlowStore> SwipeFlow<Q, S> {
|
||||
store,
|
||||
transition: Transition::None,
|
||||
swipe: Swipe::new().down().up().left().right(),
|
||||
anim_offset: Offset::zero(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -72,12 +67,7 @@ impl<Q: FlowState, S: FlowStore> SwipeFlow<Q, S> {
|
||||
}
|
||||
self.transition = Transition::External {
|
||||
prev_state: self.state,
|
||||
animation: Animation::new(
|
||||
Offset::zero(),
|
||||
direction.as_offset(self.anim_offset),
|
||||
ANIMATION_DURATION,
|
||||
Instant::now(),
|
||||
),
|
||||
animation: Animation::new(0.0f32, 1.0f32, util::SLIDE_DURATION, Instant::now()),
|
||||
direction,
|
||||
};
|
||||
self.state = state;
|
||||
@ -92,17 +82,17 @@ impl<Q: FlowState, S: FlowStore> SwipeFlow<Q, S> {
|
||||
fn render_transition<'s>(
|
||||
&'s self,
|
||||
prev_state: &Q,
|
||||
animation: &Animation<Offset>,
|
||||
animation: &Animation<f32>,
|
||||
direction: &SwipeDirection,
|
||||
target: &mut impl Renderer<'s>,
|
||||
) {
|
||||
let off = animation.value(Instant::now());
|
||||
target.with_origin(off, &|target| {
|
||||
self.render_state(*prev_state, target);
|
||||
});
|
||||
target.with_origin(off - direction.as_offset(self.anim_offset), &|target| {
|
||||
self.render_state(self.state, target);
|
||||
});
|
||||
util::render_slide(
|
||||
|target| self.render_state(*prev_state, target),
|
||||
|target| self.render_state(self.state, target),
|
||||
animation.value(Instant::now()),
|
||||
*direction,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_transition(&mut self, ctx: &mut EventCtx, event: Event) -> Option<FlowMsg> {
|
||||
@ -164,10 +154,6 @@ impl<Q: FlowState, S: FlowStore> Component for SwipeFlow<Q, S> {
|
||||
type Msg = FlowMsg;
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
// Save screen size for slide animation. Once we have reasonable constants trait
|
||||
// this can be set in the constructor.
|
||||
self.anim_offset = bounds.size();
|
||||
|
||||
self.swipe.place(bounds);
|
||||
self.store.place(bounds)
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
use crate::ui::lerp::Lerp;
|
||||
use core::ops::{Add, Neg, Sub};
|
||||
use core::ops::{Add, Mul, Neg, Sub};
|
||||
|
||||
const fn min(a: i16, b: i16) -> i16 {
|
||||
if a < b {
|
||||
@ -128,6 +128,17 @@ impl Sub<Offset> for Offset {
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f32> for Offset {
|
||||
type Output = Offset;
|
||||
|
||||
fn mul(self, rhs: f32) -> Self::Output {
|
||||
Offset::new(
|
||||
(f32::from(self.x) * rhs) as i16,
|
||||
(f32::from(self.y) * rhs) as i16,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Point> for Offset {
|
||||
fn from(val: Point) -> Self {
|
||||
Offset::new(val.x, val.y)
|
||||
|
@ -71,6 +71,8 @@ impl_lerp_for_uint!(u8);
|
||||
impl_lerp_for_uint!(u16);
|
||||
impl_lerp_for_uint!(u32);
|
||||
|
||||
impl_lerp_for_int!(f32);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
@ -7,10 +7,8 @@ use crate::{
|
||||
ui::{
|
||||
component::{Component, Event, EventCtx, TimerToken},
|
||||
display::{
|
||||
self,
|
||||
image::{ImageInfo, ToifFormat},
|
||||
tjpgd::jpeg_info,
|
||||
toif::{Icon, Toif},
|
||||
toif::Icon,
|
||||
Color, Font,
|
||||
},
|
||||
event::{TouchEvent, USBEvent},
|
||||
|
@ -67,7 +67,7 @@ pub use prompt_screen::PromptScreen;
|
||||
pub use result::{ResultFooter, ResultScreen, ResultStyle};
|
||||
pub use scroll::ScrollBar;
|
||||
#[cfg(feature = "translations")]
|
||||
pub use share_words::{ShareWords, ShareWordsMsg};
|
||||
pub use share_words::ShareWords;
|
||||
pub use simple_page::SimplePage;
|
||||
pub use status_screen::StatusScreen;
|
||||
pub use swipe::{Swipe, SwipeDirection};
|
||||
|
@ -1,19 +1,23 @@
|
||||
use super::theme;
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
time::{Duration, Instant},
|
||||
translations::TR,
|
||||
ui::{
|
||||
component::{Component, Event, EventCtx, Paginate, Swipe, SwipeDirection},
|
||||
animation::Animation,
|
||||
component::{Component, Event, EventCtx, Never, SwipeDirection},
|
||||
flow::Swipable,
|
||||
geometry::{Alignment, Alignment2D, Insets, Offset, Rect},
|
||||
model_mercury::component::Footer,
|
||||
shape,
|
||||
shape::Renderer,
|
||||
util,
|
||||
},
|
||||
};
|
||||
use heapless::{String, Vec};
|
||||
|
||||
const MAX_WORDS: usize = 33; // super-shamir has 33 words, all other have less
|
||||
const ANIMATION_DURATION: Duration = Duration::from_millis(166);
|
||||
|
||||
/// Component showing mnemonic/share words during backup procedure. Model T3T1
|
||||
/// contains one word per screen. A user is instructed to swipe up/down to see
|
||||
@ -22,19 +26,15 @@ pub struct ShareWords<'a> {
|
||||
area: Rect,
|
||||
share_words: Vec<TString<'a>, MAX_WORDS>,
|
||||
page_index: usize,
|
||||
prev_index: usize,
|
||||
/// Area reserved for a shown word from mnemonic/share
|
||||
area_word: Rect,
|
||||
/// TODO: review when swipe concept done for T3T1
|
||||
swipe: Swipe,
|
||||
/// `Some` when transition animation is in progress
|
||||
animation: Option<Animation<f32>>,
|
||||
/// Footer component for instructions and word counting
|
||||
footer: Footer<'static>,
|
||||
}
|
||||
|
||||
pub enum ShareWordsMsg {
|
||||
GoPrevScreen,
|
||||
WordsSeen,
|
||||
}
|
||||
|
||||
impl<'a> ShareWords<'a> {
|
||||
const AREA_WORD_HEIGHT: i16 = 91;
|
||||
|
||||
@ -43,8 +43,9 @@ impl<'a> ShareWords<'a> {
|
||||
area: Rect::zero(),
|
||||
share_words,
|
||||
page_index: 0,
|
||||
prev_index: 0,
|
||||
area_word: Rect::zero(),
|
||||
swipe: Swipe::new().up().down(),
|
||||
animation: None,
|
||||
footer: Footer::new(TR::instructions__swipe_up),
|
||||
}
|
||||
}
|
||||
@ -56,10 +57,23 @@ impl<'a> ShareWords<'a> {
|
||||
fn is_final_page(&self) -> bool {
|
||||
self.page_index == self.share_words.len() - 1
|
||||
}
|
||||
|
||||
fn render_word<'s>(&'s self, word_index: usize, target: &mut impl Renderer<'s>) {
|
||||
// the share word
|
||||
let word = self.share_words[word_index];
|
||||
let word_baseline = target.viewport().clip.center()
|
||||
+ Offset::y(theme::TEXT_SUPER.text_font.visible_text_height("A") / 2);
|
||||
word.map(|w| {
|
||||
shape::Text::new(word_baseline, w)
|
||||
.with_font(theme::TEXT_SUPER.text_font)
|
||||
.with_align(Alignment::Center)
|
||||
.render(target);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Component for ShareWords<'a> {
|
||||
type Msg = ShareWordsMsg;
|
||||
type Msg = Never;
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
self.area = bounds;
|
||||
@ -76,29 +90,18 @@ impl<'a> Component for ShareWords<'a> {
|
||||
self.footer
|
||||
.place(used_area.split_bottom(Footer::HEIGHT_SIMPLE).1);
|
||||
|
||||
self.swipe.place(bounds); // Swipe possible on the whole screen area
|
||||
self.area
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
fn event(&mut self, ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
|
||||
// ctx.set_page_count(self.share_words.len());
|
||||
let swipe = self.swipe.event(ctx, event);
|
||||
match swipe {
|
||||
Some(SwipeDirection::Up) => {
|
||||
if self.is_final_page() {
|
||||
return Some(ShareWordsMsg::WordsSeen);
|
||||
}
|
||||
self.change_page(self.page_index + 1);
|
||||
ctx.request_paint();
|
||||
if let Some(a) = &self.animation {
|
||||
if a.finished(Instant::now()) {
|
||||
self.animation = None;
|
||||
} else {
|
||||
ctx.request_anim_frame();
|
||||
}
|
||||
Some(SwipeDirection::Down) => {
|
||||
if self.is_first_page() {
|
||||
return Some(ShareWordsMsg::GoPrevScreen);
|
||||
}
|
||||
self.change_page(self.page_index.saturating_sub(1));
|
||||
ctx.request_paint();
|
||||
}
|
||||
_ => (),
|
||||
ctx.request_paint();
|
||||
}
|
||||
None
|
||||
}
|
||||
@ -129,16 +132,25 @@ impl<'a> Component for ShareWords<'a> {
|
||||
.with_fg(theme::GREY)
|
||||
.render(target);
|
||||
|
||||
// the share word
|
||||
let word = self.share_words[self.page_index];
|
||||
let word_baseline = self.area_word.center()
|
||||
+ Offset::y(theme::TEXT_SUPER.text_font.visible_text_height("A") / 2);
|
||||
word.map(|w| {
|
||||
shape::Text::new(word_baseline, w)
|
||||
.with_font(theme::TEXT_SUPER.text_font)
|
||||
.with_align(Alignment::Center)
|
||||
.render(target);
|
||||
});
|
||||
if let Some(animation) = &self.animation {
|
||||
target.in_clip(self.area_word, &|target| {
|
||||
util::render_slide(
|
||||
|target| self.render_word(self.prev_index, target),
|
||||
|target| self.render_word(self.page_index, target),
|
||||
animation.value(Instant::now()),
|
||||
if self.prev_index < self.page_index {
|
||||
SwipeDirection::Up
|
||||
} else {
|
||||
SwipeDirection::Down
|
||||
},
|
||||
target,
|
||||
)
|
||||
});
|
||||
} else {
|
||||
target.in_clip(self.area_word, &|target| {
|
||||
self.render_word(self.page_index, target);
|
||||
})
|
||||
};
|
||||
|
||||
// footer with instructions
|
||||
self.footer.render(target);
|
||||
@ -148,15 +160,36 @@ impl<'a> Component for ShareWords<'a> {
|
||||
fn bounds(&self, _sink: &mut dyn FnMut(Rect)) {}
|
||||
}
|
||||
|
||||
impl<'a> Swipable for ShareWords<'a> {}
|
||||
|
||||
impl<'a> Paginate for ShareWords<'a> {
|
||||
fn page_count(&mut self) -> usize {
|
||||
self.share_words.len()
|
||||
impl<'a> Swipable for ShareWords<'a> {
|
||||
fn swipe_start(&mut self, ctx: &mut EventCtx, direction: SwipeDirection) -> bool {
|
||||
match direction {
|
||||
SwipeDirection::Up if !self.is_final_page() => {
|
||||
self.prev_index = self.page_index;
|
||||
self.page_index = (self.page_index + 1).min(self.share_words.len() - 1);
|
||||
}
|
||||
SwipeDirection::Down if !self.is_first_page() => {
|
||||
self.prev_index = self.page_index;
|
||||
self.page_index = self.page_index.saturating_sub(1);
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
if util::animation_disabled() {
|
||||
ctx.request_paint();
|
||||
return true;
|
||||
}
|
||||
self.animation = Some(Animation::new(
|
||||
0.0f32,
|
||||
1.0f32,
|
||||
ANIMATION_DURATION,
|
||||
Instant::now(),
|
||||
));
|
||||
ctx.request_anim_frame();
|
||||
ctx.request_paint();
|
||||
true
|
||||
}
|
||||
|
||||
fn change_page(&mut self, active_page: usize) {
|
||||
self.page_index = active_page;
|
||||
fn swipe_finished(&self) -> bool {
|
||||
self.animation.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,7 @@ use crate::{
|
||||
use heapless::Vec;
|
||||
|
||||
use super::super::{
|
||||
component::{Frame, FrameMsg, PromptScreen, ShareWords, ShareWordsMsg, SwipeDirection},
|
||||
component::{Frame, FrameMsg, PromptScreen, ShareWords, SwipeDirection},
|
||||
theme,
|
||||
};
|
||||
|
||||
@ -37,6 +37,12 @@ impl FlowState for ShowShareWords {
|
||||
(ShowShareWords::Confirm, SwipeDirection::Down) => {
|
||||
Decision::Goto(ShowShareWords::Words, direction)
|
||||
}
|
||||
(ShowShareWords::Words, SwipeDirection::Up) => {
|
||||
Decision::Goto(ShowShareWords::Confirm, direction)
|
||||
}
|
||||
(ShowShareWords::Words, SwipeDirection::Down) => {
|
||||
Decision::Goto(ShowShareWords::Instruction, direction)
|
||||
}
|
||||
(ShowShareWords::CheckBackupIntro, SwipeDirection::Up) => {
|
||||
Decision::Return(FlowMsg::Confirmed)
|
||||
}
|
||||
@ -85,11 +91,7 @@ impl ShowShareWords {
|
||||
.map(|msg| matches!(msg, FrameMsg::Content(_)).then_some(FlowMsg::Confirmed));
|
||||
|
||||
let content_words =
|
||||
Frame::left_aligned(title, ShareWords::new(share_words_vec)).map(|msg| match msg {
|
||||
FrameMsg::Content(ShareWordsMsg::GoPrevScreen) => Some(FlowMsg::Cancelled),
|
||||
FrameMsg::Content(ShareWordsMsg::WordsSeen) => Some(FlowMsg::Confirmed),
|
||||
_ => None,
|
||||
});
|
||||
Frame::left_aligned(title, ShareWords::new(share_words_vec)).map(|_| None);
|
||||
|
||||
let content_confirm =
|
||||
Frame::left_aligned(text_confirm, PromptScreen::new_hold_to_confirm())
|
||||
|
@ -1,5 +1,6 @@
|
||||
use crate::{
|
||||
strutil::{ShortString, TString},
|
||||
time::Duration,
|
||||
ui::{
|
||||
component::text::TextStyle,
|
||||
display,
|
||||
@ -179,6 +180,32 @@ macro_rules! include_icon {
|
||||
};
|
||||
}
|
||||
|
||||
pub const SLIDE_DURATION: Duration = Duration::from_millis(333);
|
||||
|
||||
#[cfg(feature = "new_rendering")]
|
||||
pub fn render_slide<'s, F0, F1, R>(
|
||||
render_old: F0,
|
||||
render_new: F1,
|
||||
progress: f32,
|
||||
direction: crate::ui::component::SwipeDirection,
|
||||
target: &mut R,
|
||||
) where
|
||||
R: crate::ui::shape::Renderer<'s>,
|
||||
F0: Fn(&mut R),
|
||||
F1: Fn(&mut R),
|
||||
{
|
||||
let bounds = target.viewport().clip;
|
||||
let full_offset = direction.as_offset(bounds.size());
|
||||
let current_offset = full_offset * progress;
|
||||
|
||||
target.with_origin(current_offset, &|target| {
|
||||
render_old(target);
|
||||
});
|
||||
target.with_origin(current_offset - full_offset, &|target| {
|
||||
render_new(target);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::strutil;
|
||||
|
Loading…
Reference in New Issue
Block a user