1
0
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:
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::{
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,

View File

@ -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.

View File

@ -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);

View File

@ -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)
}

View File

@ -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)

View File

@ -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::*;

View File

@ -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},

View File

@ -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};

View File

@ -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()
}
}

View File

@ -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())

View File

@ -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;