1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-03-03 08:46:05 +00:00

feat(eckhart): full-screen input number component

This commit is contained in:
Lukas Bielesch 2025-02-25 15:29:45 +01:00 committed by Lukáš Bielesch
parent 013ce4da45
commit 33de234c7a
4 changed files with 303 additions and 11 deletions

View File

@ -6,6 +6,7 @@ mod header;
mod hint;
mod hold_to_confirm;
mod keyboard;
mod number_input_screen;
mod result;
mod select_word_screen;
mod share_words;
@ -29,6 +30,7 @@ pub use keyboard::{
slip39::Slip39Input,
word_count_screen::{SelectWordCountMsg, SelectWordCountScreen},
};
pub use number_input_screen::{NumberInputScreenMsg, NumberInputScreen};
pub use result::{ResultFooter, ResultScreen, ResultStyle};
pub use select_word_screen::{SelectWordMsg, SelectWordScreen};
#[cfg(feature = "translations")]

View File

@ -0,0 +1,273 @@
use crate::{
strutil::{self, TString},
translations::TR,
ui::{
component::{Component, Event, EventCtx, Label, Maybe, Never},
geometry::{Alignment, Insets, Offset, Rect},
shape::{self, Renderer},
},
};
use super::{
super::{super::constant::SCREEN, fonts, theme},
ActionBar, ActionBarMsg, Button, ButtonMsg, Header, HeaderMsg,
};
pub enum NumberInputScreenMsg {
Cancelled,
Confirmed(u32),
Menu,
}
pub struct NumberInputScreen {
/// Screen header
header: Header,
/// Screeen description
description: Label<'static>,
/// Number input dialog
number_input: NumberInput,
/// Screen action bar
action_bar: ActionBar,
}
impl NumberInputScreen {
const DESCRIPTION_HEIGHT: i16 = 123;
const INPUT_HEIGHT: i16 = 170;
pub fn new(min: u32, max: u32, init_value: u32, text: TString<'static>) -> Self {
Self {
header: Header::new(TString::empty()),
action_bar: ActionBar::new_double(
Button::with_icon(theme::ICON_CHEVRON_UP),
Button::with_text(TR::buttons__continue.into()),
),
number_input: NumberInput::new(min, max, init_value),
description: Label::new(text, Alignment::Start, theme::TEXT_MEDIUM),
}
}
pub fn with_header(mut self, header: Header) -> Self {
self.header = header;
self
}
pub fn value(&self) -> u32 {
self.number_input.value
}
}
impl Component for NumberInputScreen {
type Msg = NumberInputScreenMsg;
fn place(&mut self, bounds: Rect) -> Rect {
// assert full screen
debug_assert_eq!(bounds.height(), SCREEN.height());
debug_assert_eq!(bounds.width(), SCREEN.width());
let (header_area, rest) = bounds.split_top(Header::HEADER_HEIGHT);
let (rest, action_bar_area) = rest.split_bottom(ActionBar::ACTION_BAR_HEIGHT);
let (description_area, rest) = rest.split_top(Self::DESCRIPTION_HEIGHT);
let (input_area, rest) = rest.split_top(Self::INPUT_HEIGHT);
// Set touch expansion for the action bar not to overlap with the input area
self.action_bar
.set_touch_expansion(Insets::top(rest.height()));
let description_area = description_area.inset(Insets::sides(24));
self.header.place(header_area);
self.description.place(description_area);
self.number_input.place(input_area);
self.action_bar.place(action_bar_area);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.number_input.event(ctx, event);
if let Some(msg) = self.header.event(ctx, event) {
match msg {
HeaderMsg::Menu => return Some(NumberInputScreenMsg::Menu),
_ => {}
}
}
if let Some(msg) = self.action_bar.event(ctx, event) {
match msg {
ActionBarMsg::Confirmed => {
return Some(NumberInputScreenMsg::Confirmed(self.value()))
}
ActionBarMsg::Cancelled => return Some(NumberInputScreenMsg::Cancelled),
_ => {}
}
}
None
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.header.render(target);
self.description.render(target);
self.number_input.render(target);
self.action_bar.render(target);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for NumberInputScreen {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("NumberInputScreen");
t.child("number_input", &self.number_input);
t.child("description", &self.description);
}
}
struct NumberInput {
area: Rect,
dec: Maybe<Button>,
inc: Maybe<Button>,
min: u32,
max: u32,
value: u32,
}
impl NumberInput {
const BUTTON_PADDING: i16 = 10;
const BUTTON_SIZE: Offset = Offset::new(138, 130);
const BORDER_PADDING: i16 = 24;
pub fn new(min: u32, max: u32, value: u32) -> Self {
let dec = Button::with_icon(theme::ICON_MINUS)
.styled(theme::button_keyboard())
.with_radius(12);
let inc = Button::with_icon(theme::ICON_PLUS)
.styled(theme::button_keyboard())
.with_radius(12);
let value = value.clamp(min, max);
Self {
area: Rect::zero(),
dec: Maybe::new(theme::BG, dec, value > min),
inc: Maybe::new(theme::BG, inc, value < max),
min,
max,
value,
}
}
fn increase(&mut self, ctx: &mut EventCtx) {
self.value = self.value.saturating_add(1).min(self.max);
self.on_change(ctx);
}
fn decrease(&mut self, ctx: &mut EventCtx) {
self.value = self.value.saturating_sub(1).max(self.min);
self.on_change(ctx);
}
fn on_change(&mut self, ctx: &mut EventCtx) {
self.dec.show_if(ctx, self.value > self.min);
self.inc.show_if(ctx, self.value < self.max);
ctx.request_paint();
}
fn render_borders<'s>(&'s self, target: &mut impl Renderer<'s>) {
let borders = self.area.inset(Insets::sides(Self::BORDER_PADDING));
// Render lines as bars with the with 1px
let top_line =
Rect::from_bottom_right_and_size(borders.top_right(), Offset::new(borders.width(), 1));
let bottom_line =
Rect::from_top_right_and_size(borders.bottom_right(), Offset::new(borders.width(), 1));
shape::Bar::new(top_line)
.with_fg(theme::GREY_EXTRA_DARK)
.with_bg(theme::GREY_EXTRA_DARK)
.render(target);
shape::Bar::new(bottom_line)
.with_fg(theme::GREY_EXTRA_DARK)
.with_bg(theme::GREY_EXTRA_DARK)
.render(target);
}
fn render_number<'s>(&'s self, target: &mut impl Renderer<'s>) {
let mut buf = [0u8; 1];
if let Some(text) = strutil::format_i64(self.value as i64, &mut buf) {
let font = fonts::FONT_SATOSHI_EXTRALIGHT_72;
let y_offset = Offset::y(font.visible_text_height(text) / 2);
shape::Text::new(self.area.center().ofs(y_offset), text, font)
.with_align(Alignment::Center)
.with_fg(theme::GREY)
.render(target);
}
}
}
impl Component for NumberInput {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
let button_bounds = self.area.inset(Insets::sides(Self::BUTTON_PADDING));
// Equivalent to from_left_center_and_size
let dec_bounds = Rect::from_center_and_size(
button_bounds
.left_center()
.ofs(Offset::x(Self::BUTTON_SIZE.x / 2)),
Self::BUTTON_SIZE,
);
// Equivalent to from_right_center_and_size
let inc_bounds = Rect::from_center_and_size(
button_bounds
.right_center()
.ofs(Offset::x(-Self::BUTTON_SIZE.x / 2)),
Self::BUTTON_SIZE,
);
self.dec.place(dec_bounds);
self.inc.place(inc_bounds);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(ButtonMsg::Clicked) = self.dec.event(ctx, event) {
self.decrease(ctx);
};
if let Some(ButtonMsg::Clicked) = self.inc.event(ctx, event) {
self.increase(ctx);
};
None
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.render_borders(target);
self.render_number(target);
self.dec.render(target);
self.inc.render(target);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for NumberInput {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("NumberInput");
t.int("value", self.value as i64);
}
}
#[cfg(test)]
mod tests {
use super::{super::super::constant::SCREEN, *};
#[test]
fn test_component_heights_fit_screen() {
assert!(
NumberInputScreen::DESCRIPTION_HEIGHT
+ NumberInputScreen::INPUT_HEIGHT
+ Header::HEADER_HEIGHT
+ ActionBar::ACTION_BAR_HEIGHT
<= SCREEN.height(),
"Components overflow the screen height",
);
}
}

View File

@ -14,9 +14,9 @@ use crate::{
};
use super::component::{
AllowedTextContent, MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg, PinKeyboard,
PinKeyboardMsg, SelectWordCountMsg, SelectWordCountScreen, SelectWordMsg, SelectWordScreen,
TextScreen, TextScreenMsg,
AllowedTextContent, MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg, NumberInputScreen,
NumberInputScreenMsg, PinKeyboard, PinKeyboardMsg, SelectWordCountMsg, SelectWordCountScreen,
SelectWordMsg, SelectWordScreen, TextScreen, TextScreenMsg,
};
impl ComponentMsgObj for PinKeyboard<'_> {
@ -99,3 +99,13 @@ impl ComponentMsgObj for SelectWordCountScreen {
}
}
}
impl ComponentMsgObj for NumberInputScreen {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
NumberInputScreenMsg::Confirmed(i) => i.try_into(),
NumberInputScreenMsg::Cancelled => Ok(CANCELLED.as_obj()),
NumberInputScreenMsg::Menu => Ok(INFO.as_obj()),
}
}
}

View File

@ -26,8 +26,9 @@ use crate::{
use super::{
component::{
ActionBar, Bip39Input, Button, Header, HeaderMsg, Hint, MnemonicKeyboard, PinKeyboard,
SelectWordCountScreen, SelectWordScreen, Slip39Input, TextScreen,
ActionBar, Bip39Input, Button, Header, HeaderMsg, Hint, MnemonicKeyboard,
NumberInputScreen, PinKeyboard, SelectWordCountScreen, SelectWordScreen, Slip39Input,
TextScreen,
},
flow, fonts, theme, UIEckhart,
};
@ -382,14 +383,20 @@ impl FirmwareUI for UIEckhart {
}
fn request_number(
_title: TString<'static>,
_count: u32,
_min_count: u32,
_max_count: u32,
_description: Option<TString<'static>>,
title: TString<'static>,
count: u32,
min_count: u32,
max_count: u32,
description: Option<TString<'static>>,
_more_info_callback: Option<impl Fn(u32) -> TString<'static> + 'static>,
) -> Result<impl LayoutMaybeTrace, Error> {
Err::<RootComponent<Empty, ModelUI>, Error>(Error::ValueError(c"not implemented"))
let description = description.unwrap_or(TString::empty());
let component = NumberInputScreen::new(min_count, max_count, count, description)
.with_header(Header::new(title).with_menu_button());
let layout = RootComponent::new(component);
Ok(layout)
}
fn request_pin(