1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-02-24 21:32:03 +00:00

feat(eckhart): add easing and rollback to HtC anim

- also switch corner icons to 4px width
This commit is contained in:
obrusvit 2025-02-23 17:19:20 +01:00
parent 5a2a7141c2
commit 2e6ae22ee1
12 changed files with 214 additions and 97 deletions

View File

@ -92,7 +92,7 @@ pub enum HeaderMsg {
impl Header {
pub const HEADER_HEIGHT: i16 = 96; // [px]
pub const HEADER_BUTTON_WIDTH: i16 = 56; // [px]
const HEADER_INSETS: Insets = Insets::sides(24); // [px]
pub const HEADER_INSETS: Insets = Insets::sides(24); // [px]
pub const fn new(title: TString<'static>) -> Self {
Self {

View File

@ -5,20 +5,21 @@ use crate::{
component::{Component, Event, EventCtx, Never},
display::Color,
geometry::{Offset, Rect},
lerp::Lerp,
shape::{self, Renderer},
},
};
use super::{
super::{component::Header, cshape::ScreenBorder, fonts, theme},
super::{component::Header, cshape::ScreenBorder, theme},
constant::SCREEN,
};
/// A component that displays a border that grows from the bottom of the screen
/// to the top. The animation is parametrizable by color and duration.
pub struct HoldToConfirmAnim {
/// Duration of the animation
duration: Duration,
/// Intended total duration of Hold to Confirm animation
total_duration: Duration,
/// Screen border and header overlay color
color: Color,
/// Screen border shape
@ -27,17 +28,50 @@ pub struct HoldToConfirmAnim {
timer: Stopwatch,
/// Header overlay text shown during the animation
header_overlay: Option<TString<'static>>,
/// Rollback animation state
rollback: RollbackState,
}
/// State of the rollback animation, when `stop` is called.
struct RollbackState {
/// Timer for the rollback animation
timer: Stopwatch,
/// Point in time of the growth animation when the rollback was initiated
duration: Duration,
}
impl HoldToConfirmAnim {
/// Growth duration of the bottom part of the border
const BOTTOM_DURATION: Duration = Duration::from_millis(200);
/// Growth duration of the side parts of the border
const SIDES_DURATION: Duration = Duration::from_millis(800);
/// Growth duration of the top part of the border
const TOP_DURATION: Duration = Duration::from_millis(600);
/// Duration of the rollback animation after `stop` is called
const ROLLBACK_DURATION: Duration = Duration::from_millis(600);
/// Duration after which the header overlay is shown after `start` is called
const HEADER_OVERLAY_DELAY: Duration = Duration::from_millis(300);
pub fn new() -> Self {
debug_assert!(
theme::CONFIRM_HOLD_DURATION.to_millis()
> (Self::BOTTOM_DURATION.to_millis()
+ Self::SIDES_DURATION.to_millis()
+ Self::TOP_DURATION.to_millis())
);
let default_color = theme::GREEN_LIME;
Self {
duration: theme::CONFIRM_HOLD_DURATION,
total_duration: theme::CONFIRM_HOLD_DURATION,
color: default_color,
border: ScreenBorder::new(default_color),
timer: Stopwatch::default(),
header_overlay: None,
rollback: RollbackState {
timer: Stopwatch::default(),
duration: Duration::default(),
},
}
}
@ -48,7 +82,13 @@ impl HoldToConfirmAnim {
}
pub fn with_duration(mut self, duration: Duration) -> Self {
self.duration = duration;
debug_assert!(
duration.to_millis()
> (Self::BOTTOM_DURATION.to_millis()
+ Self::SIDES_DURATION.to_millis()
+ Self::TOP_DURATION.to_millis())
);
self.total_duration = duration;
self
}
@ -62,64 +102,19 @@ impl HoldToConfirmAnim {
}
pub fn stop(&mut self) {
self.rollback.timer = Stopwatch::new_started();
self.rollback.duration = self.timer.elapsed();
self.timer = Stopwatch::new_stopped();
}
fn is_active(&self) -> bool {
self.timer.is_running_within(self.duration)
self.timer.is_running_within(self.total_duration)
}
fn get_clips(&self) -> (Rect, Option<Rect>) {
let ratio = self.timer.elapsed() / self.duration;
let bottom_width = self.border.bottom_width();
let total_height = SCREEN.height();
let total_width = SCREEN.width();
let circumference = 2 * total_height + total_width + bottom_width;
let bottom_ratio = bottom_width as f32 / circumference as f32;
let vertical_ratio = (2 * total_height) as f32 / circumference as f32;
let upper_ratio = total_width as f32 / circumference as f32;
let vertical_cut = bottom_ratio + vertical_ratio;
if ratio < bottom_ratio {
// Animate the bottom border growing horizontally.
let clip_width = ((ratio / bottom_ratio) * bottom_width as f32) as i16;
let clip_width = clip_width.clamp(0, bottom_width);
(
Rect::from_center_and_size(
SCREEN
.bottom_center()
.ofs(Offset::y(-ScreenBorder::WIDTH / 2)),
Offset::new(clip_width, ScreenBorder::WIDTH),
),
None,
)
} else if ratio < vertical_cut {
// Animate the vertical border growing from the bottom up.
let progress = (ratio - bottom_ratio) / vertical_ratio;
let clip_height = (progress * total_height as f32) as i16;
let clip_height = clip_height.clamp(0, total_height - ScreenBorder::WIDTH);
(
Rect::from_bottom_left_and_size(
SCREEN.bottom_left(),
Offset::new(total_width, clip_height),
),
None,
)
} else {
// Animate the top border growing horizontally towards center.
let progress = (ratio - vertical_cut) / upper_ratio;
let clip_width = total_width - ((progress * total_width as f32) as i16);
(
SCREEN,
Some(Rect::from_center_and_size(
SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)),
Offset::new(clip_width, ScreenBorder::WIDTH),
)),
)
}
fn is_rollback(&self) -> bool {
self.rollback
.timer
.is_running_within(Self::ROLLBACK_DURATION)
}
}
@ -132,7 +127,7 @@ impl Component for HoldToConfirmAnim {
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event {
if self.is_active() {
if self.is_active() || self.is_rollback() {
ctx.request_anim_frame();
ctx.request_paint();
}
@ -141,37 +136,160 @@ impl Component for HoldToConfirmAnim {
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
// Rollback & Fading out animation
if self.is_rollback() {
let rollback_elapsed = self.rollback.timer.elapsed();
let alpha = self.get_rollback_alpha(rollback_elapsed);
let rollback_duration_progressed = self
.rollback
.duration
.checked_add(rollback_elapsed)
.unwrap_or(Duration::default());
let (clip, top_gap) = self.get_clips(rollback_duration_progressed);
let top_back_rollback = self.get_top_gap_rollback(rollback_elapsed);
let top_gap = top_gap.union(top_back_rollback);
self.render_clipped_border(clip, top_gap, alpha, target);
}
// Growing animation
if self.is_active() {
// override header with custom text
if let Some(text) = self.header_overlay {
let font = fonts::FONT_SATOSHI_REGULAR_22;
let header_pad = Rect::from_top_left_and_size(
SCREEN.top_left(),
Offset::new(SCREEN.width(), Header::HEADER_HEIGHT),
);
shape::Bar::new(header_pad)
.with_bg(theme::BG)
.render(target);
text.map(|text| {
let text_pos = header_pad.top_left()
+ Offset::new(24, font.vert_center(0, Header::HEADER_HEIGHT, text));
shape::Text::new(text_pos, text, font)
.with_fg(self.color)
.render(target);
});
if self.timer.elapsed() > Self::HEADER_OVERLAY_DELAY {
self.render_header_overlay(target);
}
// growing border
let (in_clip, out_clip_opt) = self.get_clips();
target.in_clip(in_clip, &|target| {
self.border.render(target);
});
// optional out clip for upper line rendering
if let Some(out_clip) = out_clip_opt {
shape::Bar::new(out_clip)
.with_bg(theme::BG)
.with_fg(theme::BG)
.render(target);
}
let (clip, top_gap) = self.get_clips(self.timer.elapsed());
self.render_clipped_border(clip, top_gap, u8::MAX, target);
}
}
}
// Rendering helpers
impl HoldToConfirmAnim {
fn render_header_overlay<'s>(&'s self, target: &mut impl Renderer<'s>) {
if let Some(text) = self.header_overlay {
let font = theme::label_title_main().text_font;
let header_pad = Rect::from_top_left_and_size(
SCREEN.top_left(),
Offset::new(SCREEN.width(), Header::HEADER_HEIGHT),
);
// FIXME: vert_center is precisely aligned with the `Header` title (which uses `Label`)
// but this solution might break with `Header` changes
let text_offset = Offset::new(
Header::HEADER_INSETS.left,
font.vert_center(0, Header::HEADER_HEIGHT - 1, "A"),
);
let text_pos = header_pad.top_left() + text_offset;
shape::Bar::new(header_pad)
.with_bg(theme::BG)
.render(target);
text.map(|text| {
shape::Text::new(text_pos, text, font)
.with_fg(self.color)
.render(target);
});
}
}
fn render_clipped_border<'s>(
&'s self,
clip: Rect,
top_gap: Rect,
alpha: u8,
target: &mut impl Renderer<'s>,
) {
target.in_clip(clip, &|target| {
self.border.render(alpha, target);
});
// optional out clip for upper line rendering
shape::Bar::new(top_gap)
.with_bg(theme::BG)
.with_fg(theme::BG)
.render(target);
}
fn get_rollback_alpha(&self, elapsed: Duration) -> u8 {
let progress = (elapsed / Self::ROLLBACK_DURATION).clamp(0.0, 1.0);
let shift = pareen::constant(0.0).seq_ease_out(
0.0,
easer::functions::Cubic,
1.0,
pareen::constant(1.0),
);
u8::lerp(u8::MAX, u8::MIN, shift.eval(progress))
}
fn get_top_gap_rollback(&self, elapsed: Duration) -> Rect {
let progress = (elapsed / Self::ROLLBACK_DURATION).clamp(0.0, 1.0);
let clip_width = (progress * SCREEN.width() as f32) as i16;
Rect::from_center_and_size(
SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)),
Offset::new(clip_width, ScreenBorder::WIDTH),
)
}
fn get_clips(&self, elapsed: Duration) -> (Rect, Rect) {
// Define segment-specific timings
let bottom_dur_ratio = Self::BOTTOM_DURATION / self.total_duration;
let sides_dur_ratio = Self::SIDES_DURATION / self.total_duration;
let top_dur_ratio = Self::TOP_DURATION / self.total_duration;
let progress = (elapsed / self.total_duration).clamp(0.0, 1.0);
const TOP_GAP_ZERO: Rect = Rect::from_center_and_size(
SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)),
Offset::zero(),
);
const TOP_GAP_FULL: Rect = Rect::from_center_and_size(
SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)),
Offset::new(SCREEN.width(), ScreenBorder::WIDTH),
);
match progress {
// Bottom phase growing linearly
p if p < bottom_dur_ratio => {
let bottom_progress = (p / bottom_dur_ratio).clamp(0.0, 1.0);
let width = i16::lerp(0, SCREEN.width(), bottom_progress);
let clip = Rect::from_center_and_size(
SCREEN
.bottom_center()
.ofs(Offset::y(-ScreenBorder::WIDTH / 2)),
Offset::new(width, ScreenBorder::WIDTH),
);
(clip, TOP_GAP_FULL)
}
// Sides phase growing up linearly
p if p < (bottom_dur_ratio + sides_dur_ratio) => {
let sides_progress = ((p - bottom_dur_ratio) / sides_dur_ratio).clamp(0.0, 1.0);
let height = i16::lerp(ScreenBorder::WIDTH, SCREEN.height(), sides_progress);
let clip = Rect::from_bottom_left_and_size(
SCREEN.bottom_left(),
Offset::new(SCREEN.width(), height),
);
(clip, TOP_GAP_FULL)
}
// Top phase
p if p < 1.0 => {
let top_progress =
((p - bottom_dur_ratio - sides_dur_ratio) / top_dur_ratio).clamp(0.0, 1.0);
let ease = pareen::constant(0.0).seq_ease_out(
0.0,
easer::functions::Cubic,
1.0,
pareen::constant(1.0),
);
let eased_progress = ease.eval(top_progress);
let width = i16::lerp(SCREEN.width(), 0, eased_progress);
let top_gap = Rect::from_center_and_size(
SCREEN.top_center().ofs(Offset::y(ScreenBorder::WIDTH / 2)),
Offset::new(width, ScreenBorder::WIDTH),
);
(SCREEN, top_gap)
}
// Animation complete
_ => (SCREEN, TOP_GAP_ZERO),
}
}
}

View File

@ -16,7 +16,7 @@ pub struct ScreenBorder {
}
impl ScreenBorder {
pub const WIDTH: i16 = 2;
pub const WIDTH: i16 = 4;
pub fn new(color: Color) -> Self {
let screen = constant::screen();
@ -26,14 +26,14 @@ impl ScreenBorder {
x0: screen.x0 + ICON_BORDER_TL.toif.width(),
y0: screen.y0,
x1: screen.x1 - ICON_BORDER_TR.toif.width(),
y1: screen.y0 + 2,
y1: screen.y0 + Self::WIDTH,
};
// Bottom bar: from the right edge of bottom-left icon to the left edge of
// bottom-right icon.
let bottom_bar_rect = Rect {
x0: screen.x0 + ICON_BORDER_BL.toif.width(),
y0: screen.y1 - 2,
y0: screen.y1 - Self::WIDTH,
x1: screen.x1 - ICON_BORDER_BR.toif.width(),
y1: screen.y1,
};
@ -42,15 +42,15 @@ impl ScreenBorder {
// bottom-left icon.
let left_bar_rect = Rect {
x0: screen.x0,
y0: screen.y0 + ICON_BORDER_TL.toif.height() - 1,
x1: screen.x0 + 2,
y0: screen.y0 + ICON_BORDER_TL.toif.height(),
x1: screen.x0 + Self::WIDTH,
y1: screen.y1 - ICON_BORDER_BL.toif.height(),
};
// Right bar: from the bottom edge of top-right icon to the top edge of
// bottom-right icon.
let right_bar_rect = Rect {
x0: screen.x1 - 2,
y0: screen.y0 + ICON_BORDER_TR.toif.height() - 1,
x0: screen.x1 - Self::WIDTH,
y0: screen.y0 + ICON_BORDER_TR.toif.height(),
x1: screen.x1,
y1: screen.y1 - ICON_BORDER_BR.toif.height(),
};
@ -70,8 +70,7 @@ impl ScreenBorder {
// Draw the four side bars.
self.side_bars.iter().for_each(|bar| {
shape::Bar::new(*bar)
.with_fg(self.color)
.with_thickness(2)
.with_bg(self.color)
.with_alpha(alpha)
.render(target);
});
@ -102,8 +101,8 @@ impl ScreenBorder {
.iter()
.for_each(|(position, toif, alignment)| {
shape::ToifImage::new(*position, *toif)
.with_fg(self.color)
.with_align(*alignment)
.with_fg(self.color)
.with_alpha(alpha)
.render(target);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 B

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 B

After

Width:  |  Height:  |  Size: 245 B

View File

@ -12,7 +12,7 @@ use super::{
fonts,
};
pub const CONFIRM_HOLD_DURATION: Duration = Duration::from_millis(1500);
pub const CONFIRM_HOLD_DURATION: Duration = Duration::from_millis(2000);
pub const ERASE_HOLD_DURATION: Duration = Duration::from_millis(1500);
// Color palette.