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 where F: ChoiceFactory, { choices: F, pad: Pad, buttons: Child, 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 ChoicePage where F: ChoiceFactory, { 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, 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) -> (::Item, A) { self.choices.get(self.page_counter) } /// Getting the current item pub fn get_current_item(&self) -> ::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 { 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 Component for ChoicePage where F: ChoiceFactory, { 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 { // 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 crate::trace::Trace for ChoicePage where F: 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); } }