From 9de999f13b2aff11f4648ed5e0ef6598b66df0b5 Mon Sep 17 00:00:00 2001 From: Lukas Bielesch Date: Fri, 4 Apr 2025 15:24:41 +0200 Subject: [PATCH] feat(eckhart): implement trait functions: confirm_value_intro, confirm_modify_output, confirm_modify_fee, confirm_with_info, show_info_with_cancel and confirm_props --- .../flow/confirm_value_intro.rs | 133 ++++++++++++ .../layout_eckhart/flow/confirm_with_menu.rs | 113 +++++++++++ .../rust/src/ui/layout_eckhart/flow/mod.rs | 4 + .../rust/src/ui/layout_eckhart/ui_firmware.rs | 190 +++++++++++++++--- .../src/trezor/ui/layouts/eckhart/__init__.py | 49 ++++- 5 files changed, 454 insertions(+), 35 deletions(-) create mode 100644 core/embed/rust/src/ui/layout_eckhart/flow/confirm_value_intro.rs create mode 100644 core/embed/rust/src/ui/layout_eckhart/flow/confirm_with_menu.rs diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/confirm_value_intro.rs b/core/embed/rust/src/ui/layout_eckhart/flow/confirm_value_intro.rs new file mode 100644 index 0000000000..5b60101d6e --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/flow/confirm_value_intro.rs @@ -0,0 +1,133 @@ +use crate::{ + error, + micropython::obj::Obj, + strutil::TString, + translations::TR, + ui::{ + component::{ + text::paragraphs::{Paragraph, ParagraphSource}, + ComponentExt, + }, + flow::{ + base::{Decision, DecisionBuilder as _}, + FlowController, FlowMsg, SwipeFlow, + }, + geometry::{Alignment, Direction, LinearPlacement, Offset}, + layout::util::StrOrBytes, + }, +}; + +use super::super::{ + component::Button, + firmware::{ + ActionBar, Header, TextScreen, TextScreenMsg, VerticalMenu, VerticalMenuScreen, + VerticalMenuScreenMsg, + }, + theme, +}; + +const TIMEOUT_MS: u32 = 2000; + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ConfirmValueIntro { + Intro, + Menu, +} + +impl FlowController for ConfirmValueIntro { + #[inline] + fn index(&'static self) -> usize { + *self as usize + } + + fn handle_swipe(&'static self, _direction: Direction) -> Decision { + self.do_nothing() + } + + fn handle_event(&'static self, msg: FlowMsg) -> Decision { + match (self, msg) { + (Self::Intro, FlowMsg::Confirmed) => self.return_msg(FlowMsg::Confirmed), + (Self::Intro, FlowMsg::Info) => Self::Menu.goto(), + (Self::Menu, FlowMsg::Choice(0)) => self.return_msg(FlowMsg::Info), + (Self::Menu, FlowMsg::Choice(1)) => self.return_msg(FlowMsg::Cancelled), + (Self::Menu, FlowMsg::Cancelled) => Self::Intro.goto(), + _ => self.do_nothing(), + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn new_confirm_value_intro( + title: TString<'static>, + subtitle: Option>, + value: Obj, + value_menu_label: TString<'static>, + cancel_menu_label: Option>, + confirm_button_label: Option>, + hold: bool, + chunkify: bool, +) -> Result { + let cancel_menu_label = cancel_menu_label.unwrap_or(TR::buttons__cancel.into()); + + // Intro + let mut confirm_button = Button::with_text( + confirm_button_label.unwrap_or(TR::sign_message__confirm_without_review.into()), + ) + .styled(theme::button_confirm()); + if hold { + confirm_button = confirm_button.with_long_press(theme::LOCK_HOLD_DURATION); + } + + let value = if value != Obj::const_none() { + unwrap!(value.try_into()) + } else { + StrOrBytes::Str("".into()) + }; + + let intro_style = if chunkify { + &theme::TEXT_MONO_ADDRESS_CHUNKS + } else { + &theme::TEXT_MONO_ADDRESS + }; + let content_intro = TextScreen::new( + Paragraph::new(intro_style, value.as_str_offset(0)) + .into_paragraphs() + .with_placement(LinearPlacement::vertical()), + ) + .with_page_limit(1) + .with_header(Header::new(title).with_menu_button()) + .with_subtitle(subtitle.unwrap_or(TString::empty())) + .with_action_bar(ActionBar::new_single(confirm_button)) + .map(|msg| match msg { + TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + TextScreenMsg::Menu => Some(FlowMsg::Info), + }); + + let menu_items = VerticalMenu::empty() + .item( + Button::with_text(value_menu_label) + .styled(theme::menu_item_title()) + .with_text_align(Alignment::Start) + .with_content_offset(Offset::x(12)), + ) + .item( + Button::with_text(cancel_menu_label) + .styled(theme::menu_item_title_orange()) + .with_text_align(Alignment::Start) + .with_content_offset(Offset::x(12)), + ); + + let content_menu = VerticalMenuScreen::new(menu_items) + .with_header(Header::new(TString::empty()).with_close_button()) + .map(move |msg| match msg { + VerticalMenuScreenMsg::Selected(i) => Some(FlowMsg::Choice(i)), + VerticalMenuScreenMsg::Close => Some(FlowMsg::Cancelled), + _ => None, + }); + + let mut res = SwipeFlow::new(&ConfirmValueIntro::Intro)?; + res.add_page(&ConfirmValueIntro::Intro, content_intro)? + .add_page(&ConfirmValueIntro::Menu, content_menu)?; + Ok(res) +} diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/confirm_with_menu.rs b/core/embed/rust/src/ui/layout_eckhart/flow/confirm_with_menu.rs new file mode 100644 index 0000000000..65b6576949 --- /dev/null +++ b/core/embed/rust/src/ui/layout_eckhart/flow/confirm_with_menu.rs @@ -0,0 +1,113 @@ +use crate::{ + error, + maybe_trace::MaybeTrace, + strutil::TString, + translations::TR, + ui::{ + component::ComponentExt, + flow::{ + base::{Decision, DecisionBuilder as _}, + FlowController, FlowMsg, SwipeFlow, + }, + geometry::{Alignment, Direction, Offset}, + }, +}; + +use super::super::{ + component::Button, + firmware::{ + ActionBar, AllowedTextContent, Header, TextScreen, TextScreenMsg, VerticalMenu, + VerticalMenuScreen, VerticalMenuScreenMsg, + }, + theme, +}; + +const TIMEOUT_MS: u32 = 2000; + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ConfirmWithMenu { + Value, + Menu, +} + +impl FlowController for ConfirmWithMenu { + #[inline] + fn index(&'static self) -> usize { + *self as usize + } + + fn handle_swipe(&'static self, _direction: Direction) -> Decision { + self.do_nothing() + } + + fn handle_event(&'static self, msg: FlowMsg) -> Decision { + match (self, msg) { + (Self::Value, FlowMsg::Confirmed) => self.return_msg(FlowMsg::Confirmed), + (Self::Value, FlowMsg::Info) => Self::Menu.goto(), + (Self::Menu, FlowMsg::Choice(0)) => self.return_msg(FlowMsg::Info), + (Self::Menu, FlowMsg::Choice(1)) => self.return_msg(FlowMsg::Cancelled), + (Self::Menu, FlowMsg::Cancelled) => Self::Value.goto(), + _ => self.do_nothing(), + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn new_confirm_with_menu( + title: TString<'static>, + subtitle: Option>, + content: T, + confirm_label: Option>, + hold: bool, + extra_menu_label: Option>, + cancel_menu_label: Option>, +) -> Result { + let cancel_menu_label = cancel_menu_label.unwrap_or(TR::buttons__cancel.into()); + let confirm_label = confirm_label.unwrap_or(TR::buttons__confirm.into()); + + // Value + let mut confirm_button = Button::with_text(confirm_label).styled(theme::button_confirm()); + if hold { + confirm_button = confirm_button.with_long_press(theme::LOCK_HOLD_DURATION); + } + let content_value = TextScreen::new(content) + .with_header(Header::new(title).with_menu_button()) + .with_action_bar(ActionBar::new_single(confirm_button)) + .with_subtitle(subtitle.unwrap_or(TString::empty())) + .map(|msg| match msg { + TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed), + TextScreenMsg::Cancelled => Some(FlowMsg::Cancelled), + TextScreenMsg::Menu => Some(FlowMsg::Info), + }); + + let mut menu_items = VerticalMenu::empty(); + + if let Some(extra_menu_label) = extra_menu_label { + menu_items = menu_items.item( + Button::with_text(extra_menu_label) + .styled(theme::menu_item_title()) + .with_text_align(Alignment::Start) + .with_content_offset(Offset::x(12)), + ); + } + + menu_items = menu_items.item( + Button::with_text(cancel_menu_label) + .styled(theme::menu_item_title_orange()) + .with_text_align(Alignment::Start) + .with_content_offset(Offset::x(12)), + ); + + let content_menu = VerticalMenuScreen::new(menu_items) + .with_header(Header::new(TString::empty()).with_close_button()) + .map(move |msg| match msg { + VerticalMenuScreenMsg::Selected(i) => Some(FlowMsg::Choice(i)), + VerticalMenuScreenMsg::Close => Some(FlowMsg::Cancelled), + _ => None, + }); + + let mut res = SwipeFlow::new(&ConfirmWithMenu::Value)?; + res.add_page(&ConfirmWithMenu::Value, content_value)? + .add_page(&ConfirmWithMenu::Menu, content_menu)?; + Ok(res) +} diff --git a/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs b/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs index 58679faaaf..662518a3b3 100644 --- a/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs +++ b/core/embed/rust/src/ui/layout_eckhart/flow/mod.rs @@ -2,6 +2,8 @@ pub mod confirm_output; pub mod confirm_reset; pub mod confirm_set_new_pin; pub mod confirm_summary; +pub mod confirm_value_intro; +pub mod confirm_with_menu; pub mod continue_recovery_homepage; pub mod get_address; pub mod prompt_backup; @@ -14,6 +16,8 @@ pub use confirm_output::new_confirm_output; pub use confirm_reset::new_confirm_reset; pub use confirm_set_new_pin::new_set_new_pin; pub use confirm_summary::new_confirm_summary; +pub use confirm_value_intro::new_confirm_value_intro; +pub use confirm_with_menu::new_confirm_with_menu; pub use continue_recovery_homepage::new_continue_recovery_homepage; pub use get_address::GetAddress; pub use prompt_backup::PromptBackup; diff --git a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs index ac878bc3c5..55e9469ae6 100644 --- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs +++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs @@ -14,13 +14,14 @@ use crate::{ Checklist, Paragraph, ParagraphSource, ParagraphVecLong, ParagraphVecShort, Paragraphs, VecExt, }, + TextStyle, }, Empty, FormattedText, }, geometry::{Alignment, LinearPlacement, Offset}, layout::{ obj::{LayoutMaybeTrace, LayoutObj, RootComponent}, - util::{ConfirmValueParams, RecoveryType, StrOrBytes}, + util::{ConfirmValueParams, PropsList, RecoveryType, StrOrBytes}, }, ui_firmware::{ FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_WORD_QUIZ_ITEMS, @@ -179,21 +180,84 @@ impl FirmwareUI for UIEckhart { } fn confirm_modify_fee( - _title: TString<'static>, - _sign: i32, - _user_fee_change: TString<'static>, - _total_fee_new: TString<'static>, + title: TString<'static>, + sign: i32, + user_fee_change: TString<'static>, + total_fee_new: TString<'static>, _fee_rate_amount: Option>, ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + let (description, change, total_label) = match sign { + s if s < 0 => ( + TR::modify_fee__decrease_fee, + user_fee_change, + TR::modify_fee__new_transaction_fee, + ), + s if s > 0 => ( + TR::modify_fee__increase_fee, + user_fee_change, + TR::modify_fee__new_transaction_fee, + ), + _ => ( + TR::modify_fee__no_change, + "".into(), + TR::modify_fee__transaction_fee, + ), + }; + + let paragraphs = ParagraphVecShort::from_iter([ + Paragraph::new(&theme::TEXT_SMALL_LIGHT, description), + Paragraph::new(&theme::TEXT_MONO_MEDIUM_LIGHT, change), + Paragraph::new(&theme::TEXT_SMALL_LIGHT, total_label), + Paragraph::new(&theme::TEXT_MONO_MEDIUM_LIGHT, total_fee_new), + ]); + + let flow = flow::new_confirm_with_menu( + title, + None, + paragraphs + .into_paragraphs() + .with_spacing(12) + .with_placement(LinearPlacement::vertical()), + None, + false, + Some(TR::words__title_information.into()), + None, + )?; + Ok(flow) } fn confirm_modify_output( - _sign: i32, - _amount_change: TString<'static>, - _amount_new: TString<'static>, + sign: i32, + amount_change: TString<'static>, + amount_new: TString<'static>, ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + let description = if sign < 0 { + TR::modify_amount__decrease_amount + } else { + TR::modify_amount__increase_amount + }; + + let paragraphs = ParagraphVecShort::from_iter([ + Paragraph::new(&theme::TEXT_SMALL_LIGHT, description), + Paragraph::new(&theme::TEXT_MONO_MEDIUM_LIGHT, amount_change), + Paragraph::new(&theme::TEXT_SMALL_LIGHT, TR::modify_amount__new_amount), + Paragraph::new(&theme::TEXT_MONO_MEDIUM_LIGHT, amount_new), + ]); + + let layout = RootComponent::new( + TextScreen::new( + paragraphs + .into_paragraphs() + .with_placement(LinearPlacement::vertical()) + .with_spacing(12), + ) + .with_header(Header::new(TR::modify_amount__title.into())) + .with_action_bar(ActionBar::new_double( + Button::with_icon(theme::ICON_CROSS), + Button::with_text(TR::buttons__confirm.into()), + )), + ); + Ok(layout) } fn confirm_more( @@ -261,11 +325,30 @@ impl FirmwareUI for UIEckhart { } fn confirm_properties( - _title: TString<'static>, - _items: Obj, - _hold: bool, + title: TString<'static>, + items: Obj, + hold: bool, ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + let paragraphs = PropsList::new( + items, + &theme::TEXT_SMALL_LIGHT, + &theme::TEXT_MEDIUM, + &theme::TEXT_MONO_LIGHT, + )?; + + let flow = flow::new_confirm_with_menu( + title, + None, + paragraphs + .into_paragraphs() + .with_spacing(12) + .with_placement(LinearPlacement::vertical()), + None, + hold, + None, + None, + )?; + Ok(flow) } fn confirm_value( @@ -333,25 +416,65 @@ impl FirmwareUI for UIEckhart { } fn confirm_value_intro( - _title: TString<'static>, - _value: Obj, - _subtitle: Option>, - _verb: Option>, - _verb_cancel: Option>, - _hold: bool, - _chunkify: bool, + title: TString<'static>, + value: Obj, + subtitle: Option>, + verb: Option>, + verb_cancel: Option>, + hold: bool, + chunkify: bool, ) -> Result, Error> { - Err::, Error>(Error::ValueError(c"confirm_value_intro not implemented")) + let flow = flow::new_confirm_value_intro( + title, + subtitle, + value, + TR::buttons__view_all_data.into(), + verb_cancel, + verb, + hold, + chunkify, + )?; + + LayoutObj::new_root(flow) } fn confirm_with_info( - _title: TString<'static>, - _items: Obj, - _verb: TString<'static>, - _verb_info: TString<'static>, + title: TString<'static>, + items: Obj, + verb: TString<'static>, + verb_info: TString<'static>, _verb_cancel: Option>, ) -> Result { - Err::, Error>(Error::ValueError(c"not implemented")) + let mut paragraphs = ParagraphVecShort::new(); + + for para in IterBuf::new().try_iterate(items)? { + let [text, is_data]: [Obj; 2] = util::iter_into_array(para)?; + let is_data = is_data.try_into()?; + let style: &TextStyle = if is_data { + &theme::TEXT_SMALL_LIGHT + } else { + &theme::TEXT_MONO_MEDIUM_LIGHT + }; + let text: TString = text.try_into()?; + paragraphs.add(Paragraph::new(style, text)); + if paragraphs.is_full() { + break; + } + } + + let flow = flow::new_confirm_with_menu( + title, + None, + paragraphs + .into_paragraphs() + .with_placement(LinearPlacement::vertical()) + .with_spacing(12), + Some(verb), + false, + Some(verb_info), + None, + )?; + Ok(flow) } fn check_homescreen_format(image: BinaryData, _accept_toif: bool) -> bool { @@ -902,19 +1025,24 @@ impl FirmwareUI for UIEckhart { let [key, value]: [Obj; 2] = util::iter_into_array(para)?; let key: TString = key.try_into()?; let value: TString = value.try_into()?; - paragraphs.add(Paragraph::new(&theme::TEXT_MEDIUM, key).no_break()); + paragraphs.add(Paragraph::new(&theme::TEXT_SMALL_LIGHT, key).no_break()); if chunkify { paragraphs.add(Paragraph::new( theme::get_chunkified_text_style(value.len()), value, )); } else { - paragraphs.add(Paragraph::new(&theme::TEXT_MONO_MEDIUM, value)); + paragraphs.add(Paragraph::new(&theme::TEXT_MONO_LIGHT, value)); } } - let screen = TextScreen::new(paragraphs.into_paragraphs()) - .with_header(Header::new(title).with_close_button()); + let screen = TextScreen::new( + paragraphs + .into_paragraphs() + .with_spacing(12) + .with_placement(LinearPlacement::vertical()), + ) + .with_header(Header::new(title).with_close_button()); let layout = RootComponent::new(screen); Ok(layout) } diff --git a/core/src/trezor/ui/layouts/eckhart/__init__.py b/core/src/trezor/ui/layouts/eckhart/__init__.py index d8f3b093c4..75861ba6cb 100644 --- a/core/src/trezor/ui/layouts/eckhart/__init__.py +++ b/core/src/trezor/ui/layouts/eckhart/__init__.py @@ -899,8 +899,36 @@ async def confirm_modify_output( amount_change: str, amount_new: str, ) -> None: - # FIXME: not implemented - raise NotImplementedError + address_layout = trezorui_api.confirm_value( + title=TR.modify_amount__title, + value=address, + verb=TR.buttons__continue, + verb_cancel=None, + description=f"{TR.words__address}:", + ) + modify_layout = trezorui_api.confirm_modify_output( + sign=sign, + amount_change=amount_change, + amount_new=amount_new, + ) + + send_button_request = True + while True: + await raise_if_not_confirmed( + address_layout, + "modify_output" if send_button_request else None, + ButtonRequestType.ConfirmOutput, + ) + result = await interact( + modify_layout, + "modify_output" if send_button_request else None, + ButtonRequestType.ConfirmOutput, + raise_on_cancel=None, + ) + send_button_request = False + + if result is CONFIRMED: + break def confirm_modify_fee( @@ -910,8 +938,21 @@ def confirm_modify_fee( total_fee_new: str, fee_rate_amount: str | None = None, ) -> Awaitable[None]: - # FIXME: not implemented - raise NotImplementedError + fee_layout = trezorui_api.confirm_modify_fee( + title=title, + sign=sign, + user_fee_change=user_fee_change, + total_fee_new=total_fee_new, + fee_rate_amount=fee_rate_amount, + ) + items: list[tuple[str, str]] = [] + if fee_rate_amount: + items.append((TR.bitcoin__new_fee_rate, fee_rate_amount)) + info_layout = trezorui_api.show_info_with_cancel( + title=TR.confirm_total__title_fee, + items=items, + ) + return with_info(fee_layout, info_layout, "modify_fee", ButtonRequestType.SignTx) def confirm_coinjoin(max_rounds: int, max_fee_per_vbyte: str) -> Awaitable[None]: