You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
trezor-firmware/core/embed/rust/src/ui/model_tr/component/input_methods/choice.rs

624 lines
22 KiB

use crate::ui::{
component::{Child, Component, Event, EventCtx, Pad},
geometry::{Insets, Offset, Rect},
util::animation_disabled,
};
use super::super::{
constant, theme, AutomaticMover, ButtonController, ButtonControllerMsg, ButtonLayout, ButtonPos,
};
const DEFAULT_ITEMS_DISTANCE: i16 = 10;
pub trait Choice {
// Only `paint_center` is required, the rest is optional
// and therefore has a default implementation.
fn paint_center(&self, area: Rect, inverse: bool);
fn width_center(&self) -> i16 {
0
}
fn paint_side(&self, _area: Rect) {}
fn width_side(&self) -> i16 {
0
}
fn btn_layout(&self) -> ButtonLayout;
/// Whether it is possible to do the middle action event without
/// releasing the button - after long-press duration is reached.
fn trigger_middle_without_release(&self) -> bool {
false
}
}
/// Interface for a specific component efficiently giving
/// `ChoicePage` all the information it needs to render
/// all the choice pages.
///
/// It avoids the need to store the whole sequence of
/// items in `heapless::Vec` (which caused StackOverflow),
/// but offers a "lazy-loading" way of requesting the
/// items only when they are needed, one-by-one.
/// This way, no more than one item is stored in memory at any time.
pub trait ChoiceFactory {
type Action;
type Item: Choice;
fn count(&self) -> usize;
fn get(&self, index: usize) -> (Self::Item, Self::Action);
}
/// General component displaying a set of items on the screen
/// and allowing the user to select one of them.
///
/// To be used by other more specific components that will
/// supply a set of `Choice`s (through `ChoiceFactory`)
/// and will receive back the index of the selected choice.
///
/// Each `Choice` is responsible for setting the screen -
/// choosing the button text, their duration, text displayed
/// on screen etc.
///
/// `is_carousel` can be used to make the choice page "infinite" -
/// after reaching one end, users will appear at the other end.
pub struct ChoicePage<F, A>
where
F: ChoiceFactory<Action = A>,
{
choices: F,
pad: Pad,
buttons: Child<ButtonController>,
page_counter: usize,
/// How many pixels are between the items.
items_distance: i16,
/// Whether the choice page is "infinite" (carousel).
is_carousel: bool,
/// Whether we should show items on left/right even when they cannot
/// be painted entirely (they would be cut off).
show_incomplete: bool,
/// Whether to show only the currently selected item, nothing left/right.
show_only_one_item: bool,
/// 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, A> ChoicePage<F, A>
where
F: ChoiceFactory<Action = A>,
{
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)
.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,
}
}
/// Set the page counter at the very beginning.
/// Need to update the initial button layout.
pub fn with_initial_page_counter(mut self, page_counter: usize) -> Self {
self.page_counter = page_counter;
let initial_btn_layout = self.get_current_item().btn_layout();
self.buttons = Child::new(
ButtonController::new(initial_btn_layout)
.with_ignore_btn_delay(constant::IGNORE_OTHER_BTN_MS),
);
self
}
/// Enabling the carousel mode.
pub fn with_carousel(mut self, carousel: bool) -> Self {
self.is_carousel = carousel;
self
}
/// Show incomplete items, even when they cannot render in their entirety.
pub fn with_incomplete(mut self, show_incomplete: bool) -> Self {
self.show_incomplete = show_incomplete;
self
}
/// Show only the currently selected item, nothing left/right.
pub fn with_only_one_item(mut self, only_one_item: bool) -> Self {
self.show_only_one_item = only_one_item;
self
}
/// Adjust the distance between the items.
pub fn with_items_distance(mut self, items_distance: i16) -> Self {
self.items_distance = items_distance;
self
}
/// Resetting the component, which enables reusing the same instance
/// for multiple choice categories.
///
/// Used for example in passphrase, where there are multiple categories of
/// characters.
pub fn reset(
&mut self,
ctx: &mut EventCtx,
new_choices: F,
new_page_counter: Option<usize>,
is_carousel: bool,
) {
self.choices = new_choices;
if let Some(new_counter) = new_page_counter {
self.page_counter = new_counter;
}
self.is_carousel = is_carousel;
self.update(ctx);
}
/// Navigating to the chosen page index.
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.
fn paint_choices(&mut self) {
// Getting the row area for the choices - so that displaying
// items in the used font will show them in the middle vertically.
let area_height_half = self.pad.area.height() / 2;
let font_size_half = theme::FONT_CHOICE_ITEMS.visible_text_height("Ay") / 2;
let center_row_area = self
.pad
.area
.split_top(area_height_half)
.0
.outset(Insets::bottom(font_size_half));
// Drawing the current item in the middle.
self.show_current_choice(center_row_area);
// Not drawing the rest when not wanted
if self.show_only_one_item {
return;
}
// Getting the remaining left and right areas.
let center_width = self.get_current_item().width_center();
let (left_area, _center_area, right_area) = center_row_area.split_center(center_width);
// Possibly drawing on the left side.
if self.has_previous_choice() || self.is_carousel {
self.show_left_choices(left_area);
}
// Possibly drawing on the right side.
if self.has_next_choice() || self.is_carousel {
self.show_right_choices(right_area);
}
}
/// Setting current buttons, and clearing.
fn update(&mut self, ctx: &mut EventCtx) {
self.set_buttons(ctx);
self.clear_and_repaint(ctx);
}
/// Clearing the whole area and requesting repaint.
fn clear_and_repaint(&mut self, ctx: &mut EventCtx) {
self.pad.clear();
ctx.request_paint();
}
/// Index of the last page.
fn last_page_index(&self) -> usize {
self.choices.count() - 1
}
/// Whether there is a previous choice (on the left).
pub fn has_previous_choice(&self) -> bool {
self.page_counter > 0
}
/// Whether there is a next choice (on the right).
pub fn has_next_choice(&self) -> bool {
self.page_counter < self.last_page_index()
}
/// Getting the choice on the current index
fn get_current_choice(&self) -> (<F as ChoiceFactory>::Item, A) {
self.choices.get(self.page_counter)
}
/// Getting the current item
pub fn get_current_item(&self) -> <F as ChoiceFactory>::Item {
self.get_current_choice().0
}
/// Getting the current action
pub fn get_current_action(&self) -> A {
self.get_current_choice().1
}
/// Display the current choice in the middle.
fn show_current_choice(&mut self, area: Rect) {
self.get_current_item()
.paint_center(area, self.inverse_selected_item);
}
/// Display all the choices fitting on the left side.
/// Going as far as possible.
fn show_left_choices(&self, area: Rect) {
// NOTE: page index can get negative here, so having it as i16 instead of usize
let mut page_index = self.page_counter as i16 - 1;
let mut current_area = area.split_right(self.items_distance).0;
while current_area.width() > 0 {
// Breaking out of the loop if we exhausted left items
// and the carousel mode is not enabled.
if page_index < 0 {
if self.is_carousel {
// Moving to the last page.
page_index = self.last_page_index() as i16;
} else {
break;
}
}
let (choice, _) = self.choices.get(page_index as usize);
let choice_width = choice.width_side();
if current_area.width() <= choice_width && !self.show_incomplete {
// early break for an item that will not fit the remaining space
break;
}
// We need to calculate the area explicitly because we want to allow it
// to exceed the bounds of the original area.
let choice_area = Rect::from_top_right_and_size(
current_area.top_right(),
Offset::new(choice_width, current_area.height()),
);
choice.paint_side(choice_area);
// Updating loop variables.
current_area = current_area
.split_right(choice_width + self.items_distance)
.0;
page_index -= 1;
}
}
/// Display all the choices fitting on the right side.
/// Going as far as possible.
fn show_right_choices(&self, area: Rect) {
let mut page_index = self.page_counter + 1;
let mut current_area = area.split_left(self.items_distance).1;
while current_area.width() > 0 {
// Breaking out of the loop if we exhausted right items
// and the carousel mode is not enabled.
if page_index > self.last_page_index() {
if self.is_carousel {
// Moving to the first page.
page_index = 0;
} else {
break;
}
}
let (choice, _) = self.choices.get(page_index);
let choice_width = choice.width_side();
if current_area.width() <= choice_width && !self.show_incomplete {
// early break for an item that will not fit the remaining space
break;
}
// We need to calculate the area explicitly because we want to allow it
// to exceed the bounds of the original area.
let choice_area = Rect::from_top_left_and_size(
current_area.top_left(),
Offset::new(choice_width, current_area.height()),
);
choice.paint_side(choice_area);
// Updating loop variables.
current_area = current_area
.split_left(choice_width + self.items_distance)
.1;
page_index += 1;
}
}
/// Decrease the page counter to the previous page.
fn decrease_page_counter(&mut self) {
self.page_counter -= 1;
}
/// Advance page counter to the next page.
fn increase_page_counter(&mut self) {
self.page_counter += 1;
}
/// Set page to the first one.
fn page_counter_to_zero(&mut self) {
self.page_counter = 0;
}
/// Set page to the last one.
fn page_counter_to_max(&mut self) {
self.page_counter = self.last_page_index();
}
/// Get current page counter.
pub fn page_index(&self) -> usize {
self.page_counter
}
/// Updating the visual state of the buttons after each event.
/// All three buttons are handled based upon the current choice.
/// If defined in the current choice, setting their text,
/// whether they are long-pressed, and painting them.
fn set_buttons(&mut self, ctx: &mut EventCtx) {
let btn_layout = self.get_current_item().btn_layout();
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, A> Component for ChoicePage<F, A>
where
F: ChoiceFactory<Action = A>,
{
type Msg = (A, bool);
fn place(&mut self, bounds: Rect) -> Rect {
let (content_area, button_area) = bounds.split_bottom(theme::BUTTON_HEIGHT);
self.pad.place(content_area);
self.buttons.place(button_area);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
// Cancel highlighting of the current choice.
// The Highlighting is started by pressing the middle button and
// canceled immediately when any other event is processed
self.inverse_selected_item = false;
// 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);
// 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, long_press)) = button_event {
match pos {
ButtonPos::Left => {
// Clicked BACK. Decrease the page counter.
// In case of carousel going to the right end.
self.move_left(ctx);
}
ButtonPos::Right => {
// Clicked NEXT. Increase the page counter.
// In case of carousel going to the left end.
self.move_right(ctx);
}
ButtonPos::Middle => {
// Clicked SELECT. Send current choice index with information about long-press
self.clear_and_repaint(ctx);
return Some((self.get_current_action(), long_press));
}
}
};
// The middle button was pressed for longer time - sending the Event with long
// press. Also resetting the functional and visual state of the buttons.
// Only doing this when the item is configured to do so
if let Some(ButtonControllerMsg::LongPressed(ButtonPos::Middle)) = button_event {
if self.get_current_item().trigger_middle_without_release() {
self.buttons.mutate(ctx, |ctx, buttons| {
buttons.reset_state(ctx);
});
self.clear_and_repaint(ctx);
return Some((self.get_current_action(), true));
}
};
// 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;
self.clear_and_repaint(ctx);
};
None
}
fn paint(&mut self) {
self.pad.paint();
self.buttons.paint();
self.paint_choices();
}
}
// DEBUG-ONLY SECTION BELOW
#[cfg(feature = "ui_debug")]
impl<F, A> crate::trace::Trace for ChoicePage<F, A>
where
F: ChoiceFactory<Action = A>,
<F as ChoiceFactory>::Item: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("ChoicePage");
t.int("active_page", self.page_counter as i64);
t.int("page_count", self.choices.count() as i64);
t.bool("is_carousel", self.is_carousel);
if self.has_previous_choice() {
t.child("prev_choice", &self.choices.get(self.page_counter - 1).0);
} else if self.is_carousel {
// In case of carousel going to the left end.
t.child("prev_choice", &self.choices.get(self.last_page_index()).0);
}
t.child("current_choice", &self.choices.get(self.page_counter).0);
if self.has_next_choice() {
t.child("next_choice", &self.choices.get(self.page_counter + 1).0);
} else if self.is_carousel {
// In case of carousel going to the very left.
t.child("next_choice", &self.choices.get(0).0);
}
t.child("buttons", &self.buttons);
}
}