1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-01-03 03:50:58 +00:00

feat(core/rust): add ChoicePage animation and hold-to-move functionality

[no changelog]
This commit is contained in:
grdddj 2023-09-13 16:35:59 +02:00 committed by Jiří Musil
parent 272fb4842a
commit 568c5f135b
9 changed files with 465 additions and 63 deletions

View File

@ -13,7 +13,7 @@ use super::{loader::DEFAULT_DURATION_MS, theme};
const HALF_SCREEN_BUTTON_WIDTH: i16 = constant::WIDTH / 2 - 1;
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum ButtonPos {
Left,
Middle,

View File

@ -3,8 +3,9 @@ use super::{
};
use crate::{
strutil::StringType,
time::Duration,
ui::{
component::{base::Event, Component, EventCtx, Pad},
component::{base::Event, Component, EventCtx, Pad, TimerToken},
event::{ButtonEvent, PhysicalButton},
geometry::Rect,
},
@ -195,6 +196,9 @@ where
///
/// Only "final" button events are returned in `ButtonControllerMsg::Triggered`,
/// based upon the buttons being long-press or not.
///
/// There is optional complexity with IgnoreButtonDelay, which gets executed
/// only in cases where clients request it.
pub struct ButtonController<T>
where
T: StringType,
@ -204,10 +208,12 @@ where
middle_btn: ButtonContainer<T>,
right_btn: ButtonContainer<T>,
state: ButtonState,
// Button area is needed so the buttons
// can be "re-placed" after their text is changed
// Will be set in `place`
/// Button area is needed so the buttons
/// can be "re-placed" after their text is changed
/// Will be set in `place`
button_area: Rect,
/// Handling optional ignoring of buttons after pressing the other button.
ignore_btn_delay: Option<IgnoreButtonDelay>,
}
impl<T> ButtonController<T>
@ -222,9 +228,17 @@ where
right_btn: ButtonContainer::new(ButtonPos::Right, btn_layout.btn_right),
state: ButtonState::Nothing,
button_area: Rect::zero(),
ignore_btn_delay: None,
}
}
/// Set up the logic to ignore a button after some time from pressing
/// the other button.
pub fn with_ignore_btn_delay(mut self, delay_ms: u32) -> Self {
self.ignore_btn_delay = Some(IgnoreButtonDelay::new(delay_ms));
self
}
/// Updating all the three buttons to the wanted states.
pub fn set(&mut self, btn_layout: ButtonLayout<T>) {
self.pad.clear();
@ -240,6 +254,14 @@ where
self.right_btn.set_pressed(ctx, right);
}
pub fn highlight_button(&mut self, ctx: &mut EventCtx, pos: ButtonPos) {
match pos {
ButtonPos::Left => self.left_btn.set_pressed(ctx, true),
ButtonPos::Middle => self.middle_btn.set_pressed(ctx, true),
ButtonPos::Right => self.right_btn.set_pressed(ctx, true),
}
}
/// Handle middle button hold-to-confirm start.
/// We need to cancel possible holds in both other buttons.
fn middle_hold_started(&mut self, ctx: &mut EventCtx) {
@ -291,8 +313,14 @@ where
// _ _
ButtonState::Nothing => match button_event {
// ▼ * | * ▼
ButtonEvent::ButtonPressed(which) => (
ButtonEvent::ButtonPressed(which) => {
// ↓ _ | _ ↓
// Initial button press will set the timer for second button,
// and after some delay, it will become un-clickable
if let Some(ignore_btn_delay) = &mut self.ignore_btn_delay {
ignore_btn_delay.handle_button_press(ctx, which);
}
(
ButtonState::OneDown(which),
match which {
// ▼ *
@ -306,7 +334,8 @@ where
Some(ButtonControllerMsg::Pressed(ButtonPos::Right))
}
},
),
)
}
_ => (self.state, None),
},
// ↓ _ | _ ↓
@ -314,18 +343,32 @@ where
// ▲ * | * ▲
ButtonEvent::ButtonReleased(b) if b == which_down => match which_down {
// ▲ *
// Trigger the button and make the second one clickable in all cases
PhysicalButton::Left => {
if let Some(ignore_btn_delay) = &mut self.ignore_btn_delay {
ignore_btn_delay.make_button_clickable(ButtonPos::Right);
}
// _ _
(ButtonState::Nothing, self.left_btn.maybe_trigger(ctx))
}
// * ▲
PhysicalButton::Right => {
if let Some(ignore_btn_delay) = &mut self.ignore_btn_delay {
ignore_btn_delay.make_button_clickable(ButtonPos::Left);
}
// _ _
(ButtonState::Nothing, self.right_btn.maybe_trigger(ctx))
}
},
// * ▼ | ▼ *
ButtonEvent::ButtonPressed(b) if b != which_down => {
// Buttons may be non-clickable (caused by long-holding the other
// button)
if let Some(ignore_btn_delay) = &self.ignore_btn_delay {
if ignore_btn_delay.ignore_button(b) {
return None;
}
}
self.middle_hold_started(ctx);
(
// ↓ ↓
@ -356,6 +399,11 @@ where
// ▲ * | * ▲
ButtonEvent::ButtonReleased(b) if b != which_up => {
// _ _
// Both button needs to be clickable now
if let Some(ignore_btn_delay) = &mut self.ignore_btn_delay {
ignore_btn_delay.make_button_clickable(ButtonPos::Left);
ignore_btn_delay.make_button_clickable(ButtonPos::Right);
}
(ButtonState::Nothing, self.middle_btn.maybe_trigger(ctx))
}
_ => (self.state, None),
@ -394,8 +442,13 @@ where
self.state = new_state;
event
}
// HoldToConfirm expiration
Event::Timer(_) => self.handle_htc_expiration(ctx, event),
// Timer - handle clickable properties and HoldToConfirm expiration
Event::Timer(token) => {
if let Some(ignore_btn_delay) = &mut self.ignore_btn_delay {
ignore_btn_delay.handle_timer_token(token);
}
self.handle_htc_expiration(ctx, event)
}
_ => None,
}
}
@ -421,6 +474,179 @@ where
}
}
/// When one button is pressed, the other one becomes un-clickable after some
/// small time period. This is to prevent accidental clicks when user is holding
/// the button to automatically move through items.
struct IgnoreButtonDelay {
/// How big is the delay after the button is inactive
delay: Duration,
/// Whether left button is currently clickable
left_clickable: bool,
/// Whether right button is currently clickable
right_clickable: bool,
/// Timer for setting the left_clickable
left_clickable_timer: Option<TimerToken>,
/// Timer for setting the right_clickable
right_clickable_timer: Option<TimerToken>,
}
impl IgnoreButtonDelay {
pub fn new(delay_ms: u32) -> Self {
Self {
delay: Duration::from_millis(delay_ms),
left_clickable: true,
right_clickable: true,
left_clickable_timer: None,
right_clickable_timer: None,
}
}
pub fn make_button_clickable(&mut self, pos: ButtonPos) {
match pos {
ButtonPos::Left => {
self.left_clickable = true;
self.left_clickable_timer = None;
}
ButtonPos::Right => {
self.right_clickable = true;
self.right_clickable_timer = None;
}
ButtonPos::Middle => {}
}
}
pub fn handle_button_press(&mut self, ctx: &mut EventCtx, button: PhysicalButton) {
if matches!(button, PhysicalButton::Left) {
self.right_clickable_timer = Some(ctx.request_timer(self.delay));
}
if matches!(button, PhysicalButton::Right) {
self.left_clickable_timer = Some(ctx.request_timer(self.delay));
}
}
pub fn ignore_button(&self, button: PhysicalButton) -> bool {
if matches!(button, PhysicalButton::Left) && !self.left_clickable {
return true;
}
if matches!(button, PhysicalButton::Right) && !self.right_clickable {
return true;
}
false
}
pub fn handle_timer_token(&mut self, token: TimerToken) {
if self.left_clickable_timer == Some(token) {
self.left_clickable = false;
self.left_clickable_timer = None;
}
if self.right_clickable_timer == Some(token) {
self.right_clickable = false;
self.right_clickable_timer = None;
}
}
}
/// Component allowing for automatically moving through items (e.g. Choice
/// items).
///
/// Users are in full control of starting/stopping the movement.
///
/// Can be started e.g. by holding left/right button.
pub struct AutomaticMover {
/// For requesting timer events repeatedly
timer_token: Option<TimerToken>,
/// Which direction should we go (which button is down)
moving_direction: Option<ButtonPos>,
/// How many screens were moved automatically
auto_moved_screens: usize,
/// Function to get duration of each movement according to the already moved
/// steps
duration_func: fn(usize) -> u32,
}
impl AutomaticMover {
pub fn new() -> Self {
fn default_duration_func(steps: usize) -> u32 {
match steps {
x if x < 3 => 200,
x if x < 10 => 150,
_ => 100,
}
}
Self {
timer_token: None,
moving_direction: None,
auto_moved_screens: 0,
duration_func: default_duration_func,
}
}
pub fn with_duration_func(mut self, duration_func: fn(usize) -> u32) -> Self {
self.duration_func = duration_func;
self
}
/// Determines how long to wait between automatic movements.
/// Moves quicker with increasing number of screens moved.
/// Can be forced to be always the same (e.g. for animation purposes).
fn get_auto_move_duration(&self) -> Duration {
// Calculating duration from function
let ms_duration = (self.duration_func)(self.auto_moved_screens);
Duration::from_millis(ms_duration)
}
/// In which direction we are moving, if any
pub fn moving_direction(&self) -> Option<ButtonPos> {
self.moving_direction
}
// Whether we are currently moving.
pub fn is_moving(&self) -> bool {
self.moving_direction.is_some()
}
/// Whether we have done at least one automatic movement.
pub fn was_moving(&self) -> bool {
self.auto_moved_screens > 0
}
pub fn start_moving(&mut self, ctx: &mut EventCtx, button: ButtonPos) {
self.auto_moved_screens = 0;
self.moving_direction = Some(button);
self.timer_token = Some(ctx.request_timer(self.get_auto_move_duration()));
}
pub fn stop_moving(&mut self) {
self.moving_direction = None;
self.timer_token = None;
}
}
impl Component for AutomaticMover {
type Msg = ButtonPos;
fn place(&mut self, bounds: Rect) -> Rect {
bounds
}
fn paint(&mut self) {}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
// Moving automatically only when we receive a TimerToken that we have
// requested before
if let Event::Timer(token) = event {
if self.timer_token == Some(token) && self.moving_direction.is_some() {
// Request new token and send the appropriate button trigger event
self.timer_token = Some(ctx.request_timer(self.get_auto_move_duration()));
self.auto_moved_screens += 1;
return self.moving_direction;
}
}
None
}
}
// DEBUG-ONLY SECTION BELOW
#[cfg(feature = "ui_debug")]
@ -443,3 +669,10 @@ impl<T: StringType> crate::trace::Trace for ButtonController<T> {
t.child("right_btn", &self.right_btn);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for AutomaticMover {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("AutomaticMover");
}
}

View File

@ -30,6 +30,9 @@ where
page_counter: usize,
return_confirmed_index: bool,
show_scrollbar: bool,
/// Possibly enforcing the second button to be ignored after some time after
/// pressing the first button
ignore_second_button_ms: Option<u32>,
}
impl<F, T> Flow<F, T>
@ -55,6 +58,7 @@ where
page_counter: 0,
return_confirmed_index: false,
show_scrollbar: true,
ignore_second_button_ms: None,
}
}
@ -77,6 +81,12 @@ where
self
}
/// Ignoring the second button duration
pub fn with_ignore_second_button_ms(mut self, ignore_second_button_ms: u32) -> Self {
self.ignore_second_button_ms = Some(ignore_second_button_ms);
self
}
pub fn confirmed_index(&self) -> Option<usize> {
self.return_confirmed_index.then_some(self.page_counter)
}
@ -225,7 +235,14 @@ where
// We finally found how long is the first page, and can set its button layout.
self.current_page.place(content_area);
if let Some(ignore_ms) = self.ignore_second_button_ms {
self.buttons = Child::new(
ButtonController::new(self.current_page.btn_layout())
.with_ignore_btn_delay(ignore_ms),
);
} else {
self.buttons = Child::new(ButtonController::new(self.current_page.btn_layout()));
}
self.pad.place(title_content_area);
self.buttons.place(button_area);

View File

@ -3,10 +3,13 @@ use crate::{
ui::{
component::{Child, Component, Event, EventCtx, Pad},
geometry::{Insets, Offset, Rect},
util::animation_disabled,
},
};
use super::super::{theme, ButtonController, ButtonControllerMsg, ButtonLayout, ButtonPos};
use super::super::{
constant, theme, AutomaticMover, ButtonController, ButtonControllerMsg, ButtonLayout, ButtonPos,
};
const DEFAULT_ITEMS_DISTANCE: i16 = 10;
@ -79,6 +82,13 @@ where
/// Whether the middle selected item should be painted with
/// inverse colors - black on white.
inverse_selected_item: bool,
/// For moving through the items when holding left/right button
holding_mover: AutomaticMover,
/// For doing quick animations when changing the page counter.
animation_mover: AutomaticMover,
/// How many animated steps we should still do (positive for right, negative
/// for left).
animated_steps_to_do: i16,
}
impl<F, T, A> ChoicePage<F, T, A>
@ -89,16 +99,30 @@ where
pub fn new(choices: F) -> Self {
let initial_btn_layout = choices.get(0).0.btn_layout();
/// First move happens immediately, then in 35 ms intervals
fn animation_duration_func(steps: usize) -> u32 {
match steps {
0 => 0,
_ => 35,
}
}
Self {
choices,
pad: Pad::with_background(theme::BG),
buttons: Child::new(ButtonController::new(initial_btn_layout)),
buttons: Child::new(
ButtonController::new(initial_btn_layout)
.with_ignore_btn_delay(constant::IGNORE_OTHER_BTN_MS),
),
page_counter: 0,
items_distance: DEFAULT_ITEMS_DISTANCE,
is_carousel: false,
show_incomplete: false,
show_only_one_item: false,
inverse_selected_item: false,
holding_mover: AutomaticMover::new(),
animation_mover: AutomaticMover::new().with_duration_func(animation_duration_func),
animated_steps_to_do: 0,
}
}
@ -107,7 +131,10 @@ where
pub fn with_initial_page_counter(mut self, page_counter: usize) -> Self {
self.page_counter = page_counter;
let initial_btn_layout = self.get_current_choice().0.btn_layout();
self.buttons = Child::new(ButtonController::new(initial_btn_layout));
self.buttons = Child::new(
ButtonController::new(initial_btn_layout)
.with_ignore_btn_delay(constant::IGNORE_OTHER_BTN_MS),
);
self
}
@ -156,10 +183,34 @@ where
}
/// Navigating to the chosen page index.
pub fn set_page_counter(&mut self, ctx: &mut EventCtx, page_counter: usize) {
pub fn set_page_counter(
&mut self,
ctx: &mut EventCtx,
page_counter: usize,
do_animation: bool,
) {
// Either moving with animation or just jumping to the final position directly.
if do_animation && !animation_disabled() {
let diff = page_counter as i16 - self.page_counter as i16;
// When there would be a small number of animation frames (3 or less),
// animating in the opposite direction to make the animation longer.
self.animated_steps_to_do = match diff {
-3..=0 => diff + self.choices.count() as i16,
1..=3 => diff - self.choices.count() as i16,
_ => diff,
};
// Starting the movement immediately - either left or right.
let pos = if self.animated_steps_to_do > 0 {
ButtonPos::Right
} else {
ButtonPos::Left
};
self.animation_mover.start_moving(ctx, pos);
} else {
self.page_counter = page_counter;
self.update(ctx);
}
}
/// Display current, previous and next choices according to
/// the current ChoiceItem.
@ -356,14 +407,66 @@ where
/// whether they are long-pressed, and painting them.
fn set_buttons(&mut self, ctx: &mut EventCtx) {
let btn_layout = self.get_current_choice().0.btn_layout();
self.buttons.mutate(ctx, |_ctx, buttons| {
self.buttons.mutate(ctx, |ctx, buttons| {
buttons.set(btn_layout);
// When user holds one of the buttons, highlighting it.
if let Some(btn_down) = self.holding_mover.moving_direction() {
buttons.highlight_button(ctx, btn_down);
}
ctx.request_paint();
});
}
pub fn choice_factory(&self) -> &F {
&self.choices
}
/// Go to the choice visually on the left.
fn move_left(&mut self, ctx: &mut EventCtx) {
if self.has_previous_choice() {
self.decrease_page_counter();
self.update(ctx);
} else if self.is_carousel {
self.page_counter_to_max();
self.update(ctx);
}
}
/// Go to the choice visually on the right.
fn move_right(&mut self, ctx: &mut EventCtx) {
if self.has_next_choice() {
self.increase_page_counter();
self.update(ctx);
} else if self.is_carousel {
self.page_counter_to_zero();
self.update(ctx);
}
}
/// Possibly doing an animation movement with the choice - either left or
/// right.
fn animation_event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<ButtonPos> {
if animation_disabled() {
return None;
}
// Stopping the movement if it is moving and there are no steps left
if self.animated_steps_to_do == 0 {
if self.animation_mover.is_moving() {
self.animation_mover.stop_moving();
}
return None;
}
let animation_result = self.animation_mover.event(ctx, event);
// When about to animate, decreasing the number of steps to do.
if animation_result.is_some() {
if self.animated_steps_to_do > 0 {
self.animated_steps_to_do -= 1;
} else {
self.animated_steps_to_do += 1;
}
}
animation_result
}
}
impl<F, T, A> Component for ChoicePage<F, T, A>
@ -381,32 +484,67 @@ where
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
// Possible animation movement when setting (randomizing) the page counter.
if let Some(animation_direction) = self.animation_event(ctx, event) {
match animation_direction {
ButtonPos::Left => self.move_left(ctx),
ButtonPos::Right => self.move_right(ctx),
_ => {}
}
return None;
}
// When animation is running, ignoring all user events
if self.animation_mover.is_moving() {
return None;
}
// Possible automatic movement when user is holding left or right button.
if let Some(auto_move_direction) = self.holding_mover.event(ctx, event) {
match auto_move_direction {
ButtonPos::Left => self.move_left(ctx),
ButtonPos::Right => self.move_right(ctx),
_ => {}
}
return None;
}
let button_event = self.buttons.event(ctx, event);
// Button was "triggered" - released. Doing the appropriate action.
// Possibly stopping or starting the automatic mover.
if let Some(moving_direction) = self.holding_mover.moving_direction() {
// Stopping the automatic movement when the released button is the same as the
// direction we were moving, or when the pressed button is the
// opposite one (user does middle-click).
if matches!(button_event, Some(ButtonControllerMsg::Triggered(pos)) if pos == moving_direction)
|| matches!(button_event, Some(ButtonControllerMsg::Pressed(pos)) if pos != moving_direction)
{
self.holding_mover.stop_moving();
// Ignoring the event when it already did some automatic movements. (Otherwise
// it would do one more movement.)
if self.holding_mover.was_moving() {
return None;
}
}
} else if let Some(ButtonControllerMsg::Pressed(pos)) = button_event {
// Starting the movement when left/right button is pressed.
if matches!(pos, ButtonPos::Left | ButtonPos::Right) {
self.holding_mover.start_moving(ctx, pos);
}
}
// There was a legitimate button event - doing some action
if let Some(ButtonControllerMsg::Triggered(pos)) = button_event {
match pos {
ButtonPos::Left => {
if self.has_previous_choice() {
// Clicked BACK. Decrease the page counter.
self.decrease_page_counter();
self.update(ctx);
} else if self.is_carousel {
// In case of carousel going to the right end.
self.page_counter_to_max();
self.update(ctx);
}
self.move_left(ctx);
}
ButtonPos::Right => {
if self.has_next_choice() {
// Clicked NEXT. Increase the page counter.
self.increase_page_counter();
self.update(ctx);
} else if self.is_carousel {
// In case of carousel going to the left end.
self.page_counter_to_zero();
self.update(ctx);
}
self.move_right(ctx);
}
ButtonPos::Middle => {
// Clicked SELECT. Send current choice index
@ -415,7 +553,7 @@ where
}
}
};
// The middle button was "pressed", highlighting the current choice by color
// The middle button was pressed, highlighting the current choice by color
// inversion.
if let Some(ButtonControllerMsg::Pressed(ButtonPos::Middle)) = button_event {
self.inverse_selected_item = true;

View File

@ -323,8 +323,11 @@ where
/// Randomly choose an index in the current category
fn randomize_category_position(&mut self, ctx: &mut EventCtx) {
self.choice_page
.set_page_counter(ctx, random_category_position(&self.current_category));
self.choice_page.set_page_counter(
ctx,
random_category_position(&self.current_category),
true,
);
}
}

View File

@ -219,8 +219,9 @@ where
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
// Any event when showing real PIN should hide it
// Any non-timer event when showing real PIN should hide it
// Same with showing last digit
if !matches!(event, Event::Timer(_)) {
if self.show_real_pin {
self.show_real_pin = false;
self.update(ctx)
@ -229,6 +230,7 @@ where
self.show_last_digit = false;
self.update(ctx)
}
}
// Any button event will show the "real" prompt
if !self.showing_real_prompt {
@ -254,7 +256,7 @@ where
self.textbox.append(ctx, ch);
// Choosing random digit to be shown next
self.choice_page
.set_page_counter(ctx, get_random_digit_position());
.set_page_counter(ctx, get_random_digit_position(), true);
self.show_last_digit = true;
self.update(ctx);
None

View File

@ -13,7 +13,7 @@ pub use button::{
Button, ButtonAction, ButtonActions, ButtonContent, ButtonDetails, ButtonLayout, ButtonPos,
ButtonStyle, ButtonStyleSheet,
};
pub use button_controller::{ButtonController, ButtonControllerMsg};
pub use button_controller::{AutomaticMover, ButtonController, ButtonControllerMsg};
pub use common_messages::CancelConfirmMsg;
pub use error::ErrorScreen;
pub use hold_to_confirm::{HoldToConfirm, HoldToConfirmMsg};

View File

@ -22,3 +22,5 @@ pub const fn screen() -> Rect {
Rect::from_top_left_and_size(Point::zero(), SIZE)
}
pub const SCREEN: Rect = screen();
pub const IGNORE_OTHER_BTN_MS: u32 = 200;

View File

@ -859,7 +859,14 @@ extern "C" fn tutorial(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj
let pages = FlowPages::new(get_page, PAGE_COUNT);
let obj = LayoutObj::new(Flow::new(pages).with_scrollbar(false))?;
// Setting the ignore-second-button to mimic all the Choice pages, to teach user
// that they should really press both buttons at the same time to achieve
// middle-click.
let obj = LayoutObj::new(
Flow::new(pages)
.with_scrollbar(false)
.with_ignore_second_button_ms(constant::IGNORE_OTHER_BTN_MS),
)?;
Ok(obj.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }