1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-01-24 14:20:57 +00:00

feat(core/ui): T3T1 share words animation

[no changelog]
This commit is contained in:
Martin Milata 2024-05-16 13:22:40 +02:00
parent e5e8e27abc
commit 30ca8bdd62
11 changed files with 163 additions and 117 deletions

View File

@ -1,7 +1,7 @@
use crate::ui::{ use crate::ui::{
component::{Component, Event, EventCtx}, component::{Component, Event, EventCtx},
event::TouchEvent, event::TouchEvent,
geometry::{Point, Rect}, geometry::{Offset, Point, Rect},
shape::Renderer, shape::Renderer,
}; };
@ -13,6 +13,17 @@ pub enum SwipeDirection {
Right, 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. /// Copy of `model_tt/component/swipe.rs` but without the backlight handling.
pub struct Swipe { pub struct Swipe {
pub area: Rect, pub area: Rect,

View File

@ -1,20 +1,6 @@
use crate::ui::{ use crate::ui::component::{EventCtx, SwipeDirection};
component::{EventCtx, SwipeDirection},
geometry::Offset,
};
use num_traits::ToPrimitive; 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. /// Component must implement this trait in order to be part of swipe-based flow.
/// ///
/// Default implementation ignores every swipe. /// Default implementation ignores every swipe.

View File

@ -1,11 +1,11 @@
use crate::{ use crate::{
micropython::gc::Gc, micropython::gc::Gc,
time::{Duration, Instant}, time::Instant,
ui::{ ui::{
animation::Animation, animation::Animation,
component::{Component, Event, EventCtx, Paginate, SwipeDirection}, component::{Component, Event, EventCtx, Paginate, SwipeDirection},
flow::base::Swipable, flow::base::Swipable,
geometry::{Axis, Offset, Rect}, geometry::{Axis, Rect},
shape::Renderer, shape::Renderer,
util, util,
}, },
@ -15,13 +15,11 @@ pub struct Transition<T> {
/// Clone of the component before page change. /// Clone of the component before page change.
cloned: Gc<T>, cloned: Gc<T>,
/// Animation progress. /// Animation progress.
animation: Animation<Offset>, animation: Animation<f32>,
/// Direction of the slide animation. /// Direction of the slide animation.
direction: SwipeDirection, direction: SwipeDirection,
} }
const ANIMATION_DURATION: Duration = Duration::from_millis(333);
/// Allows any implementor of `Paginate` to be part of `Swipable` UI flow. /// Allows any implementor of `Paginate` to be part of `Swipable` UI flow.
/// Renders sliding animation when changing pages. /// Renders sliding animation when changing pages.
pub struct SwipePage<T> { pub struct SwipePage<T> {
@ -74,16 +72,13 @@ impl<T: Component + Paginate + Clone> SwipePage<T> {
transition: &'s Transition<T>, transition: &'s Transition<T>,
target: &mut impl Renderer<'s>, target: &mut impl Renderer<'s>,
) { ) {
let off = transition.animation.value(Instant::now());
target.in_clip(self.bounds, &|target| { target.in_clip(self.bounds, &|target| {
target.with_origin(off, &|target| { util::render_slide(
transition.cloned.render(target); |target| transition.cloned.render(target),
}); |target| self.inner.render(target),
target.with_origin( transition.animation.value(Instant::now()),
off - transition.direction.as_offset(self.bounds.size()), transition.direction,
&|target| { target,
self.inner.render(target);
},
); );
}); });
} }
@ -147,12 +142,7 @@ impl<T: Component + Paginate + Clone> Swipable for SwipePage<T> {
} }
self.transition = Some(Transition { self.transition = Some(Transition {
cloned: unwrap!(Gc::new(self.inner.clone())), cloned: unwrap!(Gc::new(self.inner.clone())),
animation: Animation::new( animation: Animation::new(0.0f32, 1.0f32, util::SLIDE_DURATION, Instant::now()),
Offset::zero(),
direction.as_offset(self.bounds.size()),
ANIMATION_DURATION,
Instant::now(),
),
direction, direction,
}); });
self.inner.change_page(self.current); self.inner.change_page(self.current);

View File

@ -1,6 +1,6 @@
use crate::{ use crate::{
error, error,
time::{Duration, Instant}, time::Instant,
ui::{ ui::{
animation::Animation, animation::Animation,
component::{Component, Event, EventCtx, Swipe, SwipeDirection}, 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 /// Given a state enum and a corresponding FlowStore, create a Component that
/// implements a swipe navigation between the states with animated transitions. /// implements a swipe navigation between the states with animated transitions.
/// ///
@ -28,8 +26,6 @@ pub struct SwipeFlow<Q, S> {
transition: Transition<Q>, transition: Transition<Q>,
/// Swipe detector. /// Swipe detector.
swipe: Swipe, swipe: Swipe,
/// Animation parameter.
anim_offset: Offset,
} }
enum Transition<Q> { enum Transition<Q> {
@ -38,7 +34,7 @@ enum Transition<Q> {
/// State we are transitioning _from_. /// State we are transitioning _from_.
prev_state: Q, prev_state: Q,
/// Animation progress. /// Animation progress.
animation: Animation<Offset>, animation: Animation<f32>,
/// Direction of the slide animation. /// Direction of the slide animation.
direction: SwipeDirection, direction: SwipeDirection,
}, },
@ -55,7 +51,6 @@ impl<Q: FlowState, S: FlowStore> SwipeFlow<Q, S> {
store, store,
transition: Transition::None, transition: Transition::None,
swipe: Swipe::new().down().up().left().right(), 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 { self.transition = Transition::External {
prev_state: self.state, prev_state: self.state,
animation: Animation::new( animation: Animation::new(0.0f32, 1.0f32, util::SLIDE_DURATION, Instant::now()),
Offset::zero(),
direction.as_offset(self.anim_offset),
ANIMATION_DURATION,
Instant::now(),
),
direction, direction,
}; };
self.state = state; self.state = state;
@ -92,17 +82,17 @@ impl<Q: FlowState, S: FlowStore> SwipeFlow<Q, S> {
fn render_transition<'s>( fn render_transition<'s>(
&'s self, &'s self,
prev_state: &Q, prev_state: &Q,
animation: &Animation<Offset>, animation: &Animation<f32>,
direction: &SwipeDirection, direction: &SwipeDirection,
target: &mut impl Renderer<'s>, target: &mut impl Renderer<'s>,
) { ) {
let off = animation.value(Instant::now()); util::render_slide(
target.with_origin(off, &|target| { |target| self.render_state(*prev_state, target),
self.render_state(*prev_state, target); |target| self.render_state(self.state, target),
}); animation.value(Instant::now()),
target.with_origin(off - direction.as_offset(self.anim_offset), &|target| { *direction,
self.render_state(self.state, target); target,
}); );
} }
fn handle_transition(&mut self, ctx: &mut EventCtx, event: Event) -> Option<FlowMsg> { 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; type Msg = FlowMsg;
fn place(&mut self, bounds: Rect) -> Rect { 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.swipe.place(bounds);
self.store.place(bounds) self.store.place(bounds)
} }

View File

@ -1,5 +1,5 @@
use crate::ui::lerp::Lerp; 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 { const fn min(a: i16, b: i16) -> i16 {
if a < b { 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 { impl From<Point> for Offset {
fn from(val: Point) -> Self { fn from(val: Point) -> Self {
Offset::new(val.x, val.y) Offset::new(val.x, val.y)

View File

@ -71,6 +71,8 @@ impl_lerp_for_uint!(u8);
impl_lerp_for_uint!(u16); impl_lerp_for_uint!(u16);
impl_lerp_for_uint!(u32); impl_lerp_for_uint!(u32);
impl_lerp_for_int!(f32);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -7,10 +7,8 @@ use crate::{
ui::{ ui::{
component::{Component, Event, EventCtx, TimerToken}, component::{Component, Event, EventCtx, TimerToken},
display::{ display::{
self,
image::{ImageInfo, ToifFormat}, image::{ImageInfo, ToifFormat},
tjpgd::jpeg_info, toif::Icon,
toif::{Icon, Toif},
Color, Font, Color, Font,
}, },
event::{TouchEvent, USBEvent}, event::{TouchEvent, USBEvent},

View File

@ -67,7 +67,7 @@ pub use prompt_screen::PromptScreen;
pub use result::{ResultFooter, ResultScreen, ResultStyle}; pub use result::{ResultFooter, ResultScreen, ResultStyle};
pub use scroll::ScrollBar; pub use scroll::ScrollBar;
#[cfg(feature = "translations")] #[cfg(feature = "translations")]
pub use share_words::{ShareWords, ShareWordsMsg}; pub use share_words::ShareWords;
pub use simple_page::SimplePage; pub use simple_page::SimplePage;
pub use status_screen::StatusScreen; pub use status_screen::StatusScreen;
pub use swipe::{Swipe, SwipeDirection}; pub use swipe::{Swipe, SwipeDirection};

View File

@ -1,19 +1,23 @@
use super::theme; use super::theme;
use crate::{ use crate::{
strutil::TString, strutil::TString,
time::{Duration, Instant},
translations::TR, translations::TR,
ui::{ ui::{
component::{Component, Event, EventCtx, Paginate, Swipe, SwipeDirection}, animation::Animation,
component::{Component, Event, EventCtx, Never, SwipeDirection},
flow::Swipable, flow::Swipable,
geometry::{Alignment, Alignment2D, Insets, Offset, Rect}, geometry::{Alignment, Alignment2D, Insets, Offset, Rect},
model_mercury::component::Footer, model_mercury::component::Footer,
shape, shape,
shape::Renderer, shape::Renderer,
util,
}, },
}; };
use heapless::{String, Vec}; use heapless::{String, Vec};
const MAX_WORDS: usize = 33; // super-shamir has 33 words, all other have less 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 /// 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 /// 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, area: Rect,
share_words: Vec<TString<'a>, MAX_WORDS>, share_words: Vec<TString<'a>, MAX_WORDS>,
page_index: usize, page_index: usize,
prev_index: usize,
/// Area reserved for a shown word from mnemonic/share /// Area reserved for a shown word from mnemonic/share
area_word: Rect, area_word: Rect,
/// TODO: review when swipe concept done for T3T1 /// `Some` when transition animation is in progress
swipe: Swipe, animation: Option<Animation<f32>>,
/// Footer component for instructions and word counting /// Footer component for instructions and word counting
footer: Footer<'static>, footer: Footer<'static>,
} }
pub enum ShareWordsMsg {
GoPrevScreen,
WordsSeen,
}
impl<'a> ShareWords<'a> { impl<'a> ShareWords<'a> {
const AREA_WORD_HEIGHT: i16 = 91; const AREA_WORD_HEIGHT: i16 = 91;
@ -43,8 +43,9 @@ impl<'a> ShareWords<'a> {
area: Rect::zero(), area: Rect::zero(),
share_words, share_words,
page_index: 0, page_index: 0,
prev_index: 0,
area_word: Rect::zero(), area_word: Rect::zero(),
swipe: Swipe::new().up().down(), animation: None,
footer: Footer::new(TR::instructions__swipe_up), footer: Footer::new(TR::instructions__swipe_up),
} }
} }
@ -56,10 +57,23 @@ impl<'a> ShareWords<'a> {
fn is_final_page(&self) -> bool { fn is_final_page(&self) -> bool {
self.page_index == self.share_words.len() - 1 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> { impl<'a> Component for ShareWords<'a> {
type Msg = ShareWordsMsg; type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect { fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds; self.area = bounds;
@ -76,30 +90,19 @@ impl<'a> Component for ShareWords<'a> {
self.footer self.footer
.place(used_area.split_bottom(Footer::HEIGHT_SIMPLE).1); .place(used_area.split_bottom(Footer::HEIGHT_SIMPLE).1);
self.swipe.place(bounds); // Swipe possible on the whole screen area
self.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()); // ctx.set_page_count(self.share_words.len());
let swipe = self.swipe.event(ctx, event); if let Some(a) = &self.animation {
match swipe { if a.finished(Instant::now()) {
Some(SwipeDirection::Up) => { self.animation = None;
if self.is_final_page() { } else {
return Some(ShareWordsMsg::WordsSeen); ctx.request_anim_frame();
} }
self.change_page(self.page_index + 1);
ctx.request_paint(); ctx.request_paint();
} }
Some(SwipeDirection::Down) => {
if self.is_first_page() {
return Some(ShareWordsMsg::GoPrevScreen);
}
self.change_page(self.page_index.saturating_sub(1));
ctx.request_paint();
}
_ => (),
}
None None
} }
@ -129,16 +132,25 @@ impl<'a> Component for ShareWords<'a> {
.with_fg(theme::GREY) .with_fg(theme::GREY)
.render(target); .render(target);
// the share word if let Some(animation) = &self.animation {
let word = self.share_words[self.page_index]; target.in_clip(self.area_word, &|target| {
let word_baseline = self.area_word.center() util::render_slide(
+ Offset::y(theme::TEXT_SUPER.text_font.visible_text_height("A") / 2); |target| self.render_word(self.prev_index, target),
word.map(|w| { |target| self.render_word(self.page_index, target),
shape::Text::new(word_baseline, w) animation.value(Instant::now()),
.with_font(theme::TEXT_SUPER.text_font) if self.prev_index < self.page_index {
.with_align(Alignment::Center) SwipeDirection::Up
.render(target); } else {
SwipeDirection::Down
},
target,
)
}); });
} else {
target.in_clip(self.area_word, &|target| {
self.render_word(self.page_index, target);
})
};
// footer with instructions // footer with instructions
self.footer.render(target); self.footer.render(target);
@ -148,15 +160,36 @@ impl<'a> Component for ShareWords<'a> {
fn bounds(&self, _sink: &mut dyn FnMut(Rect)) {} fn bounds(&self, _sink: &mut dyn FnMut(Rect)) {}
} }
impl<'a> Swipable for ShareWords<'a> {} impl<'a> Swipable for ShareWords<'a> {
fn swipe_start(&mut self, ctx: &mut EventCtx, direction: SwipeDirection) -> bool {
impl<'a> Paginate for ShareWords<'a> { match direction {
fn page_count(&mut self) -> usize { SwipeDirection::Up if !self.is_final_page() => {
self.share_words.len() 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) { fn swipe_finished(&self) -> bool {
self.page_index = active_page; self.animation.is_none()
} }
} }

View File

@ -15,7 +15,7 @@ use crate::{
use heapless::Vec; use heapless::Vec;
use super::super::{ use super::super::{
component::{Frame, FrameMsg, PromptScreen, ShareWords, ShareWordsMsg, SwipeDirection}, component::{Frame, FrameMsg, PromptScreen, ShareWords, SwipeDirection},
theme, theme,
}; };
@ -37,6 +37,12 @@ impl FlowState for ShowShareWords {
(ShowShareWords::Confirm, SwipeDirection::Down) => { (ShowShareWords::Confirm, SwipeDirection::Down) => {
Decision::Goto(ShowShareWords::Words, direction) 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) => { (ShowShareWords::CheckBackupIntro, SwipeDirection::Up) => {
Decision::Return(FlowMsg::Confirmed) Decision::Return(FlowMsg::Confirmed)
} }
@ -85,11 +91,7 @@ impl ShowShareWords {
.map(|msg| matches!(msg, FrameMsg::Content(_)).then_some(FlowMsg::Confirmed)); .map(|msg| matches!(msg, FrameMsg::Content(_)).then_some(FlowMsg::Confirmed));
let content_words = let content_words =
Frame::left_aligned(title, ShareWords::new(share_words_vec)).map(|msg| match msg { Frame::left_aligned(title, ShareWords::new(share_words_vec)).map(|_| None);
FrameMsg::Content(ShareWordsMsg::GoPrevScreen) => Some(FlowMsg::Cancelled),
FrameMsg::Content(ShareWordsMsg::WordsSeen) => Some(FlowMsg::Confirmed),
_ => None,
});
let content_confirm = let content_confirm =
Frame::left_aligned(text_confirm, PromptScreen::new_hold_to_confirm()) Frame::left_aligned(text_confirm, PromptScreen::new_hold_to_confirm())

View File

@ -1,5 +1,6 @@
use crate::{ use crate::{
strutil::{ShortString, TString}, strutil::{ShortString, TString},
time::Duration,
ui::{ ui::{
component::text::TextStyle, component::text::TextStyle,
display, 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)] #[cfg(test)]
mod tests { mod tests {
use crate::strutil; use crate::strutil;