From 6da783210491f2fceee060b67865db0924099af1 Mon Sep 17 00:00:00 2001 From: Martin Milata Date: Mon, 4 Mar 2024 12:08:57 +0100 Subject: [PATCH] feat(core): init T3T1 UI layouts Start with copy of components and layouts from T2T1. --- .../component/address_details.rs | 222 ++ .../component/coinjoin_progress.rs | 183 ++ .../src/ui/model_mercury/component/dialog.rs | 233 ++ .../src/ui/model_mercury/component/fido.rs | 246 ++ .../ui/model_mercury/component/fido_icons.rs | 80 + .../component/fido_icons.rs.mako | 35 + .../model_mercury/component/homescreen/mod.rs | 598 +++++ .../component/homescreen/render.rs | 768 ++++++ .../model_mercury/component/keyboard/bip39.rs | 326 +++ .../component/keyboard/common.rs | 152 ++ .../component/keyboard/mnemonic.rs | 234 ++ .../model_mercury/component/keyboard/mod.rs | 8 + .../component/keyboard/passphrase.rs | 441 ++++ .../model_mercury/component/keyboard/pin.rs | 572 +++++ .../component/keyboard/slip39.rs | 399 +++ .../component/keyboard/word_count.rs | 80 + .../model_mercury/component/number_input.rs | 276 +++ .../src/ui/model_mercury/component/page.rs | 851 +++++++ .../ui/model_mercury/component/progress.rs | 183 ++ .../src/ui/model_mercury/component/scroll.rs | 179 ++ .../ui/model_mercury/component/simple_page.rs | 199 ++ .../src/ui/model_mercury/component/swipe.rs | 165 ++ .../embed/rust/src/ui/model_mercury/layout.rs | 2167 +++++++++++++++++ .../ui/model_mercury/res/fido/icon_apple.toif | Bin 0 -> 697 bytes .../ui/model_mercury/res/fido/icon_aws.toif | Bin 0 -> 1264 bytes .../model_mercury/res/fido/icon_binance.toif | Bin 0 -> 555 bytes .../res/fido/icon_bitbucket.toif | Bin 0 -> 865 bytes .../model_mercury/res/fido/icon_bitfinex.toif | Bin 0 -> 1151 bytes .../res/fido/icon_bitwarden.toif | Bin 0 -> 617 bytes .../res/fido/icon_cloudflare.toif | Bin 0 -> 615 bytes .../model_mercury/res/fido/icon_coinbase.toif | Bin 0 -> 568 bytes .../model_mercury/res/fido/icon_dashlane.toif | Bin 0 -> 2158 bytes .../model_mercury/res/fido/icon_dropbox.toif | Bin 0 -> 852 bytes .../ui/model_mercury/res/fido/icon_duo.toif | Bin 0 -> 463 bytes .../model_mercury/res/fido/icon_facebook.toif | Bin 0 -> 747 bytes .../model_mercury/res/fido/icon_fastmail.toif | Bin 0 -> 1185 bytes .../model_mercury/res/fido/icon_fedora.toif | Bin 0 -> 1185 bytes .../ui/model_mercury/res/fido/icon_gandi.toif | Bin 0 -> 1237 bytes .../model_mercury/res/fido/icon_gemini.toif | Bin 0 -> 1042 bytes .../model_mercury/res/fido/icon_github.toif | Bin 0 -> 1092 bytes .../model_mercury/res/fido/icon_gitlab.toif | Bin 0 -> 952 bytes .../model_mercury/res/fido/icon_google.toif | Bin 0 -> 1071 bytes .../model_mercury/res/fido/icon_invity.toif | Bin 0 -> 136 bytes .../model_mercury/res/fido/icon_keeper.toif | Bin 0 -> 1507 bytes .../model_mercury/res/fido/icon_kraken.toif | Bin 0 -> 654 bytes .../res/fido/icon_login.gov.toif | Bin 0 -> 651 bytes .../res/fido/icon_microsoft.toif | Bin 0 -> 165 bytes .../model_mercury/res/fido/icon_mojeid.toif | Bin 0 -> 1330 bytes .../res/fido/icon_namecheap.toif | Bin 0 -> 867 bytes .../model_mercury/res/fido/icon_proton.toif | Bin 0 -> 587 bytes .../res/fido/icon_slushpool.toif | Bin 0 -> 1021 bytes .../model_mercury/res/fido/icon_stripe.toif | Bin 0 -> 668 bytes .../model_mercury/res/fido/icon_tutanota.toif | Bin 0 -> 646 bytes .../model_mercury/res/fido/icon_webauthn.toif | Bin 0 -> 1135 bytes .../rust/src/ui/shape/model/model_mercury.rs | 77 + core/mocks/generated/trezorui2.pyi | 462 ++++ .../src/trezor/ui/layouts/mercury/__init__.py | 1524 ++++++++++++ core/src/trezor/ui/layouts/mercury/fido.py | 89 + .../trezor/ui/layouts/mercury/homescreen.py | 143 ++ .../src/trezor/ui/layouts/mercury/progress.py | 72 + .../src/trezor/ui/layouts/mercury/recovery.py | 171 ++ core/src/trezor/ui/layouts/mercury/reset.py | 369 +++ 62 files changed, 11504 insertions(+) create mode 100644 core/embed/rust/src/ui/model_mercury/component/address_details.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/coinjoin_progress.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/dialog.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/fido.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/fido_icons.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/fido_icons.rs.mako create mode 100644 core/embed/rust/src/ui/model_mercury/component/homescreen/mod.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/homescreen/render.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/keyboard/bip39.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/keyboard/common.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/keyboard/mnemonic.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/keyboard/mod.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/keyboard/passphrase.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/keyboard/pin.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/keyboard/slip39.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/keyboard/word_count.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/number_input.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/page.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/progress.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/scroll.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/simple_page.rs create mode 100644 core/embed/rust/src/ui/model_mercury/component/swipe.rs create mode 100644 core/embed/rust/src/ui/model_mercury/layout.rs create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_apple.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_aws.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_binance.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_bitbucket.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_bitfinex.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_bitwarden.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_cloudflare.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_coinbase.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_dashlane.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_dropbox.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_duo.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_facebook.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_fastmail.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_fedora.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_gandi.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_gemini.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_github.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_gitlab.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_google.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_invity.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_keeper.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_kraken.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_login.gov.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_microsoft.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_mojeid.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_namecheap.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_proton.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_slushpool.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_stripe.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_tutanota.toif create mode 100644 core/embed/rust/src/ui/model_mercury/res/fido/icon_webauthn.toif create mode 100644 core/embed/rust/src/ui/shape/model/model_mercury.rs create mode 100644 core/src/trezor/ui/layouts/mercury/__init__.py create mode 100644 core/src/trezor/ui/layouts/mercury/fido.py create mode 100644 core/src/trezor/ui/layouts/mercury/homescreen.py create mode 100644 core/src/trezor/ui/layouts/mercury/progress.py create mode 100644 core/src/trezor/ui/layouts/mercury/recovery.py create mode 100644 core/src/trezor/ui/layouts/mercury/reset.py diff --git a/core/embed/rust/src/ui/model_mercury/component/address_details.rs b/core/embed/rust/src/ui/model_mercury/component/address_details.rs new file mode 100644 index 000000000..76dd85b13 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/address_details.rs @@ -0,0 +1,222 @@ +use heapless::Vec; + +use crate::{ + error::Error, + micropython::buffer::StrBuffer, + strutil::StringType, + translations::TR, + ui::{ + component::{ + text::paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt}, + Component, Event, EventCtx, Paginate, Qr, + }, + geometry::Rect, + shape::Renderer, + }, +}; + +use super::{theme, Frame, FrameMsg}; + +const MAX_XPUBS: usize = 16; + +pub struct AddressDetails { + qr_code: Frame, + details: Frame>, T>, + xpub_view: Frame>, T>, + xpubs: Vec<(T, T), MAX_XPUBS>, + xpub_page_count: Vec, + current_page: usize, +} + +impl AddressDetails +where + T: StringType, +{ + pub fn new( + qr_title: T, + qr_address: T, + case_sensitive: bool, + details_title: T, + account: Option, + path: Option, + ) -> Result + where + T: From<&'static str>, + { + let mut para = ParagraphVecShort::new(); + if let Some(a) = account { + para.add(Paragraph::new( + &theme::TEXT_NORMAL, + TR::words__account_colon.try_into()?, + )); + para.add(Paragraph::new(&theme::TEXT_MONO, a)); + } + if let Some(p) = path { + para.add(Paragraph::new( + &theme::TEXT_NORMAL, + TR::address_details__derivation_path.try_into()?, + )); + para.add(Paragraph::new(&theme::TEXT_MONO, p)); + } + let result = Self { + qr_code: Frame::left_aligned( + theme::label_title(), + qr_title, + Qr::new(qr_address, case_sensitive)?.with_border(7), + ) + .with_cancel_button() + .with_border(theme::borders_horizontal_scroll()), + details: Frame::left_aligned( + theme::label_title(), + details_title, + para.into_paragraphs(), + ) + .with_cancel_button() + .with_border(theme::borders_horizontal_scroll()), + xpub_view: Frame::left_aligned( + theme::label_title(), + " \n ".into(), + Paragraph::new(&theme::TEXT_MONO, "".into()).into_paragraphs(), + ) + .with_cancel_button() + .with_border(theme::borders_horizontal_scroll()), + xpubs: Vec::new(), + xpub_page_count: Vec::new(), + current_page: 0, + }; + Ok(result) + } + + pub fn add_xpub(&mut self, title: T, xpub: T) -> Result<(), Error> { + self.xpubs + .push((title, xpub)) + .map_err(|_| Error::OutOfRange) + } + + fn switch_xpub(&mut self, i: usize, page: usize) -> usize + where + T: Clone, + { + // Context is needed for updating child so that it can request repaint. In this + // case the parent component that handles paging always requests complete + // repaint after page change so we can use a dummy context here. + let mut dummy_ctx = EventCtx::new(); + self.xpub_view + .update_title(&mut dummy_ctx, self.xpubs[i].0.clone()); + self.xpub_view.update_content(&mut dummy_ctx, |p| { + p.inner_mut().update(self.xpubs[i].1.clone()); + let npages = p.page_count(); + p.change_page(page); + npages + }) + } + + fn lookup(&self, scrollbar_page: usize) -> (usize, usize) { + let mut xpub_index = 0; + let mut xpub_page = scrollbar_page; + for page_count in self.xpub_page_count.iter().map(|pc| { + let upc: usize = (*pc).into(); + upc + }) { + if page_count <= xpub_page { + xpub_page -= page_count; + xpub_index += 1; + } else { + break; + } + } + (xpub_index, xpub_page) + } +} + +impl Paginate for AddressDetails +where + T: StringType + Clone, +{ + fn page_count(&mut self) -> usize { + let total_xpub_pages: u8 = self.xpub_page_count.iter().copied().sum(); + 2usize.saturating_add(total_xpub_pages.into()) + } + + fn change_page(&mut self, to_page: usize) { + self.current_page = to_page; + if to_page > 1 { + let i = to_page - 2; + let (xpub_index, xpub_page) = self.lookup(i); + self.switch_xpub(xpub_index, xpub_page); + } + } +} + +impl Component for AddressDetails +where + T: StringType + Clone, +{ + type Msg = (); + + fn place(&mut self, bounds: Rect) -> Rect { + self.qr_code.place(bounds); + self.details.place(bounds); + self.xpub_view.place(bounds); + + self.xpub_page_count.clear(); + for i in 0..self.xpubs.len() { + let npages = self.switch_xpub(i, 0) as u8; + unwrap!(self.xpub_page_count.push(npages)); + } + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let msg = match self.current_page { + 0 => self.qr_code.event(ctx, event), + 1 => self.details.event(ctx, event), + _ => self.xpub_view.event(ctx, event), + }; + match msg { + Some(FrameMsg::Button(_)) => Some(()), + _ => None, + } + } + + fn paint(&mut self) { + match self.current_page { + 0 => self.qr_code.paint(), + 1 => self.details.paint(), + _ => self.xpub_view.paint(), + } + } + + fn render(&mut self, target: &mut impl Renderer) { + match self.current_page { + 0 => self.qr_code.render(target), + 1 => self.details.render(target), + _ => self.xpub_view.render(target), + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + match self.current_page { + 0 => self.qr_code.bounds(sink), + 1 => self.details.bounds(sink), + _ => self.xpub_view.bounds(sink), + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for AddressDetails +where + T: StringType, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("AddressDetails"); + match self.current_page { + 0 => t.child("qr_code", &self.qr_code), + 1 => t.child("details", &self.details), + _ => t.child("xpub_view", &self.xpub_view), + } + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/coinjoin_progress.rs b/core/embed/rust/src/ui/model_mercury/component/coinjoin_progress.rs new file mode 100644 index 000000000..9b6b19494 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/coinjoin_progress.rs @@ -0,0 +1,183 @@ +use core::mem; + +use crate::{ + error::Error, + maybe_trace::MaybeTrace, + micropython::buffer::StrBuffer, + translations::TR, + ui::{ + canvas::algo::PI4, + component::{ + base::Never, painter, Child, Component, ComponentExt, Empty, Event, EventCtx, Label, + Split, + }, + constant, + display::loader::{loader_circular_uncompress, LoaderDimensions}, + geometry::{Insets, Offset, Rect}, + shape, + shape::Renderer, + util::animation_disabled, + }, +}; + +use super::{theme, Frame}; + +const RECTANGLE_HEIGHT: i16 = 56; +const LABEL_TOP: i16 = 135; +const LOADER_OUTER: i16 = 39; +const LOADER_INNER: i16 = 28; +const LOADER_OFFSET: i16 = -34; +const LOADER_SPEED: u16 = 5; + +pub struct CoinJoinProgress { + value: u16, + indeterminate: bool, + content: Child, StrBuffer>>, + // Label is not a child since circular loader paints large black rectangle which overlaps it. + // To work around this, draw label every time loader is drawn. + label: Label, +} + +impl CoinJoinProgress +where + T: AsRef, +{ + pub fn new( + text: T, + indeterminate: bool, + ) -> Result + MaybeTrace>, Error> + where + T: AsRef, + { + let style = theme::label_coinjoin_progress(); + let label = Label::centered( + TryInto::::try_into(TR::coinjoin__title_do_not_disconnect)?, + style, + ) + .vertically_centered(); + let bg = painter::rect_painter(style.background_color, theme::BG); + let inner = (bg, label); + CoinJoinProgress::with_background(text, inner, indeterminate) + } +} + +impl CoinJoinProgress +where + T: AsRef, + U: Component, +{ + pub fn with_background(text: T, inner: U, indeterminate: bool) -> Result { + Ok(Self { + value: 0, + indeterminate, + content: Frame::centered( + theme::label_title(), + TR::coinjoin__title_progress.try_into()?, + Split::bottom(RECTANGLE_HEIGHT, 0, Empty, inner), + ) + .into_child(), + label: Label::centered(text, theme::TEXT_NORMAL), + }) + } +} + +impl Component for CoinJoinProgress +where + T: AsRef, + U: Component, +{ + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + self.content.place(bounds); + let label_bounds = bounds.inset(Insets::top(LABEL_TOP)); + self.label.place(label_bounds); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + self.content.event(ctx, event); + self.label.event(ctx, event); + match event { + _ if animation_disabled() => { + return None; + } + Event::Attach if self.indeterminate => { + ctx.request_anim_frame(); + } + Event::Timer(EventCtx::ANIM_FRAME_TIMER) => { + self.value = (self.value + LOADER_SPEED) % 1000; + ctx.request_anim_frame(); + ctx.request_paint(); + } + Event::Progress(new_value, _new_description) => { + if mem::replace(&mut self.value, new_value) != new_value { + ctx.request_paint(); + } + } + _ => {} + } + None + } + + fn paint(&mut self) { + self.content.paint(); + loader_circular_uncompress( + LoaderDimensions::new(LOADER_OUTER, LOADER_INNER), + LOADER_OFFSET, + theme::FG, + theme::BG, + self.value, + self.indeterminate, + None, + ); + self.label.paint(); + } + + fn render(&mut self, target: &mut impl Renderer) { + self.content.render(target); + + let center = constant::screen().center() + Offset::y(LOADER_OFFSET); + let active_color = theme::FG; + let background_color = theme::BG; + let inactive_color = background_color.blend(active_color, 85); + + let start = (self.value - 100) % 1000; + let end = (self.value + 100) % 1000; + let start = ((start as i32 * 8 * PI4 as i32) / 1000) as i16; + let end = ((end as i32 * 8 * PI4 as i32) / 1000) as i16; + + shape::Circle::new(center, LOADER_OUTER) + .with_bg(inactive_color) + .render(target); + + shape::Circle::new(center, LOADER_OUTER) + .with_bg(active_color) + .with_start_angle(start) + .with_end_angle(end) + .render(target); + + shape::Circle::new(center, LOADER_INNER + 2) + .with_bg(active_color) + .render(target); + + shape::Circle::new(center, LOADER_INNER) + .with_bg(background_color) + .render(target); + + self.label.render(target); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for CoinJoinProgress +where + T: AsRef, + U: Component + crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("CoinJoinProgress"); + t.child("label", &self.label); + t.child("content", &self.content); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/dialog.rs b/core/embed/rust/src/ui/model_mercury/component/dialog.rs new file mode 100644 index 000000000..6f7fda94b --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/dialog.rs @@ -0,0 +1,233 @@ +use crate::{ + strutil::StringType, + ui::{ + component::{ + image::BlendedImage, + text::{ + paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt}, + TextStyle, + }, + Child, Component, Event, EventCtx, Never, + }, + geometry::{Insets, LinearPlacement, Rect}, + shape::Renderer, + }, +}; + +use super::theme; + +pub enum DialogMsg { + Content(T), + Controls(U), +} + +pub struct Dialog { + content: Child, + controls: Child, +} + +impl Dialog +where + T: Component, + U: Component, +{ + pub fn new(content: T, controls: U) -> Self { + Self { + content: Child::new(content), + controls: Child::new(controls), + } + } + + pub fn inner(&self) -> &T { + self.content.inner() + } +} + +impl Component for Dialog +where + T: Component, + U: Component, +{ + type Msg = DialogMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let controls_area = self.controls.place(bounds); + let content_area = bounds + .inset(Insets::bottom(controls_area.height())) + .inset(Insets::bottom(theme::BUTTON_SPACING)) + .inset(Insets::left(theme::CONTENT_BORDER)); + self.content.place(content_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + self.content + .event(ctx, event) + .map(Self::Msg::Content) + .or_else(|| self.controls.event(ctx, event).map(Self::Msg::Controls)) + } + + fn paint(&mut self) { + self.content.paint(); + self.controls.paint(); + } + + fn render(&mut self, target: &mut impl Renderer) { + self.content.render(target); + self.controls.render(target); + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.content.bounds(sink); + self.controls.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Dialog +where + T: crate::trace::Trace, + U: crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("Dialog"); + t.child("content", &self.content); + t.child("controls", &self.controls); + } +} + +pub struct IconDialog { + image: Child, + paragraphs: Paragraphs>, + controls: Child, +} + +impl IconDialog +where + T: StringType, + U: Component, +{ + pub fn new(icon: BlendedImage, title: T, controls: U) -> Self { + Self { + image: Child::new(icon), + paragraphs: Paragraphs::new(ParagraphVecShort::from_iter([Paragraph::new( + &theme::TEXT_DEMIBOLD, + title, + ) + .centered()])) + .with_placement( + LinearPlacement::vertical() + .align_at_center() + .with_spacing(Self::VALUE_SPACE), + ), + controls: Child::new(controls), + } + } + + pub fn with_paragraph(mut self, para: Paragraph) -> Self { + if !para.content().as_ref().is_empty() { + self.paragraphs.inner_mut().add(para); + } + self + } + + pub fn with_text(self, style: &'static TextStyle, text: T) -> Self { + self.with_paragraph(Paragraph::new(style, text).centered()) + } + + pub fn with_description(self, description: T) -> Self { + self.with_text(&theme::TEXT_NORMAL_OFF_WHITE, description) + } + + pub fn with_value(self, value: T) -> Self { + self.with_text(&theme::TEXT_MONO, value) + } + + pub fn new_shares(lines: [T; 4], controls: U) -> Self { + let [l0, l1, l2, l3] = lines; + Self { + image: Child::new(BlendedImage::new( + theme::IMAGE_BG_CIRCLE, + theme::IMAGE_FG_SUCCESS, + theme::SUCCESS_COLOR, + theme::FG, + theme::BG, + )), + paragraphs: ParagraphVecShort::from_iter([ + Paragraph::new(&theme::TEXT_NORMAL_OFF_WHITE, l0).centered(), + Paragraph::new(&theme::TEXT_DEMIBOLD, l1).centered(), + Paragraph::new(&theme::TEXT_NORMAL_OFF_WHITE, l2).centered(), + Paragraph::new(&theme::TEXT_DEMIBOLD, l3).centered(), + ]) + .into_paragraphs() + .with_placement(LinearPlacement::vertical().align_at_center()), + controls: Child::new(controls), + } + } + + pub const ICON_AREA_PADDING: i16 = 2; + pub const ICON_AREA_HEIGHT: i16 = 60; + pub const VALUE_SPACE: i16 = 5; +} + +impl Component for IconDialog +where + T: StringType, + U: Component, +{ + type Msg = DialogMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let bounds = bounds + .inset(theme::borders()) + .inset(Insets::top(Self::ICON_AREA_PADDING)); + + let controls_area = self.controls.place(bounds); + let content_area = bounds.inset(Insets::bottom(controls_area.height())); + + let (image_area, content_area) = content_area.split_top(Self::ICON_AREA_HEIGHT); + + self.image.place(image_area); + self.paragraphs.place(content_area); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + self.paragraphs.event(ctx, event); + self.controls.event(ctx, event).map(Self::Msg::Controls) + } + + fn paint(&mut self) { + self.image.paint(); + self.paragraphs.paint(); + self.controls.paint(); + } + + fn render(&mut self, target: &mut impl Renderer) { + self.image.render(target); + self.paragraphs.render(target); + self.controls.render(target); + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.image.bounds(sink); + self.paragraphs.bounds(sink); + self.controls.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for IconDialog +where + T: StringType, + U: crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("IconDialog"); + t.child("image", &self.image); + t.child("content", &self.paragraphs); + t.child("controls", &self.controls); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/fido.rs b/core/embed/rust/src/ui/model_mercury/component/fido.rs new file mode 100644 index 000000000..0f9755ea2 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/fido.rs @@ -0,0 +1,246 @@ +use crate::ui::{ + component::{image::Image, Child, Component, Event, EventCtx, Label}, + display, + geometry::{Insets, Rect}, + model_mercury::component::{ + fido_icons::get_fido_icon_data, + swipe::{Swipe, SwipeDirection}, + theme, ScrollBar, + }, + shape, + shape::Renderer, +}; + +use super::CancelConfirmMsg; + +const ICON_HEIGHT: i16 = 70; +const SCROLLBAR_INSET_TOP: i16 = 5; +const SCROLLBAR_HEIGHT: i16 = 10; +const APP_NAME_PADDING: i16 = 12; +const APP_NAME_HEIGHT: i16 = 30; + +pub enum FidoMsg { + Confirmed(usize), + Cancelled, +} + +pub struct FidoConfirm T, T, U> { + page_swipe: Swipe, + app_name: Label, + account_name: Label, + icon: Child, + /// Function/closure that will return appropriate page on demand. + get_account: F, + scrollbar: ScrollBar, + fade: bool, + controls: U, +} + +impl FidoConfirm +where + F: Fn(usize) -> T, + T: AsRef + From<&'static str>, + U: Component, +{ + pub fn new( + app_name: T, + get_account: F, + page_count: usize, + icon_name: Option, + controls: U, + ) -> Self { + let icon_data = get_fido_icon_data(icon_name.as_ref()); + + // Preparing scrollbar and setting its page-count. + let mut scrollbar = ScrollBar::horizontal(); + scrollbar.set_count_and_active_page(page_count, 0); + + // Preparing swipe component and setting possible initial + // swipe directions according to number of pages. + let mut page_swipe = Swipe::horizontal(); + page_swipe.allow_right = scrollbar.has_previous_page(); + page_swipe.allow_left = scrollbar.has_next_page(); + + Self { + app_name: Label::centered(app_name, theme::TEXT_DEMIBOLD), + account_name: Label::centered("".into(), theme::TEXT_DEMIBOLD), + page_swipe, + icon: Child::new(Image::new(icon_data)), + get_account, + scrollbar, + fade: false, + controls, + } + } + + fn on_page_swipe(&mut self, ctx: &mut EventCtx, swipe: SwipeDirection) { + // Change the page number. + match swipe { + SwipeDirection::Left if self.scrollbar.has_next_page() => { + self.scrollbar.go_to_next_page(); + } + SwipeDirection::Right if self.scrollbar.has_previous_page() => { + self.scrollbar.go_to_previous_page(); + } + _ => {} // page did not change + }; + + // Disable swipes on the boundaries. Not allowing carousel effect. + self.page_swipe.allow_right = self.scrollbar.has_previous_page(); + self.page_swipe.allow_left = self.scrollbar.has_next_page(); + + // Redraw the page. + ctx.request_paint(); + + // Reset backlight to normal level on next paint. + self.fade = true; + } + + fn active_page(&self) -> usize { + self.scrollbar.active_page + } +} + +impl Component for FidoConfirm +where + F: Fn(usize) -> T, + T: AsRef + From<&'static str>, + U: Component, +{ + type Msg = FidoMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.page_swipe.place(bounds); + + // Place the control buttons. + let controls_area = self.controls.place(bounds); + + // Get the image and content areas. + let content_area = bounds.inset(Insets::bottom(controls_area.height())); + let (image_area, content_area) = content_area.split_top(ICON_HEIGHT); + + // In case of showing a scrollbar, getting its area and placing it. + let remaining_area = if self.scrollbar.page_count > 1 { + let (scrollbar_area, remaining_area) = content_area + .inset(Insets::top(SCROLLBAR_INSET_TOP)) + .split_top(SCROLLBAR_HEIGHT); + self.scrollbar.place(scrollbar_area); + remaining_area + } else { + content_area + }; + + // Place the icon image. + self.icon.place(image_area); + + // Place the text labels. + let (app_name_area, account_name_area) = remaining_area + .inset(Insets::top(APP_NAME_PADDING)) + .split_top(APP_NAME_HEIGHT); + + self.app_name.place(app_name_area); + self.account_name.place(account_name_area); + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Some(swipe) = self.page_swipe.event(ctx, event) { + // Swipe encountered, update the page. + self.on_page_swipe(ctx, swipe); + } + if let Some(msg) = self.controls.event(ctx, event) { + // Some button was clicked, send results. + match msg { + CancelConfirmMsg::Confirmed => return Some(FidoMsg::Confirmed(self.active_page())), + CancelConfirmMsg::Cancelled => return Some(FidoMsg::Cancelled), + } + } + None + } + + fn paint(&mut self) { + self.icon.paint(); + self.controls.paint(); + self.app_name.paint(); + + if self.scrollbar.page_count > 1 { + self.scrollbar.paint(); + } + + let current_account = (self.get_account)(self.active_page()); + + // Erasing the old text content before writing the new one. + let account_name_area = self.account_name.area(); + let real_area = account_name_area + .with_height(account_name_area.height() + self.account_name.font().text_baseline() + 1); + display::rect_fill(real_area, theme::BG); + + // Account name is optional. + // Showing it only if it differs from app name. + // (Dummy requests usually have some text as both app_name and account_name.) + if !current_account.as_ref().is_empty() + && current_account.as_ref() != self.app_name.text().as_ref() + { + self.account_name.set_text(current_account); + self.account_name.paint(); + } + + if self.fade { + self.fade = false; + // Note that this is blocking and takes some time. + display::fade_backlight(theme::BACKLIGHT_NORMAL); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + self.icon.render(target); + self.controls.render(target); + self.app_name.render(target); + + if self.scrollbar.page_count > 1 { + self.scrollbar.render(target); + } + + let current_account = (self.get_account)(self.active_page()); + + // Erasing the old text content before writing the new one. + let account_name_area = self.account_name.area(); + let real_area = account_name_area + .with_height(account_name_area.height() + self.account_name.font().text_baseline() + 1); + shape::Bar::new(real_area).with_bg(theme::BG).render(target); + + // Account name is optional. + // Showing it only if it differs from app name. + // (Dummy requests usually have some text as both app_name and account_name.) + if !current_account.as_ref().is_empty() + && current_account.as_ref() != self.app_name.text().as_ref() + { + self.account_name.set_text(current_account); + self.account_name.render(target); + } + + if self.fade { + self.fade = false; + // Note that this is blocking and takes some time. + display::fade_backlight(theme::BACKLIGHT_NORMAL); + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.icon.bounds(sink); + self.app_name.bounds(sink); + self.account_name.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for FidoConfirm +where + F: Fn(usize) -> T, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("FidoConfirm"); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs b/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs new file mode 100644 index 000000000..5a3d67575 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs @@ -0,0 +1,80 @@ +//! generated from webauthn_icons.rs.mako +//! (by running `make templates` in `core`) +//! do not edit manually! + + +const ICON_APPLE: &[u8] = include_res!("model_mercury/res/fido/icon_apple.toif"); +const ICON_AWS: &[u8] = include_res!("model_mercury/res/fido/icon_aws.toif"); +const ICON_BINANCE: &[u8] = include_res!("model_mercury/res/fido/icon_binance.toif"); +const ICON_BITBUCKET: &[u8] = include_res!("model_mercury/res/fido/icon_bitbucket.toif"); +const ICON_BITFINEX: &[u8] = include_res!("model_mercury/res/fido/icon_bitfinex.toif"); +const ICON_BITWARDEN: &[u8] = include_res!("model_mercury/res/fido/icon_bitwarden.toif"); +const ICON_CLOUDFLARE: &[u8] = include_res!("model_mercury/res/fido/icon_cloudflare.toif"); +const ICON_COINBASE: &[u8] = include_res!("model_mercury/res/fido/icon_coinbase.toif"); +const ICON_DASHLANE: &[u8] = include_res!("model_mercury/res/fido/icon_dashlane.toif"); +const ICON_DROPBOX: &[u8] = include_res!("model_mercury/res/fido/icon_dropbox.toif"); +const ICON_DUO: &[u8] = include_res!("model_mercury/res/fido/icon_duo.toif"); +const ICON_FACEBOOK: &[u8] = include_res!("model_mercury/res/fido/icon_facebook.toif"); +const ICON_FASTMAIL: &[u8] = include_res!("model_mercury/res/fido/icon_fastmail.toif"); +const ICON_FEDORA: &[u8] = include_res!("model_mercury/res/fido/icon_fedora.toif"); +const ICON_GANDI: &[u8] = include_res!("model_mercury/res/fido/icon_gandi.toif"); +const ICON_GEMINI: &[u8] = include_res!("model_mercury/res/fido/icon_gemini.toif"); +const ICON_GITHUB: &[u8] = include_res!("model_mercury/res/fido/icon_github.toif"); +const ICON_GITLAB: &[u8] = include_res!("model_mercury/res/fido/icon_gitlab.toif"); +const ICON_GOOGLE: &[u8] = include_res!("model_mercury/res/fido/icon_google.toif"); +const ICON_INVITY: &[u8] = include_res!("model_mercury/res/fido/icon_invity.toif"); +const ICON_KEEPER: &[u8] = include_res!("model_mercury/res/fido/icon_keeper.toif"); +const ICON_KRAKEN: &[u8] = include_res!("model_mercury/res/fido/icon_kraken.toif"); +const ICON_LOGIN_GOV: &[u8] = include_res!("model_mercury/res/fido/icon_login.gov.toif"); +const ICON_MICROSOFT: &[u8] = include_res!("model_mercury/res/fido/icon_microsoft.toif"); +const ICON_MOJEID: &[u8] = include_res!("model_mercury/res/fido/icon_mojeid.toif"); +const ICON_NAMECHEAP: &[u8] = include_res!("model_mercury/res/fido/icon_namecheap.toif"); +const ICON_PROTON: &[u8] = include_res!("model_mercury/res/fido/icon_proton.toif"); +const ICON_SLUSHPOOL: &[u8] = include_res!("model_mercury/res/fido/icon_slushpool.toif"); +const ICON_STRIPE: &[u8] = include_res!("model_mercury/res/fido/icon_stripe.toif"); +const ICON_TUTANOTA: &[u8] = include_res!("model_mercury/res/fido/icon_tutanota.toif"); +/// Default icon when app does not have its own +const ICON_WEBAUTHN: &[u8] = include_res!("model_mercury/res/fido/icon_webauthn.toif"); + +/// Translates icon name into its data. +/// Returns default `ICON_WEBAUTHN` when the icon is not found or name not +/// supplied. +pub fn get_fido_icon_data>(icon_name: Option) -> &'static [u8] { + if let Some(icon_name) = icon_name { + match icon_name.as_ref() { + "apple" => ICON_APPLE, + "aws" => ICON_AWS, + "binance" => ICON_BINANCE, + "bitbucket" => ICON_BITBUCKET, + "bitfinex" => ICON_BITFINEX, + "bitwarden" => ICON_BITWARDEN, + "cloudflare" => ICON_CLOUDFLARE, + "coinbase" => ICON_COINBASE, + "dashlane" => ICON_DASHLANE, + "dropbox" => ICON_DROPBOX, + "duo" => ICON_DUO, + "facebook" => ICON_FACEBOOK, + "fastmail" => ICON_FASTMAIL, + "fedora" => ICON_FEDORA, + "gandi" => ICON_GANDI, + "gemini" => ICON_GEMINI, + "github" => ICON_GITHUB, + "gitlab" => ICON_GITLAB, + "google" => ICON_GOOGLE, + "invity" => ICON_INVITY, + "keeper" => ICON_KEEPER, + "kraken" => ICON_KRAKEN, + "login.gov" => ICON_LOGIN_GOV, + "microsoft" => ICON_MICROSOFT, + "mojeid" => ICON_MOJEID, + "namecheap" => ICON_NAMECHEAP, + "proton" => ICON_PROTON, + "slushpool" => ICON_SLUSHPOOL, + "stripe" => ICON_STRIPE, + "tutanota" => ICON_TUTANOTA, + _ => ICON_WEBAUTHN, + } + } else { + ICON_WEBAUTHN + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs.mako b/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs.mako new file mode 100644 index 000000000..068360df5 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/fido_icons.rs.mako @@ -0,0 +1,35 @@ +//! generated from webauthn_icons.rs.mako +//! (by running `make templates` in `core`) +//! do not edit manually! + +<% +icons: list[tuple[str, str]] = [] +for app in fido: + if app.icon is not None: + # Variable names cannot have a dot in themselves + icon_name = app.key + var_name = icon_name.replace(".", "_").upper() + icons.append((icon_name, var_name)) +%>\ + +% for icon_name, var_name in icons: +const ICON_${var_name}: &[u8] = include_res!("model_mercury/res/fido/icon_${icon_name}.toif"); +% endfor +/// Default icon when app does not have its own +const ICON_WEBAUTHN: &[u8] = include_res!("model_mercury/res/fido/icon_webauthn.toif"); + +/// Translates icon name into its data. +/// Returns default `ICON_WEBAUTHN` when the icon is not found or name not +/// supplied. +pub fn get_fido_icon_data>(icon_name: Option) -> &'static [u8] { + if let Some(icon_name) = icon_name { + match icon_name.as_ref() { +% for icon_name, var_name in icons: + "${icon_name}" => ICON_${var_name}, +% endfor + _ => ICON_WEBAUTHN, + } + } else { + ICON_WEBAUTHN + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/homescreen/mod.rs b/core/embed/rust/src/ui/model_mercury/component/homescreen/mod.rs new file mode 100644 index 000000000..51b9947cb --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/homescreen/mod.rs @@ -0,0 +1,598 @@ +mod render; + +use crate::{ + micropython::gc::Gc, + strutil::TString, + time::{Duration, Instant}, + translations::TR, + trezorhal::usb::usb_configured, + ui::{ + component::{Component, Event, EventCtx, Pad, TimerToken}, + display::{self, tjpgd::jpeg_info, toif::Icon, Color, Font}, + event::{TouchEvent, USBEvent}, + geometry::{Alignment, Alignment2D, Insets, Offset, Point, Rect}, + layout::util::get_user_custom_image, + model_mercury::{constant, theme::IMAGE_HOMESCREEN}, + shape::{self, Renderer}, + }, +}; + +use crate::{ + trezorhal::{buffers::BufferJpegWork, uzlib::UZLIB_WINDOW_SIZE}, + ui::{ + constant::HEIGHT, + display::{ + tjpgd::{jpeg_test, BufferInput}, + toif::{Toif, ToifFormat}, + }, + model_mercury::component::homescreen::render::{ + HomescreenJpeg, HomescreenToif, HOMESCREEN_TOIF_SIZE, + }, + }, +}; +use render::{ + homescreen, homescreen_blurred, HomescreenNotification, HomescreenText, + HOMESCREEN_IMAGE_HEIGHT, HOMESCREEN_IMAGE_WIDTH, +}; + +use super::{theme, Loader, LoaderMsg}; + +const AREA: Rect = constant::screen(); +const TOP_CENTER: Point = AREA.top_center(); +const LABEL_Y: i16 = HEIGHT - 18; +const LOCKED_Y: i16 = HEIGHT / 2 - 13; +const TAP_Y: i16 = HEIGHT / 2 + 14; +const HOLD_Y: i16 = 200; +const COINJOIN_Y: i16 = 30; +const LOADER_OFFSET: Offset = Offset::y(-10); +const LOADER_DELAY: Duration = Duration::from_millis(500); +const LOADER_DURATION: Duration = Duration::from_millis(2000); + +pub struct Homescreen { + label: TString<'static>, + notification: Option<(TString<'static>, u8)>, + custom_image: Option>, + hold_to_lock: bool, + loader: Loader, + pad: Pad, + paint_notification_only: bool, + delay: Option, +} + +pub enum HomescreenMsg { + Dismissed, +} + +impl Homescreen { + pub fn new( + label: TString<'static>, + notification: Option<(TString<'static>, u8)>, + hold_to_lock: bool, + ) -> Self { + Self { + label, + notification, + custom_image: get_user_custom_image().ok(), + hold_to_lock, + loader: Loader::with_lock_icon().with_durations(LOADER_DURATION, LOADER_DURATION / 3), + pad: Pad::with_background(theme::BG), + paint_notification_only: false, + delay: None, + } + } + + fn level_to_style(level: u8) -> (Color, Icon) { + match level { + 3 => (theme::YELLOW, theme::ICON_COINJOIN), + 2 => (theme::VIOLET, theme::ICON_MAGIC), + 1 => (theme::YELLOW, theme::ICON_WARN), + _ => (theme::RED, theme::ICON_WARN), + } + } + + fn get_notification(&self) -> Option { + if !usb_configured() { + let (color, icon) = Self::level_to_style(0); + Some(HomescreenNotification { + text: TR::homescreen__title_no_usb_connection.into(), + icon, + color, + }) + } else if let Some((notification, level)) = self.notification { + let (color, icon) = Self::level_to_style(level); + Some(HomescreenNotification { + text: notification, + icon, + color, + }) + } else { + None + } + } + + fn paint_loader(&mut self) { + TR::progress__locking_device.map_translated(|t| { + display::text_center( + TOP_CENTER + Offset::y(HOLD_Y), + t, + Font::NORMAL, + theme::FG, + theme::BG, + ) + }); + self.loader.paint() + } + + fn render_loader(&mut self, target: &mut impl Renderer) { + TR::progress__locking_device.map_translated(|t| { + shape::Text::new(TOP_CENTER + Offset::y(HOLD_Y), t) + .with_align(Alignment::Center) + .with_font(Font::NORMAL) + .with_fg(theme::FG); + }); + self.loader.render(target) + } + + pub fn set_paint_notification(&mut self) { + self.paint_notification_only = true; + } + + fn event_usb(&mut self, ctx: &mut EventCtx, event: Event) { + if let Event::USB(USBEvent::Connected(_)) = event { + self.paint_notification_only = true; + ctx.request_paint(); + } + } + + fn event_hold(&mut self, ctx: &mut EventCtx, event: Event) -> bool { + match event { + Event::Touch(TouchEvent::TouchStart(_)) => { + if self.loader.is_animating() { + self.loader.start_growing(ctx, Instant::now()); + } else { + self.delay = Some(ctx.request_timer(LOADER_DELAY)); + } + } + Event::Touch(TouchEvent::TouchEnd(_)) => { + self.delay = None; + let now = Instant::now(); + if self.loader.is_completely_grown(now) { + return true; + } + if self.loader.is_animating() { + self.loader.start_shrinking(ctx, now); + } + } + Event::Timer(token) if Some(token) == self.delay => { + self.delay = None; + self.pad.clear(); + self.paint_notification_only = false; + self.loader.start_growing(ctx, Instant::now()); + } + _ => {} + } + + match self.loader.event(ctx, event) { + Some(LoaderMsg::GrownCompletely) => { + // Wait for TouchEnd before returning. + } + Some(LoaderMsg::ShrunkCompletely) => { + self.loader.reset(); + self.pad.clear(); + self.paint_notification_only = false; + ctx.request_paint() + } + None => {} + } + + false + } +} + +impl Component for Homescreen { + type Msg = HomescreenMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.pad.place(AREA); + self.loader.place(AREA.translate(LOADER_OFFSET)); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + Self::event_usb(self, ctx, event); + if self.hold_to_lock { + Self::event_hold(self, ctx, event).then_some(HomescreenMsg::Dismissed) + } else { + None + } + } + + fn paint(&mut self) { + self.pad.paint(); + if self.loader.is_animating() || self.loader.is_completely_grown(Instant::now()) { + self.paint_loader(); + } else { + let mut label_style = theme::TEXT_DEMIBOLD; + label_style.text_color = theme::FG; + + let text = HomescreenText { + text: self.label, + style: label_style, + offset: Offset::y(LABEL_Y), + icon: None, + }; + + let notification = self.get_notification(); + + let mut show_default = true; + + if let Some(ref data) = self.custom_image { + if is_image_jpeg(data.as_ref()) { + let input = BufferInput(data.as_ref()); + let mut pool = BufferJpegWork::get_cleared(); + let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice()); + homescreen( + &mut hs_img, + &[text], + notification, + self.paint_notification_only, + ); + show_default = false; + } else if is_image_toif(data.as_ref()) { + let input = unwrap!(Toif::new(data.as_ref())); + let mut window = [0; UZLIB_WINDOW_SIZE]; + let mut hs_img = + HomescreenToif::new(input.decompression_context(Some(&mut window))); + homescreen( + &mut hs_img, + &[text], + notification, + self.paint_notification_only, + ); + show_default = false; + } + } + + if show_default { + let input = BufferInput(IMAGE_HOMESCREEN); + let mut pool = BufferJpegWork::get_cleared(); + let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice()); + homescreen( + &mut hs_img, + &[text], + notification, + self.paint_notification_only, + ); + } + } + } + + fn render(&mut self, target: &mut impl Renderer) { + self.pad.render(target); + if self.loader.is_animating() || self.loader.is_completely_grown(Instant::now()) { + self.render_loader(target); + } else { + let img_data = match self.custom_image { + Some(ref img) => IMAGE_HOMESCREEN, //img.as_ref(), !@# solve lifetime problem + None => IMAGE_HOMESCREEN, + }; + + if is_image_jpeg(img_data) { + shape::JpegImage::new(self.pad.area.center(), img_data) + .with_align(Alignment2D::CENTER) + .render(target); + } else if is_image_toif(img_data) { + shape::ToifImage::new(self.pad.area.center(), unwrap!(Toif::new(img_data))) + .with_align(Alignment2D::CENTER) + .render(target); + } + + self.label.map(|t| { + let style = theme::TEXT_DEMIBOLD; + let pos = Point::new(self.pad.area.center().x, LABEL_Y); + shape::Text::new(pos, t) + .with_align(Alignment::Center) + .with_font(style.text_font) + .with_fg(theme::FG) + .render(target); + }); + + if let Some(notif) = self.get_notification() { + const NOTIFICATION_HEIGHT: i16 = 36; + const NOTIFICATION_BORDER: i16 = 6; + const TEXT_ICON_SPACE: i16 = 8; + + let banner = self + .pad + .area + .inset(Insets::sides(NOTIFICATION_BORDER)) + .with_height(NOTIFICATION_HEIGHT) + .translate(Offset::y(NOTIFICATION_BORDER)); + + shape::Bar::new(banner) + .with_radius(2) + .with_bg(notif.color) + .render(target); + + notif.text.map(|t| { + let style = theme::TEXT_BOLD; + let icon_width = notif.icon.toif.width() + TEXT_ICON_SPACE; + let text_pos = Point::new( + style + .text_font + .horz_center(banner.x0 + icon_width, banner.x1, t), + style.text_font.vert_center(banner.y0, banner.y1, "A"), + ); + + shape::Text::new(text_pos, t) + .with_font(style.text_font) + .with_fg(style.text_color) + .render(target); + + let icon_pos = Point::new(text_pos.x - icon_width, banner.center().y); + + shape::ToifImage::new(icon_pos, notif.icon.toif) + .with_fg(style.text_color) + .with_align(Alignment2D::CENTER_LEFT) + .render(target); + }); + } + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.loader.bounds(sink); + sink(self.pad.area); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Homescreen { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("Homescreen"); + t.string("label", self.label); + } +} + +pub struct Lockscreen { + label: TString<'static>, + custom_image: Option>, + bootscreen: bool, + coinjoin_authorized: bool, +} + +impl Lockscreen { + pub fn new(label: TString<'static>, bootscreen: bool, coinjoin_authorized: bool) -> Self { + Lockscreen { + label, + custom_image: get_user_custom_image().ok(), + bootscreen, + coinjoin_authorized, + } + } +} + +impl Component for Lockscreen { + type Msg = HomescreenMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + bounds + } + + fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option { + if let Event::Touch(TouchEvent::TouchEnd(_)) = event { + return Some(HomescreenMsg::Dismissed); + } + None + } + + fn paint(&mut self) { + let (locked, tap) = if self.bootscreen { + ( + TR::lockscreen__title_not_connected, + TR::lockscreen__tap_to_connect, + ) + } else { + (TR::lockscreen__title_locked, TR::lockscreen__tap_to_unlock) + }; + + let mut label_style = theme::TEXT_DEMIBOLD; + label_style.text_color = theme::GREY_LIGHT; + + let mut texts: &[HomescreenText] = &[ + HomescreenText { + text: "".into(), + style: theme::TEXT_NORMAL, + offset: Offset::new(2, COINJOIN_Y), + icon: Some(theme::ICON_COINJOIN), + }, + HomescreenText { + text: locked.into(), + style: theme::TEXT_BOLD, + offset: Offset::y(LOCKED_Y), + icon: Some(theme::ICON_LOCK), + }, + HomescreenText { + text: tap.into(), + style: theme::TEXT_NORMAL, + offset: Offset::y(TAP_Y), + icon: None, + }, + HomescreenText { + text: self.label, + style: label_style, + offset: Offset::y(LABEL_Y), + icon: None, + }, + ]; + + if !self.coinjoin_authorized { + texts = &texts[1..]; + } + + let mut show_default = true; + + if let Some(ref data) = self.custom_image { + if is_image_jpeg(data.as_ref()) { + let input = BufferInput(data.as_ref()); + let mut pool = BufferJpegWork::get_cleared(); + let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice()); + homescreen_blurred(&mut hs_img, texts); + show_default = false; + } else if is_image_toif(data.as_ref()) { + let input = unwrap!(Toif::new(data.as_ref())); + let mut window = [0; UZLIB_WINDOW_SIZE]; + let mut hs_img = + HomescreenToif::new(input.decompression_context(Some(&mut window))); + homescreen_blurred(&mut hs_img, texts); + show_default = false; + } + } + + if show_default { + let input = BufferInput(IMAGE_HOMESCREEN); + let mut pool = BufferJpegWork::get_cleared(); + let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice()); + homescreen_blurred(&mut hs_img, texts); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + let img_data = match self.custom_image { + Some(ref img) => IMAGE_HOMESCREEN, //img.as_ref(), !@# solve lifetime problem + None => IMAGE_HOMESCREEN, + }; + + let center = constant::screen().center(); + + if is_image_jpeg(img_data) { + shape::JpegImage::new(center, img_data) + .with_align(Alignment2D::CENTER) + .with_blur(4) + .with_dim(130) + .render(target); + } else if is_image_toif(img_data) { + shape::ToifImage::new(center, unwrap!(Toif::new(img_data))) + .with_align(Alignment2D::CENTER) + //.with_blur(5) + .render(target); + } + + let (locked, tap) = if self.bootscreen { + ( + TR::lockscreen__title_not_connected, + TR::lockscreen__tap_to_connect, + ) + } else { + (TR::lockscreen__title_locked, TR::lockscreen__tap_to_unlock) + }; + + let mut label_style = theme::TEXT_DEMIBOLD; + label_style.text_color = theme::GREY_LIGHT; + + let mut texts: &[HomescreenText] = &[ + HomescreenText { + text: "".into(), + style: theme::TEXT_NORMAL, + offset: Offset::new(2, COINJOIN_Y), + icon: Some(theme::ICON_COINJOIN), + }, + HomescreenText { + text: locked.into(), + style: theme::TEXT_BOLD, + offset: Offset::y(LOCKED_Y), + icon: Some(theme::ICON_LOCK), + }, + HomescreenText { + text: tap.into(), + style: theme::TEXT_NORMAL, + offset: Offset::y(TAP_Y), + icon: None, + }, + HomescreenText { + text: self.label, + style: label_style, + offset: Offset::y(LABEL_Y), + icon: None, + }, + ]; + + if !self.coinjoin_authorized { + texts = &texts[1..]; + } + + for item in texts.iter() { + item.text.map(|t| { + const TEXT_ICON_SPACE: i16 = 2; + + let icon_width = match item.icon { + Some(icon) => icon.toif.width() + TEXT_ICON_SPACE, + None => 0, + }; + + let area = constant::screen(); + + let text_pos = Point::new( + item.style + .text_font + .horz_center(area.x0 + icon_width, area.x1, t), + 0, + ) + item.offset; + + shape::Text::new(text_pos, t) + .with_font(item.style.text_font) + .with_fg(item.style.text_color) + .render(target); + + if let Some(icon) = item.icon { + let icon_pos = Point::new(text_pos.x - icon_width, text_pos.y); + shape::ToifImage::new(icon_pos, icon.toif) + .with_align(Alignment2D::BOTTOM_LEFT) + .with_fg(item.style.text_color) + .render(target); + } + }); + } + } +} + +pub fn check_homescreen_format(buffer: &[u8]) -> bool { + #[cfg(not(feature = "new_rendering"))] + let result = is_image_jpeg(buffer) && jpeg_test(buffer); + #[cfg(feature = "new_rendering")] + let result = is_image_jpeg(buffer); // !@# TODO: test like if `new_rendering` is off + + result +} + +fn is_image_jpeg(buffer: &[u8]) -> bool { + let jpeg = jpeg_info(buffer); + if let Some((size, mcu_height)) = jpeg { + if size.x == HOMESCREEN_IMAGE_WIDTH && size.y == HOMESCREEN_IMAGE_HEIGHT && mcu_height <= 16 + { + return true; + } + } + false +} + +fn is_image_toif(buffer: &[u8]) -> bool { + let toif = Toif::new(buffer); + if let Ok(toif) = toif { + if toif.size().x == HOMESCREEN_TOIF_SIZE + && toif.size().y == HOMESCREEN_TOIF_SIZE + && toif.format() == ToifFormat::FullColorBE + { + return true; + } + } + false +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Lockscreen { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("Lockscreen"); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/homescreen/render.rs b/core/embed/rust/src/ui/model_mercury/component/homescreen/render.rs new file mode 100644 index 000000000..9480b4c15 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/homescreen/render.rs @@ -0,0 +1,768 @@ +use crate::{ + strutil::TString, + trezorhal::{ + buffers::{ + BufferBlurring, BufferBlurringTotals, BufferJpeg, BufferLine16bpp, BufferLine4bpp, + BufferText, + }, + display, + dma2d::{dma2d_setup_4bpp_over_16bpp, dma2d_start_blend, dma2d_wait_for_transfer}, + uzlib::UzlibContext, + }, + ui::{ + component::text::TextStyle, + constant::{screen, HEIGHT, WIDTH}, + display::{ + position_buffer, rect_fill_rounded_buffer, set_window, + tjpgd::{BufferInput, BufferOutput, JDEC}, + Color, Icon, + }, + geometry::{Offset, Point, Rect}, + model_mercury::theme, + util::icon_text_center, + }, +}; + +#[derive(Clone, Copy)] +pub struct HomescreenText<'a> { + pub text: TString<'a>, + pub style: TextStyle, + pub offset: Offset, + pub icon: Option, +} + +#[derive(Clone, Copy)] +pub struct HomescreenNotification { + pub text: TString<'static>, + pub icon: Icon, + pub color: Color, +} + +#[derive(Clone, Copy)] +struct HomescreenTextInfo { + pub text_area: Rect, + pub text_width: i16, + pub text_color: Color, + pub icon_area: Option, +} + +pub const HOMESCREEN_IMAGE_WIDTH: i16 = WIDTH; +pub const HOMESCREEN_IMAGE_HEIGHT: i16 = HEIGHT; +pub const HOMESCREEN_TOIF_SIZE: i16 = 144; +pub const HOMESCREEN_TOIF_Y_OFFSET: i16 = 27; +pub const HOMESCREEN_TOIF_X_OFFSET: usize = + ((WIDTH.saturating_sub(HOMESCREEN_TOIF_SIZE)) / 2) as usize; + +const HOMESCREEN_MAX_ICON_SIZE: i16 = 20; +const NOTIFICATION_HEIGHT: i16 = 36; +const NOTIFICATION_BORDER: i16 = 6; +const TEXT_ICON_SPACE: i16 = 2; + +const HOMESCREEN_DIM_HEIGHT: i16 = 35; +const HOMESCREEN_DIM_START: i16 = HOMESCREEN_IMAGE_HEIGHT - 42; +const HOMESCREEN_DIM: f32 = 0.65; +const HOMESCREEN_DIM_BORDER: i16 = theme::BUTTON_SPACING; + +const LOCKSCREEN_DIM: f32 = 0.55; +const LOCKSCREEN_DIM_BG: f32 = 0.0; +const LOCKSCREEN_DIM_ALL: bool = true; + +const BLUR_SIZE: usize = 9; +const BLUR_DIV: u32 = + ((65536_f32 * (1_f32 - LOCKSCREEN_DIM_BG)) as u32) / ((BLUR_SIZE * BLUR_SIZE) as u32); +const DECOMP_LINES: usize = BLUR_SIZE + 1; +const BLUR_RADIUS: i16 = (BLUR_SIZE / 2) as i16; + +const COLORS: usize = 3; +const RED_IDX: usize = 0; +const GREEN_IDX: usize = 1; +const BLUE_IDX: usize = 2; + +pub trait HomescreenDecompressor { + fn get_height(&self) -> i16; + fn decompress(&mut self); + fn get_data(&mut self) -> &mut BufferJpeg; +} + +pub struct HomescreenJpeg<'i> { + pub output: BufferOutput, + pub input: BufferInput<'i>, + pub jdec: Option>, +} + +impl<'i> HomescreenJpeg<'i> { + pub fn new(mut input: BufferInput<'i>, pool: &'i mut [u8]) -> Self { + Self { + output: BufferOutput::new(WIDTH, 16), + jdec: JDEC::new(&mut input, pool).ok(), + input, + } + } +} + +impl<'i> HomescreenDecompressor for HomescreenJpeg<'i> { + fn get_height(&self) -> i16 { + if let Some(dec) = self.jdec.as_ref() { + return dec.mcu_height(); + } + 1 + } + + fn decompress(&mut self) { + self.jdec + .as_mut() + .map(|dec| dec.decomp(&mut self.input, &mut self.output)); + } + + fn get_data(&mut self) -> &mut BufferJpeg { + self.output.buffer() + } +} + +pub struct HomescreenToif<'i> { + pub output: BufferOutput, + pub decomp_context: UzlibContext<'i>, + line: i16, +} + +impl<'i> HomescreenToif<'i> { + pub fn new(context: UzlibContext<'i>) -> Self { + Self { + output: BufferOutput::new(WIDTH, 16), + decomp_context: context, + line: 0, + } + } +} + +impl<'i> HomescreenDecompressor for HomescreenToif<'i> { + fn get_height(&self) -> i16 { + 1 + } + + fn decompress(&mut self) { + // SAFETY: Aligning to u8 slice is safe, because the original slice is aligned + // to 16 bits, therefore there are also no residuals (prefix/suffix). + // The data in the slices are integers, so these are valid for both u16 + // and u8. + if self.line >= HOMESCREEN_TOIF_Y_OFFSET + && self.line < HOMESCREEN_TOIF_Y_OFFSET + HOMESCREEN_TOIF_SIZE + { + let (_, workbuf, _) = unsafe { self.output.buffer().buffer.align_to_mut::() }; + let result = self.decomp_context.uncompress( + &mut workbuf[2 * HOMESCREEN_TOIF_X_OFFSET + ..2 * HOMESCREEN_TOIF_X_OFFSET + 2 * HOMESCREEN_TOIF_SIZE as usize], + ); + + if result.is_err() { + self.output.buffer().buffer.fill(0); + } else { + for i in 0..HOMESCREEN_TOIF_SIZE as usize { + workbuf.swap( + 2 * HOMESCREEN_TOIF_X_OFFSET + 2 * i, + 2 * HOMESCREEN_TOIF_X_OFFSET + 2 * i + 1, + ); + } + } + } else { + self.output.buffer().buffer.fill(0); + } + self.line += 1; + } + + fn get_data(&mut self) -> &mut BufferJpeg { + self.output.buffer() + } +} + +fn homescreen_get_fg_text( + y_tmp: i16, + text_info: HomescreenTextInfo, + text_buffer: &BufferText, + fg_buffer: &mut BufferLine4bpp, +) -> bool { + if y_tmp >= text_info.text_area.y0 && y_tmp < text_info.text_area.y1 { + let y_pos = y_tmp - text_info.text_area.y0; + position_buffer( + &mut fg_buffer.buffer, + &text_buffer.buffer[(y_pos * WIDTH / 2) as usize..((y_pos + 1) * WIDTH / 2) as usize], + 4, + text_info.text_area.x0, + text_info.text_width, + ); + } + + y_tmp == (text_info.text_area.y1 - 1) +} + +fn homescreen_get_fg_icon( + y_tmp: i16, + text_info: HomescreenTextInfo, + icon_data: &[u8], + fg_buffer: &mut BufferLine4bpp, +) { + if let Some(icon_area) = text_info.icon_area { + let icon_size = icon_area.size(); + if y_tmp >= icon_area.y0 && y_tmp < icon_area.y1 { + let y_pos = y_tmp - icon_area.y0; + position_buffer( + &mut fg_buffer.buffer, + &icon_data + [(y_pos * icon_size.x / 2) as usize..((y_pos + 1) * icon_size.x / 2) as usize], + 4, + icon_area.x0, + icon_size.x, + ); + } + } +} + +fn homescreen_position_text( + text: &HomescreenText, + buffer: &mut BufferText, + icon_buffer: &mut [u8], +) -> HomescreenTextInfo { + let text_width = text + .text + .map(|t| display::text_width(t, text.style.text_font.into())); + let font_max_height = display::text_max_height(text.style.text_font.into()); + let font_baseline = display::text_baseline(text.style.text_font.into()); + let text_width_clamped = text_width.clamp(0, screen().width()); + + let icon_size = if let Some(icon) = text.icon { + let size = icon.toif.size(); + assert!(size.x <= HOMESCREEN_MAX_ICON_SIZE); + assert!(size.y <= HOMESCREEN_MAX_ICON_SIZE); + icon.toif.uncompress(icon_buffer); + size + } else { + Offset::zero() + }; + + let text_top = screen().y0 + text.offset.y - font_max_height + font_baseline; + let text_bottom = screen().y0 + text.offset.y + font_baseline; + + let total_width = text_width_clamped + icon_size.x + TEXT_ICON_SPACE; + let icon_left = screen().center().x + text.offset.x - total_width / 2; + let text_left = icon_left + icon_size.x + TEXT_ICON_SPACE; + let text_right = screen().center().x + text.offset.x + total_width / 2; + + let text_area = Rect::new( + Point::new(text_left, text_top), + Point::new(text_right, text_bottom), + ); + + let icon_area = if text.icon.is_some() { + Some(Rect::from_top_left_and_size( + Point::new(icon_left, text_bottom - icon_size.y - font_baseline), + icon_size, + )) + } else { + None + }; + + text.text + .map(|t| display::text_into_buffer(t, text.style.text_font.into(), buffer, 0)); + + HomescreenTextInfo { + text_area, + text_width, + text_color: text.style.text_color, + icon_area, + } +} + +#[inline(always)] +fn homescreen_dim_area(x: i16, y: i16) -> bool { + y >= HOMESCREEN_DIM_START + && (y > HOMESCREEN_DIM_START + 1 + && y < (HOMESCREEN_DIM_START + HOMESCREEN_DIM_HEIGHT - 1) + && x > HOMESCREEN_DIM_BORDER + && x < WIDTH - HOMESCREEN_DIM_BORDER) + || (y > HOMESCREEN_DIM_START + && y < (HOMESCREEN_DIM_START + HOMESCREEN_DIM_HEIGHT) + && x > HOMESCREEN_DIM_BORDER + 1 + && x < WIDTH - (HOMESCREEN_DIM_BORDER + 1)) + || ((HOMESCREEN_DIM_START..=(HOMESCREEN_DIM_START + HOMESCREEN_DIM_HEIGHT)).contains(&y) + && x > HOMESCREEN_DIM_BORDER + 2 + && x < WIDTH - (HOMESCREEN_DIM_BORDER + 2)) +} + +fn homescreen_line_blurred( + icon_data: &[u8], + text_buffer: &mut BufferText, + fg_buffer: &mut BufferLine4bpp, + img_buffer: &mut BufferLine16bpp, + text_info: HomescreenTextInfo, + blurring: &BlurringContext, + y: i16, +) -> bool { + fg_buffer.buffer.fill(0); + for x in 0..HOMESCREEN_IMAGE_WIDTH { + let c = if LOCKSCREEN_DIM_ALL { + let x = x as usize; + + let coef = (65536_f32 * LOCKSCREEN_DIM) as u32; + + let r = (blurring.totals.buffer[RED_IDX][x] as u32 * BLUR_DIV) >> 16; + let g = (blurring.totals.buffer[GREEN_IDX][x] as u32 * BLUR_DIV) >> 16; + let b = (blurring.totals.buffer[BLUE_IDX][x] as u32 * BLUR_DIV) >> 16; + + let r = (((coef * r) >> 8) & 0xF800) as u16; + let g = (((coef * g) >> 13) & 0x07E0) as u16; + let b = (((coef * b) >> 19) & 0x001F) as u16; + + r | g | b + } else { + let x = x as usize; + + let r = (((blurring.totals.buffer[RED_IDX][x] as u32 * BLUR_DIV) >> 8) & 0xF800) as u16; + let g = + (((blurring.totals.buffer[GREEN_IDX][x] as u32 * BLUR_DIV) >> 13) & 0x07E0) as u16; + let b = + (((blurring.totals.buffer[BLUE_IDX][x] as u32 * BLUR_DIV) >> 19) & 0x001F) as u16; + r | g | b + }; + + let j = (2 * x) as usize; + img_buffer.buffer[j + 1] = (c >> 8) as u8; + img_buffer.buffer[j] = (c & 0xFF) as u8; + } + + let done = homescreen_get_fg_text(y, text_info, text_buffer, fg_buffer); + homescreen_get_fg_icon(y, text_info, icon_data, fg_buffer); + + dma2d_wait_for_transfer(); + dma2d_setup_4bpp_over_16bpp(text_info.text_color.into()); + unsafe { + dma2d_start_blend(&fg_buffer.buffer, &img_buffer.buffer, WIDTH); + } + + done +} + +#[allow(clippy::too_many_arguments)] +fn homescreen_line( + icon_data: &[u8], + text_buffer: &mut BufferText, + text_info: HomescreenTextInfo, + data_buffer: &mut BufferJpeg, + fg_buffer: &mut BufferLine4bpp, + img_buffer: &mut BufferLine16bpp, + mcu_height: i16, + y: i16, +) -> bool { + let image_data = get_data(data_buffer, y, mcu_height); + fg_buffer.buffer.fill(0); + + for x in 0..HOMESCREEN_IMAGE_WIDTH { + let d = image_data[x as usize]; + + let c = if homescreen_dim_area(x, y) { + let coef = (65536_f32 * HOMESCREEN_DIM) as u32; + + let r = (d & 0xF800) >> 8; + let g = (d & 0x07E0) >> 3; + let b = (d & 0x001F) << 3; + + let r = (((coef * r as u32) >> 8) & 0xF800) as u16; + let g = (((coef * g as u32) >> 13) & 0x07E0) as u16; + let b = (((coef * b as u32) >> 19) & 0x001F) as u16; + r | g | b + } else { + d + }; + + let j = 2 * x as usize; + img_buffer.buffer[j + 1] = (c >> 8) as u8; + img_buffer.buffer[j] = (c & 0xFF) as u8; + } + + let done = homescreen_get_fg_text(y, text_info, text_buffer, fg_buffer); + homescreen_get_fg_icon(y, text_info, icon_data, fg_buffer); + + dma2d_wait_for_transfer(); + dma2d_setup_4bpp_over_16bpp(text_info.text_color.into()); + unsafe { + dma2d_start_blend(&fg_buffer.buffer, &img_buffer.buffer, WIDTH); + } + + done +} + +fn homescreen_next_text( + texts: &[HomescreenText], + text_buffer: &mut BufferText, + icon_data: &mut [u8], + text_info: HomescreenTextInfo, + text_idx: usize, +) -> (HomescreenTextInfo, usize) { + let mut next_text_idx = text_idx; + let mut next_text_info = text_info; + + if next_text_idx < texts.len() { + if let Some(txt) = texts.get(next_text_idx) { + text_buffer.buffer.fill(0); + next_text_info = homescreen_position_text(txt, text_buffer, icon_data); + next_text_idx += 1; + } + } + + (next_text_info, next_text_idx) +} + +#[inline(always)] +fn update_accs_add(data: &[u16], idx: usize, acc_r: &mut u16, acc_g: &mut u16, acc_b: &mut u16) { + let d = data[idx]; + let r = (d & 0xF800) >> 8; + let g = (d & 0x07E0) >> 3; + let b = (d & 0x001F) << 3; + *acc_r += r; + *acc_g += g; + *acc_b += b; +} + +#[inline(always)] +fn update_accs_sub(data: &[u16], idx: usize, acc_r: &mut u16, acc_g: &mut u16, acc_b: &mut u16) { + let d = data[idx]; + let r = (d & 0xF800) >> 8; + let g = (d & 0x07E0) >> 3; + let b = (d & 0x001F) << 3; + *acc_r -= r; + *acc_g -= g; + *acc_b -= b; +} + +struct BlurringContext { + mem: BufferBlurring, + pub totals: BufferBlurringTotals, + line_num: i16, + add_idx: usize, + rem_idx: usize, +} + +impl BlurringContext { + pub fn new() -> Self { + Self { + mem: BufferBlurring::get_cleared(), + totals: BufferBlurringTotals::get_cleared(), + line_num: 0, + add_idx: 0, + rem_idx: 0, + } + } + + fn clear(&mut self) { + let lines = &mut self.mem.buffer[0..DECOMP_LINES]; + for (i, total) in self.totals.buffer.iter_mut().enumerate() { + for line in lines.iter_mut() { + line[i].fill(0); + } + total.fill(0); + } + } + + // computes color averages for one line of image data + fn compute_line_avgs(&mut self, buffer: &mut BufferJpeg, mcu_height: i16) { + let lines = &mut self.mem.buffer[0..DECOMP_LINES]; + let mut acc_r = 0; + let mut acc_g = 0; + let mut acc_b = 0; + let data = get_data(buffer, self.line_num, mcu_height); + + for i in -BLUR_RADIUS..=BLUR_RADIUS { + let ic = i.clamp(0, HOMESCREEN_IMAGE_WIDTH - 1) as usize; + update_accs_add(data, ic, &mut acc_r, &mut acc_g, &mut acc_b); + } + + for i in 0..HOMESCREEN_IMAGE_WIDTH { + lines[self.add_idx][RED_IDX][i as usize] = acc_r; + lines[self.add_idx][GREEN_IDX][i as usize] = acc_g; + lines[self.add_idx][BLUE_IDX][i as usize] = acc_b; + + // clamping handles left and right edges + let ic = (i - BLUR_RADIUS).clamp(0, HOMESCREEN_IMAGE_WIDTH - 1) as usize; + let ic2 = + (i + BLUR_SIZE as i16 - BLUR_RADIUS).clamp(0, HOMESCREEN_IMAGE_WIDTH - 1) as usize; + update_accs_add(data, ic2, &mut acc_r, &mut acc_g, &mut acc_b); + update_accs_sub(data, ic, &mut acc_r, &mut acc_g, &mut acc_b); + } + self.line_num += 1; + } + + // adds one line of averages to sliding total averages + fn vertical_avg_add(&mut self) { + let lines = &mut self.mem.buffer[0..DECOMP_LINES]; + for i in 0..HOMESCREEN_IMAGE_WIDTH as usize { + self.totals.buffer[RED_IDX][i] += lines[self.add_idx][RED_IDX][i]; + self.totals.buffer[GREEN_IDX][i] += lines[self.add_idx][GREEN_IDX][i]; + self.totals.buffer[BLUE_IDX][i] += lines[self.add_idx][BLUE_IDX][i]; + } + } + + // adds one line and removes one line of averages to/from sliding total averages + fn vertical_avg(&mut self) { + let lines = &mut self.mem.buffer[0..DECOMP_LINES]; + for i in 0..HOMESCREEN_IMAGE_WIDTH as usize { + self.totals.buffer[RED_IDX][i] += + lines[self.add_idx][RED_IDX][i] - lines[self.rem_idx][RED_IDX][i]; + self.totals.buffer[GREEN_IDX][i] += + lines[self.add_idx][GREEN_IDX][i] - lines[self.rem_idx][GREEN_IDX][i]; + self.totals.buffer[BLUE_IDX][i] += + lines[self.add_idx][BLUE_IDX][i] - lines[self.rem_idx][BLUE_IDX][i]; + } + } + + fn inc_add(&mut self) { + self.add_idx += 1; + if self.add_idx >= DECOMP_LINES { + self.add_idx = 0; + } + } + + fn inc_rem(&mut self) { + self.rem_idx += 1; + if self.rem_idx >= DECOMP_LINES { + self.rem_idx = 0; + } + } + + fn get_line_num(&self) -> i16 { + self.line_num + } +} + +#[inline(always)] +fn get_data(buffer: &mut BufferJpeg, line_num: i16, mcu_height: i16) -> &mut [u16] { + let data_start = ((line_num % mcu_height) * WIDTH) as usize; + let data_end = (((line_num % mcu_height) + 1) * WIDTH) as usize; + &mut buffer.buffer[data_start..data_end] +} + +pub fn homescreen_blurred(data: &mut dyn HomescreenDecompressor, texts: &[HomescreenText]) { + let mut icon_data = [0_u8; (HOMESCREEN_MAX_ICON_SIZE * HOMESCREEN_MAX_ICON_SIZE / 2) as usize]; + + let mut text_buffer = BufferText::get_cleared(); + let mut fg_buffer_0 = BufferLine4bpp::get_cleared(); + let mut img_buffer_0 = BufferLine16bpp::get_cleared(); + let mut fg_buffer_1 = BufferLine4bpp::get_cleared(); + let mut img_buffer_1 = BufferLine16bpp::get_cleared(); + + let mut next_text_idx = 1; + let mut text_info = + homescreen_position_text(unwrap!(texts.first()), &mut text_buffer, &mut icon_data); + + let mcu_height = data.get_height(); + data.decompress(); + + set_window(screen()); + + let mut blurring = BlurringContext::new(); + + // handling top edge case: preload the edge value N+1 times + blurring.compute_line_avgs(data.get_data(), mcu_height); + + for _ in 0..=BLUR_RADIUS { + blurring.vertical_avg_add(); + } + blurring.inc_add(); + + // load enough values to be able to compute first line averages + for _ in 0..BLUR_RADIUS { + blurring.compute_line_avgs(data.get_data(), mcu_height); + blurring.vertical_avg_add(); + blurring.inc_add(); + + if (blurring.get_line_num() % mcu_height) == 0 { + data.decompress(); + } + } + + for y in 0..HEIGHT { + // several lines have been already decompressed before this loop, adjust for + // that + if y < HOMESCREEN_IMAGE_HEIGHT - (BLUR_RADIUS + 1) { + blurring.compute_line_avgs(data.get_data(), mcu_height); + } + + let done = if y % 2 == 0 { + homescreen_line_blurred( + &icon_data, + &mut text_buffer, + &mut fg_buffer_0, + &mut img_buffer_0, + text_info, + &blurring, + y, + ) + } else { + homescreen_line_blurred( + &icon_data, + &mut text_buffer, + &mut fg_buffer_1, + &mut img_buffer_1, + text_info, + &blurring, + y, + ) + }; + + if done { + (text_info, next_text_idx) = homescreen_next_text( + texts, + &mut text_buffer, + &mut icon_data, + text_info, + next_text_idx, + ); + } + + blurring.vertical_avg(); + + // handling bottom edge case: stop incrementing counter, adding the edge value + // for the rest of image + // the extra -1 is to indicate that this was the last decompressed line, + // in the next pass the docompression and compute_line_avgs won't happen + if y < HOMESCREEN_IMAGE_HEIGHT - (BLUR_RADIUS + 1) - 1 { + blurring.inc_add(); + } + + if y == HOMESCREEN_IMAGE_HEIGHT { + // reached end of image, clear avgs (display black) + blurring.clear(); + } + + // only start incrementing remove index when enough lines have been loaded + if y >= (BLUR_RADIUS) { + blurring.inc_rem(); + } + + if (blurring.get_line_num() % mcu_height) == 0 && (blurring.get_line_num() < HEIGHT) { + data.decompress(); + } + } + dma2d_wait_for_transfer(); +} + +pub fn homescreen( + data: &mut dyn HomescreenDecompressor, + texts: &[HomescreenText], + notification: Option, + notification_only: bool, +) { + let mut icon_data = [0_u8; (HOMESCREEN_MAX_ICON_SIZE * HOMESCREEN_MAX_ICON_SIZE / 2) as usize]; + + let mut text_buffer = BufferText::get_cleared(); + let mut fg_buffer_0 = BufferLine4bpp::get_cleared(); + let mut img_buffer_0 = BufferLine16bpp::get_cleared(); + let mut fg_buffer_1 = BufferLine4bpp::get_cleared(); + let mut img_buffer_1 = BufferLine16bpp::get_cleared(); + + let mut next_text_idx = 0; + let mut text_info = if let Some(notification) = notification { + rect_fill_rounded_buffer( + Rect::from_top_left_and_size( + Point::new(NOTIFICATION_BORDER, 0), + Offset::new(WIDTH - NOTIFICATION_BORDER * 2, NOTIFICATION_HEIGHT), + ), + 2, + &mut text_buffer, + ); + let area = Rect::new( + Point::new(0, NOTIFICATION_BORDER), + Point::new(WIDTH, NOTIFICATION_HEIGHT + NOTIFICATION_BORDER), + ); + HomescreenTextInfo { + text_area: area, + text_width: WIDTH, + text_color: notification.color, + icon_area: None, + } + } else { + next_text_idx += 1; + homescreen_position_text(unwrap!(texts.first()), &mut text_buffer, &mut icon_data) + }; + + set_window(screen()); + + let mcu_height = data.get_height(); + + for y in 0..HEIGHT { + if (y % mcu_height) == 0 { + data.decompress(); + } + + let done = if y % 2 == 0 { + homescreen_line( + &icon_data, + &mut text_buffer, + text_info, + data.get_data(), + &mut fg_buffer_0, + &mut img_buffer_0, + mcu_height, + y, + ) + } else { + homescreen_line( + &icon_data, + &mut text_buffer, + text_info, + data.get_data(), + &mut fg_buffer_1, + &mut img_buffer_1, + mcu_height, + y, + ) + }; + + if done { + if notification.is_some() && next_text_idx == 0 { + //finished notification area, let interrupt and draw the text + let notification = unwrap!(notification); + + let style = TextStyle { + background_color: notification.color, + ..theme::TEXT_BOLD + }; + + dma2d_wait_for_transfer(); + + drop(fg_buffer_0); + drop(fg_buffer_1); + + icon_text_center( + text_info.text_area.center(), + notification.icon, + 8, + notification.text, + style, + Offset::new(1, -2), + ); + + fg_buffer_0 = BufferLine4bpp::get_cleared(); + fg_buffer_1 = BufferLine4bpp::get_cleared(); + + set_window( + screen() + .split_top(NOTIFICATION_HEIGHT + NOTIFICATION_BORDER) + .1, + ); + } + + if notification_only && next_text_idx == 0 { + dma2d_wait_for_transfer(); + return; + } + + (text_info, next_text_idx) = homescreen_next_text( + texts, + &mut text_buffer, + &mut icon_data, + text_info, + next_text_idx, + ); + } + } + dma2d_wait_for_transfer(); +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/bip39.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/bip39.rs new file mode 100644 index 000000000..e36db3c1b --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/bip39.rs @@ -0,0 +1,326 @@ +use crate::{ + trezorhal::bip39, + ui::{ + component::{text::common::TextBox, Component, Event, EventCtx}, + display, + geometry::{Alignment2D, Offset, Rect}, + model_mercury::{ + component::{ + keyboard::{ + common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard}, + mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT}, + }, + Button, ButtonContent, ButtonMsg, + }, + theme, + }, + shape, + shape::Renderer, + }, +}; +use heapless::String; + +const MAX_LENGTH: usize = 8; + +pub struct Bip39Input { + button: Button<&'static str>, + // used only to keep track of suggestion text color + button_suggestion: Button<&'static str>, + textbox: TextBox, + multi_tap: MultiTapKeyboard, + options_num: Option, + suggested_word: Option<&'static str>, +} + +impl MnemonicInput for Bip39Input { + /// Return the key set. Keys are further specified as indices into this + /// array. + fn keys() -> [&'static str; MNEMONIC_KEY_COUNT] { + ["abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz"] + } + + /// Returns `true` if given key index can continue towards a valid mnemonic + /// word, `false` otherwise. + fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool { + // Currently pending key is always enabled. + let key_is_pending = self.multi_tap.pending_key() == Some(key); + // Keys that contain letters from the completion mask are enabled as well. + let key_matches_mask = + bip39::word_completion_mask(self.textbox.content()) & Self::key_mask(key) != 0; + key_is_pending || key_matches_mask + } + + /// Key button was clicked. If this button is pending, let's cycle the + /// pending character in textbox. If not, let's just append the first + /// character. + fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize) { + let edit = self.multi_tap.click_key(ctx, key, Self::keys()[key]); + self.textbox.apply(ctx, edit); + self.complete_word_from_dictionary(ctx); + } + + /// Backspace button was clicked, let's delete the last character of input + /// and clear the pending marker. + fn on_backspace_click(&mut self, ctx: &mut EventCtx) { + self.multi_tap.clear_pending_state(ctx); + self.textbox.delete_last(ctx); + self.complete_word_from_dictionary(ctx); + } + + /// Backspace button was long pressed, let's delete all characters of input + /// and clear the pending marker. + fn on_backspace_long_press(&mut self, ctx: &mut EventCtx) { + self.multi_tap.clear_pending_state(ctx); + self.textbox.clear(ctx); + self.complete_word_from_dictionary(ctx); + } + + fn is_empty(&self) -> bool { + self.textbox.is_empty() + } + + fn mnemonic(&self) -> Option<&'static str> { + self.suggested_word + } +} + +impl Component for Bip39Input { + type Msg = MnemonicInputMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.button.place(bounds); + self.button_suggestion.place(bounds) + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + self.button_suggestion.event(ctx, event); + if self.multi_tap.is_timeout_event(event) { + self.on_timeout(ctx) + } else if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) { + self.on_input_click(ctx) + } else { + None + } + } + + fn paint(&mut self) { + let area = self.button.area(); + let style = self.button.style(); + + // First, paint the button background. + self.button.paint_background(style); + + // Paint the entered content (the prefix of the suggested word). + let text = self.textbox.content(); + let width = style.font.text_width(text); + // Content starts in the left-center point, offset by 16px to the right and 8px + // to the bottom. + let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8); + display::text_left( + text_baseline, + text, + style.font, + style.text_color, + style.button_color, + ); + + // Paint the rest of the suggested dictionary word. + if let Some(word) = self.suggested_word.and_then(|w| w.get(text.len()..)) { + let word_baseline = text_baseline + Offset::new(width, 0); + let style = self.button_suggestion.style(); + display::text_left( + word_baseline, + word, + style.font, + style.text_color, + style.button_color, + ); + } + + // Paint the pending marker. + if self.multi_tap.pending_key().is_some() { + paint_pending_marker(text_baseline, text, style.font, style.text_color); + } + + // Paint the icon. + if let ButtonContent::Icon(icon) = self.button.content() { + // Icon is painted in the right-center point, of expected size 16x16 pixels, and + // 16px from the right edge. + let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0); + icon.draw( + icon_center, + Alignment2D::CENTER, + style.text_color, + style.button_color, + ); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + let area = self.button.area(); + let style = self.button.style(); + + // First, paint the button background. + self.button.render_background(target, style); + + // Paint the entered content (the prefix of the suggested word). + let text = self.textbox.content(); + let width = style.font.text_width(text); + // Content starts in the left-center point, offset by 16px to the right and 8px + // to the bottom. + let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8); + shape::Text::new(text_baseline, text) + .with_font(style.font) + .with_fg(style.text_color) + .render(target); + + // Paint the rest of the suggested dictionary word. + if let Some(word) = self.suggested_word.and_then(|w| w.get(text.len()..)) { + let word_baseline = text_baseline + Offset::new(width, 0); + let style = self.button_suggestion.style(); + shape::Text::new(word_baseline, word) + .with_font(style.font) + .with_fg(style.text_color) + .render(target); + } + + // Paint the pending marker. + if self.multi_tap.pending_key().is_some() { + render_pending_marker(target, text_baseline, text, style.font, style.text_color); + } + + // Paint the icon. + if let ButtonContent::Icon(icon) = self.button.content() { + // Icon is painted in the right-center point, of expected size 16x16 pixels, and + // 16px from the right edge. + let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0); + shape::ToifImage::new(icon_center, icon.toif) + .with_align(Alignment2D::CENTER) + .with_fg(style.text_color) + .render(target); + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.button.bounds(sink); + } +} + +impl Bip39Input { + pub fn new() -> Self { + Self { + button: Button::empty(), + textbox: TextBox::empty(), + multi_tap: MultiTapKeyboard::new(), + options_num: None, + suggested_word: None, + button_suggestion: Button::empty(), + } + } + + pub fn prefilled_word(word: &str) -> Self { + // Word may be empty string, fallback to normal input + if word.is_empty() { + return Self::new(); + } + + // Styling the input to reflect already filled word + Self { + button: Button::with_icon(theme::ICON_LIST_CHECK).styled(theme::button_pin_confirm()), + textbox: TextBox::new(String::from(word)), + multi_tap: MultiTapKeyboard::new(), + options_num: bip39::options_num(word), + suggested_word: bip39::complete_word(word), + button_suggestion: Button::empty().styled(theme::button_suggestion_confirm()), + } + } + + /// Compute a bitmask of all letters contained in given key text. Lowest bit + /// is 'a', second lowest 'b', etc. + fn key_mask(key: usize) -> u32 { + let mut mask = 0; + for ch in Self::keys()[key].as_bytes() { + // We assume the key text is lower-case alphabetic ASCII, making the subtraction + // and the shift panic-free. + mask |= 1 << (ch - b'a'); + } + mask + } + + /// Input button was clicked. If the content matches the suggested word, + /// let's confirm it, otherwise just auto-complete. + fn on_input_click(&mut self, ctx: &mut EventCtx) -> Option { + if let (Some(word), Some(num)) = (self.suggested_word, self.options_num) { + return if num == 1 && word.starts_with(self.textbox.content()) + || num > 1 && word.eq(self.textbox.content()) + { + // Confirm button. + self.textbox.clear(ctx); + Some(MnemonicInputMsg::Confirmed) + } else { + // Auto-complete button. + self.textbox.replace(ctx, word); + self.complete_word_from_dictionary(ctx); + Some(MnemonicInputMsg::Completed) + }; + } + None + } + + /// Timeout occurred. If we can auto-complete current input, let's just + /// reset the pending marker. If not, input is invalid, let's backspace the + /// last character. + fn on_timeout(&mut self, ctx: &mut EventCtx) -> Option { + self.multi_tap.clear_pending_state(ctx); + if self.suggested_word.is_none() { + self.textbox.delete_last(ctx); + self.complete_word_from_dictionary(ctx); + } + Some(MnemonicInputMsg::TimedOut) + } + + fn complete_word_from_dictionary(&mut self, ctx: &mut EventCtx) { + self.options_num = bip39::options_num(self.textbox.content()); + self.suggested_word = bip39::complete_word(self.textbox.content()); + + // Change the style of the button depending on the completed word. + if let (Some(word), Some(num)) = (self.suggested_word, self.options_num) { + if num == 1 && word.starts_with(self.textbox.content()) + || num > 1 && word.eq(self.textbox.content()) + { + // Confirm button. + self.button.enable(ctx); + self.button.set_stylesheet(ctx, theme::button_pin_confirm()); + self.button + .set_content(ctx, ButtonContent::Icon(theme::ICON_LIST_CHECK)); + self.button_suggestion + .set_stylesheet(ctx, theme::button_suggestion_confirm()); + } else { + // Auto-complete button. + self.button.enable(ctx); + self.button + .set_stylesheet(ctx, theme::button_pin_autocomplete()); + self.button + .set_content(ctx, ButtonContent::Icon(theme::ICON_CLICK)); + self.button_suggestion + .set_stylesheet(ctx, theme::button_suggestion_autocomplete()); + } + } else { + // Disabled button. + self.button.disable(ctx); + self.button.set_stylesheet(ctx, theme::button_pin()); + self.button.set_content(ctx, ButtonContent::Text("")); + } + } +} + +// DEBUG-ONLY SECTION BELOW + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Bip39Input { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("Bip39Input"); + t.child("textbox", &self.textbox); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/common.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/common.rs new file mode 100644 index 000000000..619f9120b --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/common.rs @@ -0,0 +1,152 @@ +use crate::{ + time::Duration, + ui::{ + component::{text::common::TextEdit, Event, EventCtx, TimerToken}, + display::{self, Color, Font}, + geometry::{Offset, Point, Rect}, + shape, + shape::Renderer, + }, +}; + +/// Contains state commonly used in implementations multi-tap keyboards. +pub struct MultiTapKeyboard { + /// Configured timeout after which we cancel currently pending key. + timeout: Duration, + /// The currently pending state. + pending: Option, +} + +struct Pending { + /// Index of the pending key. + key: usize, + /// Index of the key press (how many times the `key` was pressed, minus + /// one). + press: usize, + /// Timer for clearing the pending state. + timer: TimerToken, +} + +impl MultiTapKeyboard { + /// Create a new, empty, multi-tap state. + pub fn new() -> Self { + Self { + timeout: Duration::from_secs(1), + pending: None, + } + } + + /// Return the index of the currently pending key, if any. + pub fn pending_key(&self) -> Option { + self.pending.as_ref().map(|p| p.key) + } + + /// Return the index of the pending key press. + pub fn pending_press(&self) -> Option { + self.pending.as_ref().map(|p| p.press) + } + + /// Return the token for the currently pending timer. + pub fn pending_timer(&self) -> Option { + self.pending.as_ref().map(|p| p.timer) + } + + /// Returns `true` if `event` is an `Event::Timer` for the currently pending + /// timer. + pub fn is_timeout_event(&self, event: Event) -> bool { + matches!((event, self.pending_timer()), (Event::Timer(t), Some(pt)) if pt == t) + } + + /// Reset to the empty state. Takes `EventCtx` to request a paint pass (to + /// either hide or show any pending marker our caller might want to draw + /// later). + pub fn clear_pending_state(&mut self, ctx: &mut EventCtx) { + if self.pending.is_some() { + self.pending = None; + ctx.request_paint(); + } + } + + /// Register a click to a key. `MultiTapKeyboard` itself does not have any + /// concept of the key set, so both the key index and the key content is + /// taken here. Returns a text editing operation the caller should apply on + /// the output buffer. Takes `EventCtx` to request a timeout for cancelling + /// the pending state. Caller is required to handle the timer event and + /// call `Self::clear_pending_state` when the timer hits. + pub fn click_key(&mut self, ctx: &mut EventCtx, key: usize, key_text: &str) -> TextEdit { + let (is_pending, press) = match &self.pending { + Some(pending) if pending.key == key => { + // This key is pending. Cycle the last inserted character through the + // key content. + (true, pending.press.wrapping_add(1)) + } + _ => { + // This key is not pending. Append the first character in the key. + (false, 0) + } + }; + + // If the key has more then one character, we need to set it as pending, so we + // can cycle through on the repeated clicks. We also request a timer so we can + // reset the pending state after a deadline. + // + // Note: It might seem that we should make sure to `request_paint` in case we + // progress into a pending state (to display the pending marker), but such + // transition only happens as a result of an append op, so the painting should + // be requested by handling the `TextEdit`. + self.pending = if key_text.len() > 1 { + Some(Pending { + key, + press, + timer: ctx.request_timer(self.timeout), + }) + } else { + None + }; + + assert!(!key_text.is_empty()); + // Now we can be sure that a looped iterator will return a value + let ch = unwrap!(key_text.chars().cycle().nth(press)); + if is_pending { + TextEdit::ReplaceLast(ch) + } else { + TextEdit::Append(ch) + } + } +} + +/// Create a visible "underscoring" of the last letter of a text. +pub fn paint_pending_marker(text_baseline: Point, text: &str, font: Font, color: Color) { + // Measure the width of the last character of input. + if let Some(last) = text.chars().last() { + let width = font.text_width(text); + let last_width = font.char_width(last); + // Draw the marker 2px under the start of the baseline of the last character. + let marker_origin = text_baseline + Offset::new(width - last_width, 2); + // Draw the marker 1px longer than the last character, and 3px thick. + let marker_rect = + Rect::from_top_left_and_size(marker_origin, Offset::new(last_width + 1, 3)); + display::rect_fill(marker_rect, color); + } +} + +/// Create a visible "underscoring" of the last letter of a text. +pub fn render_pending_marker( + target: &mut impl Renderer, + text_baseline: Point, + text: &str, + font: Font, + color: Color, +) { + // Measure the width of the last character of input. + if let Some(last) = text.chars().last() { + let width = font.text_width(text); + let last_width = font.char_width(last); + // Draw the marker 2px under the start of the baseline of the last character. + let marker_origin = text_baseline + Offset::new(width - last_width, 2); + // Draw the marker 1px longer than the last character, and 3px thick. + let marker_rect = + Rect::from_top_left_and_size(marker_origin, Offset::new(last_width + 1, 3)); + shape::Bar::new(marker_rect).with_bg(color).render(target); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/mnemonic.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/mnemonic.rs new file mode 100644 index 000000000..d31a6b990 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/mnemonic.rs @@ -0,0 +1,234 @@ +use crate::ui::{ + component::{maybe::paint_overlapping, Child, Component, Event, EventCtx, Label, Maybe}, + geometry::{Alignment2D, Grid, Offset, Rect}, + model_mercury::{ + component::{Button, ButtonMsg, Swipe, SwipeDirection}, + theme, + }, + shape::Renderer, +}; + +pub const MNEMONIC_KEY_COUNT: usize = 9; + +pub enum MnemonicKeyboardMsg { + Confirmed, + Previous, +} + +pub struct MnemonicKeyboard { + /// Initial prompt, displayed on empty input. + prompt: Child>>, + /// Backspace button. + back: Child>>, + /// Input area, acting as the auto-complete and confirm button. + input: Child>, + /// Key buttons. + keys: [Child>; MNEMONIC_KEY_COUNT], + /// Swipe controller - allowing for going to the previous word. + swipe: Swipe, + /// Whether going back is allowed (is not on the very first word). + can_go_back: bool, +} + +impl MnemonicKeyboard +where + T: MnemonicInput, + U: AsRef, +{ + pub fn new(input: T, prompt: U, can_go_back: bool) -> Self { + // Input might be already pre-filled + let prompt_visible = input.is_empty(); + + Self { + prompt: Child::new(Maybe::new( + theme::BG, + Label::centered(prompt, theme::label_keyboard_prompt()), + prompt_visible, + )), + back: Child::new(Maybe::new( + theme::BG, + Button::with_icon_blend( + theme::IMAGE_BG_BACK_BTN_TALL, + theme::ICON_BACK, + Offset::new(30, 17), + ) + .styled(theme::button_reset()) + .with_long_press(theme::ERASE_HOLD_DURATION), + !prompt_visible, + )), + input: Child::new(Maybe::new(theme::BG, input, !prompt_visible)), + keys: T::keys() + .map(|t| Button::with_text(t).styled(theme::button_pin())) + .map(Child::new), + swipe: Swipe::new().right(), + can_go_back, + } + } + + fn on_input_change(&mut self, ctx: &mut EventCtx) { + self.toggle_key_buttons(ctx); + self.toggle_prompt_or_input(ctx); + } + + /// Either enable or disable the key buttons, depending on the dictionary + /// completion mask and the pending key. + fn toggle_key_buttons(&mut self, ctx: &mut EventCtx) { + for (key, btn) in self.keys.iter_mut().enumerate() { + let enabled = self + .input + .inner() + .inner() + .can_key_press_lead_to_a_valid_word(key); + btn.mutate(ctx, |ctx, b| b.enable_if(ctx, enabled)); + } + } + + /// After edit operations, we need to either show or hide the prompt, the + /// input, and the back button. + fn toggle_prompt_or_input(&mut self, ctx: &mut EventCtx) { + let prompt_visible = self.input.inner().inner().is_empty(); + self.prompt + .mutate(ctx, |ctx, p| p.show_if(ctx, prompt_visible)); + self.input + .mutate(ctx, |ctx, i| i.show_if(ctx, !prompt_visible)); + self.back + .mutate(ctx, |ctx, b| b.show_if(ctx, !prompt_visible)); + } + + pub fn mnemonic(&self) -> Option<&'static str> { + self.input.inner().inner().mnemonic() + } +} + +impl Component for MnemonicKeyboard +where + T: MnemonicInput, + U: AsRef, +{ + type Msg = MnemonicKeyboardMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let (_, bounds) = bounds + .inset(theme::borders()) + .split_bottom(4 * theme::MNEMONIC_BUTTON_HEIGHT + 3 * theme::KEYBOARD_SPACING); + let grid = Grid::new(bounds, 4, 3).with_spacing(theme::KEYBOARD_SPACING); + let back_area = grid.row_col(0, 0); + let input_area = grid.row_col(0, 1).union(grid.row_col(0, 3)); + + let prompt_center = grid.row_col(0, 0).union(grid.row_col(0, 3)).center(); + let prompt_size = self.prompt.inner().inner().max_size(); + let prompt_area = Rect::snap(prompt_center, prompt_size, Alignment2D::CENTER); + + self.swipe.place(bounds); + self.prompt.place(prompt_area); + self.back.place(back_area); + self.input.place(input_area); + for (key, btn) in self.keys.iter_mut().enumerate() { + btn.place(grid.cell(key + grid.cols)); // Start in the second row. + } + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + // Swipe will cause going back to the previous word when allowed. + if self.can_go_back { + if let Some(SwipeDirection::Right) = self.swipe.event(ctx, event) { + return Some(MnemonicKeyboardMsg::Previous); + } + } + + match self.input.event(ctx, event) { + Some(MnemonicInputMsg::Confirmed) => { + // Confirmed, bubble up. + return Some(MnemonicKeyboardMsg::Confirmed); + } + Some(_) => { + // Either a timeout or a completion. + self.on_input_change(ctx); + return None; + } + _ => {} + } + + match self.back.event(ctx, event) { + Some(ButtonMsg::Clicked) => { + self.input + .mutate(ctx, |ctx, i| i.inner_mut().on_backspace_click(ctx)); + self.on_input_change(ctx); + return None; + } + Some(ButtonMsg::LongPressed) => { + self.input + .mutate(ctx, |ctx, i| i.inner_mut().on_backspace_long_press(ctx)); + self.on_input_change(ctx); + return None; + } + _ => {} + } + for (key, btn) in self.keys.iter_mut().enumerate() { + if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) { + self.input + .mutate(ctx, |ctx, i| i.inner_mut().on_key_click(ctx, key)); + self.on_input_change(ctx); + return None; + } + } + None + } + + fn paint(&mut self) { + paint_overlapping(&mut [&mut self.prompt, &mut self.input, &mut self.back]); + for btn in &mut self.keys { + btn.paint(); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + self.prompt.render(target); + self.input.render(target); + self.back.render(target); + + for btn in &mut self.keys { + btn.render(target); + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.prompt.bounds(sink); + self.input.bounds(sink); + self.back.bounds(sink); + for btn in &self.keys { + btn.bounds(sink) + } + } +} + +pub trait MnemonicInput: Component { + fn keys() -> [&'static str; MNEMONIC_KEY_COUNT]; + fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool; + fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize); + fn on_backspace_click(&mut self, ctx: &mut EventCtx); + fn on_backspace_long_press(&mut self, ctx: &mut EventCtx); + fn is_empty(&self) -> bool; + fn mnemonic(&self) -> Option<&'static str>; +} + +pub enum MnemonicInputMsg { + Confirmed, + Completed, + TimedOut, +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for MnemonicKeyboard +where + T: MnemonicInput + crate::trace::Trace, + U: AsRef, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("MnemonicKeyboard"); + t.child("prompt", &self.prompt); + t.child("input", &self.input); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/mod.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/mod.rs new file mode 100644 index 000000000..1b4b6c442 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/mod.rs @@ -0,0 +1,8 @@ +pub mod bip39; +pub mod mnemonic; +pub mod passphrase; +pub mod pin; +pub mod slip39; +pub mod word_count; + +mod common; diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/passphrase.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/passphrase.rs new file mode 100644 index 000000000..71f1ecb39 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/passphrase.rs @@ -0,0 +1,441 @@ +use crate::ui::{ + component::{ + base::ComponentExt, text::common::TextBox, Child, Component, Event, EventCtx, Never, + }, + display, + geometry::{Grid, Offset, Rect}, + model_mercury::component::{ + button::{Button, ButtonContent, ButtonMsg}, + keyboard::common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard}, + swipe::{Swipe, SwipeDirection}, + theme, ScrollBar, + }, + shape, + shape::Renderer, + util::long_line_content_with_ellipsis, +}; + +pub enum PassphraseKeyboardMsg { + Confirmed, + Cancelled, +} + +pub struct PassphraseKeyboard { + page_swipe: Swipe, + input: Child, + back: Child>, + confirm: Child>, + keys: [Child>; KEY_COUNT], + scrollbar: ScrollBar, + fade: bool, +} + +const STARTING_PAGE: usize = 1; +const PAGE_COUNT: usize = 4; +const KEY_COUNT: usize = 10; +#[rustfmt::skip] +const KEYBOARD: [[&str; KEY_COUNT]; PAGE_COUNT] = [ + ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], + [" ", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz", "*#"], + [" ", "ABC", "DEF", "GHI", "JKL", "MNO", "PQRS", "TUV", "WXYZ", "*#"], + ["_<>", ".:@", "/|\\", "!()", "+%&", "-[]", "?{}", ",'`", ";\"~", "$^="], + ]; + +const MAX_LENGTH: usize = 50; +const INPUT_AREA_HEIGHT: i16 = ScrollBar::DOT_SIZE + 9; + +impl PassphraseKeyboard { + pub fn new() -> Self { + Self { + page_swipe: Swipe::horizontal(), + input: Input::new().into_child(), + confirm: Button::with_icon(theme::ICON_CONFIRM) + .styled(theme::button_confirm()) + .into_child(), + back: Button::with_icon_blend( + theme::IMAGE_BG_BACK_BTN, + theme::ICON_BACK, + Offset::new(30, 12), + ) + .styled(theme::button_reset()) + .initially_enabled(false) + .with_long_press(theme::ERASE_HOLD_DURATION) + .into_child(), + keys: KEYBOARD[STARTING_PAGE].map(|text| { + Child::new(Button::new(Self::key_content(text)).styled(theme::button_pin())) + }), + scrollbar: ScrollBar::horizontal(), + fade: false, + } + } + + fn key_text(content: &ButtonContent<&'static str>) -> &'static str { + match content { + ButtonContent::Text(text) => text, + ButtonContent::Icon(_) => " ", + ButtonContent::IconAndText(_) => " ", + ButtonContent::Empty => "", + ButtonContent::IconBlend(_, _, _) => "", + } + } + + fn key_content(text: &'static str) -> ButtonContent<&'static str> { + match text { + " " => ButtonContent::Icon(theme::ICON_SPACE), + t => ButtonContent::Text(t), + } + } + + fn on_page_swipe(&mut self, ctx: &mut EventCtx, swipe: SwipeDirection) { + // Change the page number. + let key_page = self.scrollbar.active_page; + let key_page = match swipe { + SwipeDirection::Left => (key_page as isize + 1) as usize % PAGE_COUNT, + SwipeDirection::Right => (key_page as isize - 1) as usize % PAGE_COUNT, + _ => key_page, + }; + self.scrollbar.go_to(key_page); + // Clear the pending state. + self.input + .mutate(ctx, |ctx, i| i.multi_tap.clear_pending_state(ctx)); + // Update buttons. + self.replace_button_content(ctx, key_page); + // Reset backlight to normal level on next paint. + self.fade = true; + // So that swipe does not visually enable the input buttons when max length + // reached + self.update_input_btns_state(ctx); + } + + fn replace_button_content(&mut self, ctx: &mut EventCtx, page: usize) { + for (i, btn) in self.keys.iter_mut().enumerate() { + let text = KEYBOARD[page][i]; + let content = Self::key_content(text); + btn.mutate(ctx, |ctx, b| b.set_content(ctx, content)); + btn.request_complete_repaint(ctx); + } + } + + /// Possibly changing the buttons' state after change of the input. + fn after_edit(&mut self, ctx: &mut EventCtx) { + self.update_back_btn_state(ctx); + self.update_input_btns_state(ctx); + } + + /// When the input is empty, disable the back button. + fn update_back_btn_state(&mut self, ctx: &mut EventCtx) { + if self.input.inner().textbox.is_empty() { + self.back.mutate(ctx, |ctx, b| b.disable(ctx)); + } else { + self.back.mutate(ctx, |ctx, b| b.enable(ctx)); + } + } + + /// When the input has reached max length, disable all the input buttons. + fn update_input_btns_state(&mut self, ctx: &mut EventCtx) { + let active_states = self.get_buttons_active_states(); + for (key, btn) in self.keys.iter_mut().enumerate() { + btn.mutate(ctx, |ctx, b| { + if active_states[key] { + b.enable(ctx); + } else { + b.disable(ctx); + } + }); + } + } + + /// Precomputing the active states not to overlap borrows in + /// `self.keys.iter_mut` loop. + fn get_buttons_active_states(&self) -> [bool; KEY_COUNT] { + let mut active_states: [bool; KEY_COUNT] = [false; KEY_COUNT]; + for (key, state) in active_states.iter_mut().enumerate() { + *state = self.is_button_active(key); + } + active_states + } + + /// We should disable the input when the passphrase has reached maximum + /// length and we are not cycling through the characters. + fn is_button_active(&self, key: usize) -> bool { + let textbox_not_full = !self.input.inner().textbox.is_full(); + let key_is_pending = { + if let Some(pending) = self.input.inner().multi_tap.pending_key() { + pending == key + } else { + false + } + }; + textbox_not_full || key_is_pending + } + + pub fn passphrase(&self) -> &str { + self.input.inner().textbox.content() + } +} + +impl Component for PassphraseKeyboard { + type Msg = PassphraseKeyboardMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let bounds = bounds.inset(theme::borders()); + + let (input_area, key_grid_area) = + bounds.split_bottom(4 * theme::PIN_BUTTON_HEIGHT + 3 * theme::BUTTON_SPACING); + + let (input_area, scroll_area) = input_area.split_bottom(INPUT_AREA_HEIGHT); + let (scroll_area, _) = scroll_area.split_top(ScrollBar::DOT_SIZE); + + let key_grid = Grid::new(key_grid_area, 4, 3).with_spacing(theme::BUTTON_SPACING); + let confirm_btn_area = key_grid.cell(11); + let back_btn_area = key_grid.cell(9); + + self.page_swipe.place(bounds); + self.input.place(input_area); + self.confirm.place(confirm_btn_area); + self.back.place(back_btn_area); + self.scrollbar.place(scroll_area); + self.scrollbar + .set_count_and_active_page(PAGE_COUNT, STARTING_PAGE); + + // Place all the character buttons. + for (key, btn) in &mut self.keys.iter_mut().enumerate() { + // Assign the keys in each page to buttons on a 5x3 grid, starting + // from the second row. + let area = key_grid.cell(if key < 9 { + // The grid has 3 columns, and we skip the first row. + key + } else { + // For the last key (the "0" position) we skip one cell. + key + 1 + }); + btn.place(area); + } + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if self.input.inner().multi_tap.is_timeout_event(event) { + self.input + .mutate(ctx, |ctx, i| i.multi_tap.clear_pending_state(ctx)); + return None; + } + if let Some(swipe) = self.page_swipe.event(ctx, event) { + // We have detected a horizontal swipe. Change the keyboard page. + self.on_page_swipe(ctx, swipe); + return None; + } + if let Some(ButtonMsg::Clicked) = self.confirm.event(ctx, event) { + // Confirm button was clicked, we're done. + return Some(PassphraseKeyboardMsg::Confirmed); + } + + match self.back.event(ctx, event) { + Some(ButtonMsg::Clicked) => { + // Backspace button was clicked. If we have any content in the textbox, let's + // delete the last character. Otherwise cancel. + return if self.input.inner().textbox.is_empty() { + Some(PassphraseKeyboardMsg::Cancelled) + } else { + self.input.mutate(ctx, |ctx, i| { + i.multi_tap.clear_pending_state(ctx); + i.textbox.delete_last(ctx); + }); + self.after_edit(ctx); + None + }; + } + Some(ButtonMsg::LongPressed) => { + self.input.mutate(ctx, |ctx, i| { + i.multi_tap.clear_pending_state(ctx); + i.textbox.clear(ctx); + }); + self.after_edit(ctx); + return None; + } + _ => {} + } + + // Process key button events in case we did not reach maximum passphrase length. + // (All input buttons should be disallowed in that case, this is just a safety + // measure.) + // Also we need to allow for cycling through the last character. + let active_states = self.get_buttons_active_states(); + for (key, btn) in self.keys.iter_mut().enumerate() { + if !active_states[key] { + // Button is not active + continue; + } + if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) { + // Key button was clicked. If this button is pending, let's cycle the pending + // character in textbox. If not, let's just append the first character. + let text = Self::key_text(btn.inner().content()); + self.input.mutate(ctx, |ctx, i| { + let edit = i.multi_tap.click_key(ctx, key, text); + i.textbox.apply(ctx, edit); + }); + self.after_edit(ctx); + return None; + } + } + None + } + + fn paint(&mut self) { + self.input.paint(); + self.scrollbar.paint(); + self.confirm.paint(); + self.back.paint(); + for btn in &mut self.keys { + btn.paint(); + } + if self.fade { + self.fade = false; + // Note that this is blocking and takes some time. + display::fade_backlight(theme::BACKLIGHT_NORMAL); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + self.input.render(target); + self.scrollbar.render(target); + self.confirm.render(target); + self.back.render(target); + for btn in &mut self.keys { + btn.render(target); + } + if self.fade { + self.fade = false; + // Note that this is blocking and takes some time. + display::fade_backlight(theme::BACKLIGHT_NORMAL); + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.input.bounds(sink); + self.scrollbar.bounds(sink); + self.confirm.bounds(sink); + self.back.bounds(sink); + for btn in &self.keys { + btn.bounds(sink) + } + } +} + +struct Input { + area: Rect, + textbox: TextBox, + multi_tap: MultiTapKeyboard, +} + +impl Input { + fn new() -> Self { + Self { + area: Rect::zero(), + textbox: TextBox::empty(), + multi_tap: MultiTapKeyboard::new(), + } + } +} + +impl Component for Input { + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + self.area + } + + fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option { + None + } + + fn paint(&mut self) { + let style = theme::label_keyboard(); + + let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height()) + - Offset::y(style.text_font.text_baseline()); + + let text = self.textbox.content(); + + // Preparing the new text to be displayed. + // Possible optimization is to redraw the background only when pending character + // is replaced, or only draw rectangle over the pending character and + // marker. + display::rect_fill(self.area, theme::BG); + + // Find out how much text can fit into the textbox. + // Accounting for the pending marker, which draws itself one pixel longer than + // the last character + let available_area_width = self.area.width() - 1; + let text_to_display = + long_line_content_with_ellipsis(text, "...", style.text_font, available_area_width); + + display::text_left( + text_baseline, + &text_to_display, + style.text_font, + style.text_color, + style.background_color, + ); + + // Paint the pending marker. + if self.multi_tap.pending_key().is_some() { + paint_pending_marker( + text_baseline, + &text_to_display, + style.text_font, + style.text_color, + ); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + let style = theme::label_keyboard(); + + let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height()) + - Offset::y(style.text_font.text_baseline()); + + let text = self.textbox.content(); + + shape::Bar::new(self.area).with_bg(theme::BG).render(target); + + // Find out how much text can fit into the textbox. + // Accounting for the pending marker, which draws itself one pixel longer than + // the last character + let available_area_width = self.area.width() - 1; + let text_to_display = + long_line_content_with_ellipsis(text, "...", style.text_font, available_area_width); + + shape::Text::new(text_baseline, &text_to_display) + .with_font(style.text_font) + .with_fg(style.text_color) + .render(target); + + // Paint the pending marker. + if self.multi_tap.pending_key().is_some() { + render_pending_marker( + target, + text_baseline, + &text_to_display, + style.text_font, + style.text_color, + ); + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + sink(self.area) + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for PassphraseKeyboard { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("PassphraseKeyboard"); + t.string("passphrase", self.passphrase().into()); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/pin.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/pin.rs new file mode 100644 index 000000000..933e71adc --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/pin.rs @@ -0,0 +1,572 @@ +use core::mem; +use heapless::String; + +use crate::{ + time::Duration, + trezorhal::random, + ui::{ + component::{ + base::ComponentExt, text::TextStyle, Child, Component, Event, EventCtx, Label, Maybe, + Never, Pad, TimerToken, + }, + display::{self, Font}, + event::TouchEvent, + geometry::{Alignment, Alignment2D, Grid, Insets, Offset, Rect}, + model_mercury::component::{ + button::{Button, ButtonContent, ButtonMsg, ButtonMsg::Clicked}, + theme, + }, + shape, + shape::Renderer, + }, +}; + +pub enum PinKeyboardMsg { + Confirmed, + Cancelled, +} + +const MAX_LENGTH: usize = 50; +const MAX_VISIBLE_DOTS: usize = 14; +const MAX_VISIBLE_DIGITS: usize = 16; +const DIGIT_COUNT: usize = 10; // 0..10 + +const HEADER_PADDING_SIDE: i16 = 5; +const HEADER_PADDING_BOTTOM: i16 = 12; + +const HEADER_PADDING: Insets = Insets::new( + theme::borders().top, + HEADER_PADDING_SIDE, + HEADER_PADDING_BOTTOM, + HEADER_PADDING_SIDE, +); + +pub struct PinKeyboard { + allow_cancel: bool, + major_prompt: Child>, + minor_prompt: Child>, + major_warning: Option>>, + textbox: Child, + textbox_pad: Pad, + erase_btn: Child>>, + cancel_btn: Child>>, + confirm_btn: Child>, + digit_btns: [Child>; DIGIT_COUNT], + warning_timer: Option, +} + +impl PinKeyboard +where + T: AsRef, +{ + // Label position fine-tuning. + const MAJOR_OFF: Offset = Offset::y(11); + const MINOR_OFF: Offset = Offset::y(11); + + pub fn new( + major_prompt: T, + minor_prompt: T, + major_warning: Option, + allow_cancel: bool, + ) -> Self { + // Control buttons. + let erase_btn = Button::with_icon_blend( + theme::IMAGE_BG_BACK_BTN, + theme::ICON_BACK, + Offset::new(30, 12), + ) + .styled(theme::button_reset()) + .with_long_press(theme::ERASE_HOLD_DURATION) + .initially_enabled(false); + let erase_btn = Maybe::hidden(theme::BG, erase_btn).into_child(); + + let cancel_btn = Button::with_icon(theme::ICON_CANCEL).styled(theme::button_cancel()); + let cancel_btn = Maybe::new(theme::BG, cancel_btn, allow_cancel).into_child(); + + Self { + allow_cancel, + major_prompt: Label::left_aligned(major_prompt, theme::label_keyboard()).into_child(), + minor_prompt: Label::right_aligned(minor_prompt, theme::label_keyboard_minor()) + .into_child(), + major_warning: major_warning.map(|text| { + Label::left_aligned(text, theme::label_keyboard_warning()).into_child() + }), + textbox: PinDots::new(theme::label_default()).into_child(), + textbox_pad: Pad::with_background(theme::label_default().background_color), + erase_btn, + cancel_btn, + confirm_btn: Button::with_icon(theme::ICON_CONFIRM) + .styled(theme::button_confirm()) + .initially_enabled(false) + .into_child(), + digit_btns: Self::generate_digit_buttons(), + warning_timer: None, + } + } + + fn generate_digit_buttons() -> [Child>; DIGIT_COUNT] { + // Generate a random sequence of digits from 0 to 9. + let mut digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + random::shuffle(&mut digits); + digits + .map(Button::with_text) + .map(|b| b.styled(theme::button_pin())) + .map(Child::new) + } + + fn pin_modified(&mut self, ctx: &mut EventCtx) { + let is_full = self.textbox.inner().is_full(); + let is_empty = self.textbox.inner().is_empty(); + + self.textbox_pad.clear(); + self.textbox.request_complete_repaint(ctx); + + if is_empty { + self.major_prompt.request_complete_repaint(ctx); + self.minor_prompt.request_complete_repaint(ctx); + self.major_warning.request_complete_repaint(ctx); + } + + let cancel_enabled = is_empty && self.allow_cancel; + for btn in &mut self.digit_btns { + btn.mutate(ctx, |ctx, btn| btn.enable_if(ctx, !is_full)); + } + self.erase_btn.mutate(ctx, |ctx, btn| { + btn.show_if(ctx, !is_empty); + btn.inner_mut().enable_if(ctx, !is_empty); + }); + self.cancel_btn.mutate(ctx, |ctx, btn| { + btn.show_if(ctx, cancel_enabled); + btn.inner_mut().enable_if(ctx, is_empty); + }); + self.confirm_btn + .mutate(ctx, |ctx, btn| btn.enable_if(ctx, !is_empty)); + } + + pub fn pin(&self) -> &str { + self.textbox.inner().pin() + } +} + +impl Component for PinKeyboard +where + T: AsRef, +{ + type Msg = PinKeyboardMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + // Ignore the top padding for now, we need it to reliably register textbox touch + // events. + let borders_no_top = Insets { + top: 0, + ..theme::borders() + }; + // Prompts and PIN dots display. + let (header, keypad) = bounds + .inset(borders_no_top) + .split_bottom(4 * theme::PIN_BUTTON_HEIGHT + 3 * theme::BUTTON_SPACING); + let prompt = header.inset(HEADER_PADDING); + // the inset -3 is a workaround for long text in "re-enter wipe code" + let major_area = prompt.translate(Self::MAJOR_OFF).inset(Insets::right(-3)); + let minor_area = prompt.translate(Self::MINOR_OFF); + + // Control buttons. + let grid = Grid::new(keypad, 4, 3).with_spacing(theme::BUTTON_SPACING); + + // Prompts and PIN dots display. + self.textbox_pad.place(header); + self.textbox.place(header); + self.major_prompt.place(major_area); + self.minor_prompt.place(minor_area); + self.major_warning.as_mut().map(|c| c.place(major_area)); + + // Control buttons. + let erase_cancel_area = grid.row_col(3, 0); + self.erase_btn.place(erase_cancel_area); + self.cancel_btn.place(erase_cancel_area); + self.confirm_btn.place(grid.row_col(3, 2)); + + // Digit buttons. + for (i, btn) in self.digit_btns.iter_mut().enumerate() { + // Assign the digits to buttons on a 4x3 grid, starting from the first row. + let area = grid.cell(if i < 9 { + i + } else { + // For the last key (the "0" position) we skip one cell. + i + 1 + }); + btn.place(area); + } + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + match event { + // Set up timer to switch off warning prompt. + Event::Attach if self.major_warning.is_some() => { + self.warning_timer = Some(ctx.request_timer(Duration::from_secs(2))); + } + // Hide warning, show major prompt. + Event::Timer(token) if Some(token) == self.warning_timer => { + self.major_warning = None; + self.textbox_pad.clear(); + self.minor_prompt.request_complete_repaint(ctx); + ctx.request_paint(); + } + _ => {} + } + + self.textbox.event(ctx, event); + if let Some(Clicked) = self.confirm_btn.event(ctx, event) { + return Some(PinKeyboardMsg::Confirmed); + } + if let Some(Clicked) = self.cancel_btn.event(ctx, event) { + return Some(PinKeyboardMsg::Cancelled); + } + match self.erase_btn.event(ctx, event) { + Some(ButtonMsg::Clicked) => { + self.textbox.mutate(ctx, |ctx, t| t.pop(ctx)); + self.pin_modified(ctx); + return None; + } + Some(ButtonMsg::LongPressed) => { + self.textbox.mutate(ctx, |ctx, t| t.clear(ctx)); + self.pin_modified(ctx); + return None; + } + _ => {} + } + for btn in &mut self.digit_btns { + if let Some(Clicked) = btn.event(ctx, event) { + if let ButtonContent::Text(text) = btn.inner().content() { + self.textbox.mutate(ctx, |ctx, t| t.push(ctx, text)); + self.pin_modified(ctx); + return None; + } + } + } + None + } + + fn paint(&mut self) { + self.erase_btn.paint(); + self.textbox_pad.paint(); + if self.textbox.inner().is_empty() { + if let Some(ref mut w) = self.major_warning { + w.paint(); + } else { + self.major_prompt.paint(); + } + self.minor_prompt.paint(); + self.cancel_btn.paint(); + } else { + self.textbox.paint(); + } + self.confirm_btn.paint(); + for btn in &mut self.digit_btns { + btn.paint(); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + self.erase_btn.render(target); + self.textbox_pad.render(target); + if self.textbox.inner().is_empty() { + if let Some(ref mut w) = self.major_warning { + w.render(target); + } else { + self.major_prompt.render(target); + } + self.minor_prompt.render(target); + self.cancel_btn.render(target); + } else { + self.textbox.render(target); + } + self.confirm_btn.render(target); + for btn in &mut self.digit_btns { + btn.render(target); + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.major_prompt.bounds(sink); + self.minor_prompt.bounds(sink); + self.erase_btn.bounds(sink); + self.cancel_btn.bounds(sink); + self.confirm_btn.bounds(sink); + self.textbox.bounds(sink); + for b in &self.digit_btns { + b.bounds(sink) + } + } +} + +struct PinDots { + area: Rect, + pad: Pad, + style: TextStyle, + digits: String, + display_digits: bool, +} + +impl PinDots { + const DOT: i16 = 6; + const PADDING: i16 = 6; + const TWITCH: i16 = 4; + + fn new(style: TextStyle) -> Self { + Self { + area: Rect::zero(), + pad: Pad::with_background(style.background_color), + style, + digits: String::new(), + display_digits: false, + } + } + + fn size(&self) -> Offset { + let ndots = self.digits.len().min(MAX_VISIBLE_DOTS); + let mut width = Self::DOT * (ndots as i16); + width += Self::PADDING * (ndots.saturating_sub(1) as i16); + Offset::new(width, Self::DOT) + } + + fn is_empty(&self) -> bool { + self.digits.is_empty() + } + + fn is_full(&self) -> bool { + self.digits.len() == self.digits.capacity() + } + + fn clear(&mut self, ctx: &mut EventCtx) { + self.digits.clear(); + ctx.request_paint() + } + + fn push(&mut self, ctx: &mut EventCtx, text: &str) { + if self.digits.push_str(text).is_err() { + // `self.pin` is full and wasn't able to accept all of + // `text`. Should not happen. + }; + ctx.request_paint() + } + + fn pop(&mut self, ctx: &mut EventCtx) { + if self.digits.pop().is_some() { + ctx.request_paint() + } + } + + fn pin(&self) -> &str { + &self.digits + } + + fn paint_digits(&self, area: Rect) { + let center = area.center() + Offset::y(Font::MONO.text_height() / 2); + let right = center + Offset::x(Font::MONO.text_width("0") * (MAX_VISIBLE_DOTS as i16) / 2); + let digits = self.digits.len(); + + if digits <= MAX_VISIBLE_DOTS { + display::text_center( + center, + &self.digits, + Font::MONO, + self.style.text_color, + self.style.background_color, + ); + } else { + let offset: usize = digits.saturating_sub(MAX_VISIBLE_DIGITS); + display::text_right( + right, + &self.digits[offset..], + Font::MONO, + self.style.text_color, + self.style.background_color, + ); + } + } + + fn render_digits(&self, area: Rect, target: &mut impl Renderer) { + let center = area.center() + Offset::y(Font::MONO.text_height() / 2); + let right = center + Offset::x(Font::MONO.text_width("0") * (MAX_VISIBLE_DOTS as i16) / 2); + let digits = self.digits.len(); + + if digits <= MAX_VISIBLE_DOTS { + shape::Text::new(center, &self.digits) + .with_align(Alignment::Center) + .with_font(Font::MONO) + .with_fg(self.style.text_color) + .render(target); + } else { + let offset: usize = digits.saturating_sub(MAX_VISIBLE_DIGITS); + shape::Text::new(right, &self.digits[offset..]) + .with_align(Alignment::End) + .with_font(Font::MONO) + .with_fg(self.style.text_color) + .render(target); + } + } + + fn paint_dots(&self, area: Rect) { + let mut cursor = self.size().snap(area.center(), Alignment2D::CENTER); + + let digits = self.digits.len(); + let dots_visible = digits.min(MAX_VISIBLE_DOTS); + let step = Self::DOT + Self::PADDING; + + // Jiggle when overflowed. + if digits > dots_visible && digits % 2 == 0 { + cursor.x += Self::TWITCH + } + + // Small leftmost dot. + if digits > dots_visible + 1 { + theme::DOT_SMALL.draw( + cursor - Offset::x(2 * step), + Alignment2D::TOP_LEFT, + self.style.text_color, + self.style.background_color, + ); + } + + // Greyed out dot. + if digits > dots_visible { + theme::DOT_ACTIVE.draw( + cursor - Offset::x(step), + Alignment2D::TOP_LEFT, + theme::GREY_LIGHT, + self.style.background_color, + ); + } + + // Draw a dot for each PIN digit. + for _ in 0..dots_visible { + theme::DOT_ACTIVE.draw( + cursor, + Alignment2D::TOP_LEFT, + self.style.text_color, + self.style.background_color, + ); + cursor.x += step; + } + } + + fn render_dots(&self, area: Rect, target: &mut impl Renderer) { + let mut cursor = self.size().snap(area.center(), Alignment2D::CENTER); + + let digits = self.digits.len(); + let dots_visible = digits.min(MAX_VISIBLE_DOTS); + let step = Self::DOT + Self::PADDING; + + // Jiggle when overflowed. + if digits > dots_visible && digits % 2 == 0 { + cursor.x += Self::TWITCH + } + + // Small leftmost dot. + if digits > dots_visible + 1 { + shape::ToifImage::new(cursor - Offset::x(2 * step), theme::DOT_SMALL.toif) + .with_align(Alignment2D::TOP_LEFT) + .with_fg(self.style.text_color) + .render(target); + } + + // Greyed out dot. + if digits > dots_visible { + shape::ToifImage::new(cursor - Offset::x(step), theme::DOT_ACTIVE.toif) + .with_align(Alignment2D::TOP_LEFT) + .with_fg(theme::GREY_LIGHT) + .render(target); + } + + // Draw a dot for each PIN digit. + for _ in 0..dots_visible { + shape::ToifImage::new(cursor, theme::DOT_ACTIVE.toif) + .with_align(Alignment2D::TOP_LEFT) + .with_fg(self.style.text_color) + .render(target); + cursor.x += step; + } + } +} + +impl Component for PinDots { + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + self.pad.place(bounds); + self.area = bounds; + self.area + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + match event { + Event::Touch(TouchEvent::TouchStart(pos)) => { + if self.area.contains(pos) { + self.display_digits = true; + self.pad.clear(); + ctx.request_paint(); + }; + None + } + Event::Touch(TouchEvent::TouchEnd(_)) => { + if mem::replace(&mut self.display_digits, false) { + self.pad.clear(); + ctx.request_paint(); + }; + None + } + _ => None, + } + } + + fn paint(&mut self) { + let dot_area = self.area.inset(HEADER_PADDING); + self.pad.paint(); + if self.display_digits { + self.paint_digits(dot_area) + } else { + self.paint_dots(dot_area) + } + } + + fn render(&mut self, target: &mut impl Renderer) { + let dot_area = self.area.inset(HEADER_PADDING); + self.pad.render(target); + if self.display_digits { + self.render_digits(dot_area, target) + } else { + self.render_dots(dot_area, target) + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + sink(self.area); + sink(self.area.inset(HEADER_PADDING)); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for PinKeyboard +where + T: AsRef, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("PinKeyboard"); + // So that debuglink knows the locations of the buttons + let mut digits_order: String<10> = String::new(); + for btn in self.digit_btns.iter() { + let btn_content = btn.inner().content(); + if let ButtonContent::Text(text) = btn_content { + unwrap!(digits_order.push_str(text)); + } + } + t.string("digits_order", digits_order.as_str().into()); + t.string("pin", self.textbox.inner().pin().into()); + t.bool("display_digits", self.textbox.inner().display_digits); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/slip39.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/slip39.rs new file mode 100644 index 000000000..48fa4303c --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/slip39.rs @@ -0,0 +1,399 @@ +use core::iter; + +use heapless::String; + +use crate::{ + trezorhal::slip39, + ui::{ + component::{ + text::common::{TextBox, TextEdit}, + Component, Event, EventCtx, + }, + display, + geometry::{Alignment2D, Offset, Rect}, + model_mercury::{ + component::{ + keyboard::{ + common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard}, + mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT}, + }, + Button, ButtonContent, ButtonMsg, + }, + theme, + }, + shape, + shape::Renderer, + util::ResultExt, + }, +}; + +const MAX_LENGTH: usize = 8; + +pub struct Slip39Input { + button: Button<&'static str>, + textbox: TextBox, + multi_tap: MultiTapKeyboard, + final_word: Option<&'static str>, + input_mask: Slip39Mask, +} + +impl MnemonicInput for Slip39Input { + /// Return the key set. Keys are further specified as indices into this + /// array. + fn keys() -> [&'static str; MNEMONIC_KEY_COUNT] { + ["ab", "cd", "ef", "ghij", "klm", "nopq", "rs", "tuv", "wxyz"] + } + + /// Returns `true` if given key index can continue towards a valid mnemonic + /// word, `false` otherwise. + fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool { + if self.input_mask.is_final() { + false + } else { + // Currently pending key is always enabled. + // Keys that mach the completion mask are enabled as well. + self.multi_tap.pending_key() == Some(key) || self.input_mask.contains_key(key) + } + } + + /// Key button was clicked. If this button is pending, let's cycle the + /// pending character in textbox. If not, let's just append the first + /// character. + fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize) { + let edit = self.multi_tap.click_key(ctx, key, Self::keys()[key]); + if let TextEdit::Append(_) = edit { + // This key press wasn't just a pending key rotation, so let's push the key + // digit to the buffer. + self.textbox.append(ctx, Self::key_digit(key)); + } else { + // Ignore the pending char rotation. We use the pending key to paint + // the last character, but the mnemonic word computation depends + // only on the pressed key, not on the specific character inside it. + // Request paint of pending char. + ctx.request_paint(); + } + self.complete_word_from_dictionary(ctx); + } + + /// Backspace button was clicked, let's delete the last character of input + /// and clear the pending marker. + fn on_backspace_click(&mut self, ctx: &mut EventCtx) { + self.multi_tap.clear_pending_state(ctx); + self.textbox.delete_last(ctx); + self.complete_word_from_dictionary(ctx); + } + + /// Backspace button was long pressed, let's delete all characters of input + /// and clear the pending marker. + fn on_backspace_long_press(&mut self, ctx: &mut EventCtx) { + self.multi_tap.clear_pending_state(ctx); + self.textbox.clear(ctx); + self.complete_word_from_dictionary(ctx); + } + + fn is_empty(&self) -> bool { + self.textbox.is_empty() + } + + fn mnemonic(&self) -> Option<&'static str> { + self.final_word + } +} + +impl Component for Slip39Input { + type Msg = MnemonicInputMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.button.place(bounds) + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if self.multi_tap.is_timeout_event(event) { + // Timeout occurred. Reset the pending key. + self.multi_tap.clear_pending_state(ctx); + return Some(MnemonicInputMsg::TimedOut); + } + if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) { + // Input button was clicked. If the whole word is totally identified, let's + // confirm it, otherwise don't do anything. + if self.input_mask.is_final() { + return Some(MnemonicInputMsg::Confirmed); + } + } + None + } + + fn paint(&mut self) { + let area = self.button.area(); + let style = self.button.style(); + + // First, paint the button background. + self.button.paint_background(style); + + // Content starts in the left-center point, offset by 16px to the right and 8px + // to the bottom. + let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8); + + // To simplify things, we always copy the printed string here, even if it + // wouldn't be strictly necessary. + let mut text: String = String::new(); + + if let Some(word) = self.final_word { + // We're done with input, paint the full word. + text.push_str(word) + .assert_if_debugging_ui("Text buffer is too small"); + } else { + // Paint an asterisk for each letter of input. + for ch in iter::repeat('*').take(self.textbox.content().len()) { + text.push(ch) + .assert_if_debugging_ui("Text buffer is too small"); + } + // If we're in the pending state, paint the pending character at the end. + if let (Some(key), Some(press)) = + (self.multi_tap.pending_key(), self.multi_tap.pending_press()) + { + assert!(!Self::keys()[key].is_empty()); + // Now we can be sure that the looped iterator will return a value. + let ch = unwrap!(Self::keys()[key].chars().cycle().nth(press)); + text.pop(); + text.push(ch) + .assert_if_debugging_ui("Text buffer is too small"); + } + } + display::text_left( + text_baseline, + text.as_str(), + style.font, + style.text_color, + style.button_color, + ); + + // Paint the pending marker. + if self.multi_tap.pending_key().is_some() && self.final_word.is_none() { + paint_pending_marker(text_baseline, text.as_str(), style.font, style.text_color); + } + + // Paint the icon. + if let ButtonContent::Icon(icon) = self.button.content() { + // Icon is painted in the right-center point, of expected size 16x16 pixels, and + // 16px from the right edge. + let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0); + icon.draw( + icon_center, + Alignment2D::CENTER, + style.text_color, + style.button_color, + ); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + let area = self.button.area(); + let style = self.button.style(); + + // First, paint the button background. + self.button.render_background(target, style); + + // Content starts in the left-center point, offset by 16px to the right and 8px + // to the bottom. + let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8); + + // To simplify things, we always copy the printed string here, even if it + // wouldn't be strictly necessary. + let mut text: String = String::new(); + + if let Some(word) = self.final_word { + // We're done with input, paint the full word. + text.push_str(word) + .assert_if_debugging_ui("Text buffer is too small"); + } else { + // Paint an asterisk for each letter of input. + for ch in iter::repeat('*').take(self.textbox.content().len()) { + text.push(ch) + .assert_if_debugging_ui("Text buffer is too small"); + } + // If we're in the pending state, paint the pending character at the end. + if let (Some(key), Some(press)) = + (self.multi_tap.pending_key(), self.multi_tap.pending_press()) + { + assert!(!Self::keys()[key].is_empty()); + // Now we can be sure that the looped iterator will return a value. + let ch = unwrap!(Self::keys()[key].chars().cycle().nth(press)); + text.pop(); + text.push(ch) + .assert_if_debugging_ui("Text buffer is too small"); + } + } + shape::Text::new(text_baseline, text.as_str()) + .with_font(style.font) + .with_fg(style.text_color) + .render(target); + + // Paint the pending marker. + if self.multi_tap.pending_key().is_some() && self.final_word.is_none() { + render_pending_marker( + target, + text_baseline, + text.as_str(), + style.font, + style.text_color, + ); + } + + // Paint the icon. + if let ButtonContent::Icon(icon) = self.button.content() { + // Icon is painted in the right-center point, of expected size 16x16 pixels, and + // 16px from the right edge. + let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0); + shape::ToifImage::new(icon_center, icon.toif) + .with_align(Alignment2D::CENTER) + .with_fg(style.text_color) + .render(target); + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.button.bounds(sink); + } +} + +impl Slip39Input { + pub fn new() -> Self { + Self { + // Button has the same style the whole time + button: Button::empty().styled(theme::button_pin_confirm()), + textbox: TextBox::empty(), + multi_tap: MultiTapKeyboard::new(), + final_word: None, + input_mask: Slip39Mask::full(), + } + } + + pub fn prefilled_word(word: &str) -> Self { + // Word may be empty string, fallback to normal input + if word.is_empty() { + return Self::new(); + } + + let (buff, input_mask, final_word) = Self::setup_from_prefilled_word(word); + + Self { + // Button has the same style the whole time + button: Button::empty().styled(theme::button_pin_confirm()), + textbox: TextBox::new(buff), + multi_tap: MultiTapKeyboard::new(), + final_word, + input_mask, + } + } + + fn setup_from_prefilled_word( + word: &str, + ) -> (String, Slip39Mask, Option<&'static str>) { + let mut buff: String = String::new(); + + // Gradually appending encoded key digits to the buffer and checking if + // have not already formed a final word. + for ch in word.chars() { + let mut index = 0; + for (i, key) in Self::keys().iter().enumerate() { + if key.contains(ch) { + index = i; + break; + } + } + buff.push(Self::key_digit(index)) + .assert_if_debugging_ui("Text buffer is too small"); + + let sequence: Option = buff.parse().ok(); + let input_mask = sequence + .and_then(slip39::word_completion_mask) + .map(Slip39Mask) + .unwrap_or_else(Slip39Mask::full); + let final_word = if input_mask.is_final() { + sequence.and_then(slip39::button_sequence_to_word) + } else { + None + }; + + // As soon as we have a final word, we can stop. + if final_word.is_some() { + return (buff, input_mask, final_word); + } + } + (buff, Slip39Mask::full(), None) + } + + /// Convert a key index into the key digit. This is what we push into the + /// input buffer. + /// + /// # Examples + /// + /// ``` + /// Self::key_digit(0) == '1'; + /// Self::key_digit(1) == '2'; + /// ``` + fn key_digit(key: usize) -> char { + let index = key + 1; + unwrap!(char::from_digit(index as u32, 10)) + } + + fn complete_word_from_dictionary(&mut self, ctx: &mut EventCtx) { + let sequence = self.input_sequence(); + self.input_mask = sequence + .and_then(slip39::word_completion_mask) + .map(Slip39Mask) + .unwrap_or_else(Slip39Mask::full); + self.final_word = if self.input_mask.is_final() { + sequence.and_then(slip39::button_sequence_to_word) + } else { + None + }; + + // Change the style of the button depending on the input. + if self.final_word.is_some() { + // Confirm button. + self.button.enable(ctx); + self.button + .set_content(ctx, ButtonContent::Icon(theme::ICON_LIST_CHECK)); + } else { + // Disabled button. + self.button.disable(ctx); + self.button.set_content(ctx, ButtonContent::Text("")); + } + } + + fn input_sequence(&self) -> Option { + self.textbox.content().parse().ok() + } +} + +struct Slip39Mask(u16); + +impl Slip39Mask { + /// Return a mask with all keys allowed. + fn full() -> Self { + Self(0x1FF) // All buttons are allowed. 9-bit bitmap all set to 1. + } + + /// Returns `true` if `key` can lead to a valid SLIP39 word with this mask. + fn contains_key(&self, key: usize) -> bool { + self.0 & (1 << key) != 0 + } + + /// Returns `true` if mask has exactly one bit set to 1, or is equal to 0. + fn is_final(&self) -> bool { + self.0.count_ones() <= 1 + } +} + +// DEBUG-ONLY SECTION BELOW + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Slip39Input { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("Slip39Input"); + t.child("textbox", &self.textbox); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/keyboard/word_count.rs b/core/embed/rust/src/ui/model_mercury/component/keyboard/word_count.rs new file mode 100644 index 000000000..778fcda99 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/keyboard/word_count.rs @@ -0,0 +1,80 @@ +use crate::ui::{ + component::{Component, Event, EventCtx}, + geometry::{Grid, GridCellSpan, Rect}, + model_mercury::{ + component::button::{Button, ButtonMsg}, + theme, + }, + shape::Renderer, +}; + +const NUMBERS: [u32; 5] = [12, 18, 20, 24, 33]; +const LABELS: [&str; 5] = ["12", "18", "20", "24", "33"]; +const CELLS: [(usize, usize); 5] = [(0, 0), (0, 2), (0, 4), (1, 0), (1, 2)]; + +pub struct SelectWordCount { + button: [Button<&'static str>; NUMBERS.len()], +} + +pub enum SelectWordCountMsg { + Selected(u32), +} + +impl SelectWordCount { + pub fn new() -> Self { + SelectWordCount { + button: LABELS.map(|t| Button::with_text(t).styled(theme::button_pin())), + } + } +} + +impl Component for SelectWordCount { + type Msg = SelectWordCountMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let (_, bounds) = bounds.split_bottom(2 * theme::BUTTON_HEIGHT + theme::BUTTON_SPACING); + let grid = Grid::new(bounds, 2, 6).with_spacing(theme::BUTTON_SPACING); + for (btn, (x, y)) in self.button.iter_mut().zip(CELLS) { + btn.place(grid.cells(GridCellSpan { + from: (x, y), + to: (x, y + 1), + })); + } + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + for (i, btn) in self.button.iter_mut().enumerate() { + if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) { + return Some(SelectWordCountMsg::Selected(NUMBERS[i])); + } + } + None + } + + fn paint(&mut self) { + for btn in self.button.iter_mut() { + btn.paint() + } + } + + fn render(&mut self, target: &mut impl Renderer) { + for btn in self.button.iter_mut() { + btn.render(target) + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + for btn in self.button.iter() { + btn.bounds(sink) + } + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for SelectWordCount { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("SelectWordCount"); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/number_input.rs b/core/embed/rust/src/ui/model_mercury/component/number_input.rs new file mode 100644 index 000000000..25ea93e7e --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/number_input.rs @@ -0,0 +1,276 @@ +use crate::{ + error::Error, + micropython::buffer::StrBuffer, + strutil::{self, StringType}, + translations::TR, + ui::{ + component::{ + base::ComponentExt, + paginated::Paginate, + text::paragraphs::{Paragraph, Paragraphs}, + Child, Component, Event, EventCtx, Pad, + }, + display::{self, Font}, + geometry::{Alignment, Grid, Insets, Offset, Rect}, + shape::{self, Renderer}, + }, +}; + +use super::{theme, Button, ButtonMsg}; + +pub enum NumberInputDialogMsg { + Selected, + InfoRequested, +} + +pub struct NumberInputDialog +where + F: Fn(u32) -> T, +{ + area: Rect, + description_func: F, + input: Child, + paragraphs: Child>>, + paragraphs_pad: Pad, + info_button: Child>, + confirm_button: Child>, +} + +impl NumberInputDialog +where + F: Fn(u32) -> T, + T: StringType, +{ + pub fn new(min: u32, max: u32, init_value: u32, description_func: F) -> Result { + let text = description_func(init_value); + Ok(Self { + area: Rect::zero(), + description_func, + input: NumberInput::new(min, max, init_value).into_child(), + paragraphs: Paragraphs::new(Paragraph::new(&theme::TEXT_NORMAL, text)).into_child(), + paragraphs_pad: Pad::with_background(theme::BG), + info_button: Button::with_text(TR::buttons__info.try_into()?).into_child(), + confirm_button: Button::with_text(TR::buttons__continue.try_into()?) + .styled(theme::button_confirm()) + .into_child(), + }) + } + + fn update_text(&mut self, ctx: &mut EventCtx, value: u32) { + let text = (self.description_func)(value); + self.paragraphs.mutate(ctx, move |ctx, para| { + para.inner_mut().update(text); + // Recompute bounding box. + para.change_page(0); + ctx.request_paint() + }); + self.paragraphs_pad.clear(); + ctx.request_paint(); + } + + pub fn value(&self) -> u32 { + self.input.inner().value + } +} + +impl Component for NumberInputDialog +where + T: StringType, + F: Fn(u32) -> T, +{ + type Msg = NumberInputDialogMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + let button_height = theme::BUTTON_HEIGHT; + let content_area = self.area.inset(Insets::top(2 * theme::BUTTON_SPACING)); + let (input_area, content_area) = content_area.split_top(button_height); + let (content_area, button_area) = content_area.split_bottom(button_height); + let content_area = content_area.inset(Insets::new( + theme::BUTTON_SPACING, + 0, + theme::BUTTON_SPACING, + theme::CONTENT_BORDER, + )); + + let grid = Grid::new(button_area, 1, 2).with_spacing(theme::KEYBOARD_SPACING); + self.input.place(input_area); + self.paragraphs.place(content_area); + self.paragraphs_pad.place(content_area); + self.info_button.place(grid.row_col(0, 0)); + self.confirm_button.place(grid.row_col(0, 1)); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Some(NumberInputMsg::Changed(i)) = self.input.event(ctx, event) { + self.update_text(ctx, i); + } + self.paragraphs.event(ctx, event); + if let Some(ButtonMsg::Clicked) = self.info_button.event(ctx, event) { + return Some(Self::Msg::InfoRequested); + } + if let Some(ButtonMsg::Clicked) = self.confirm_button.event(ctx, event) { + return Some(Self::Msg::Selected); + }; + None + } + + fn paint(&mut self) { + self.input.paint(); + self.paragraphs_pad.paint(); + self.paragraphs.paint(); + self.info_button.paint(); + self.confirm_button.paint(); + } + + fn render(&mut self, target: &mut impl Renderer) { + self.input.render(target); + self.paragraphs_pad.render(target); + self.paragraphs.render(target); + self.info_button.render(target); + self.confirm_button.render(target); + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + sink(self.area); + self.input.bounds(sink); + self.paragraphs.bounds(sink); + self.info_button.bounds(sink); + self.confirm_button.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for NumberInputDialog +where + T: StringType, + F: Fn(u32) -> T, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("NumberInputDialog"); + t.child("input", &self.input); + t.child("paragraphs", &self.paragraphs); + t.child("info_button", &self.info_button); + t.child("confirm_button", &self.confirm_button); + } +} + +pub enum NumberInputMsg { + Changed(u32), +} + +pub struct NumberInput { + area: Rect, + dec: Child>, + inc: Child>, + min: u32, + max: u32, + value: u32, +} + +impl NumberInput { + pub fn new(min: u32, max: u32, value: u32) -> Self { + let dec = Button::with_text("-") + .styled(theme::button_counter()) + .into_child(); + let inc = Button::with_text("+") + .styled(theme::button_counter()) + .into_child(); + let value = value.clamp(min, max); + Self { + area: Rect::zero(), + dec, + inc, + min, + max, + value, + } + } +} + +impl Component for NumberInput { + type Msg = NumberInputMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let grid = Grid::new(bounds, 1, 3).with_spacing(theme::KEYBOARD_SPACING); + self.dec.place(grid.row_col(0, 0)); + self.inc.place(grid.row_col(0, 2)); + self.area = grid.row_col(0, 1); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + let mut changed = false; + if let Some(ButtonMsg::Clicked) = self.dec.event(ctx, event) { + self.value = self.min.max(self.value.saturating_sub(1)); + changed = true; + }; + if let Some(ButtonMsg::Clicked) = self.inc.event(ctx, event) { + self.value = self.max.min(self.value.saturating_add(1)); + changed = true; + }; + if changed { + self.dec + .mutate(ctx, |ctx, btn| btn.enable_if(ctx, self.value > self.min)); + self.inc + .mutate(ctx, |ctx, btn| btn.enable_if(ctx, self.value < self.max)); + ctx.request_paint(); + return Some(NumberInputMsg::Changed(self.value)); + } + None + } + + fn paint(&mut self) { + let mut buf = [0u8; 10]; + if let Some(text) = strutil::format_i64(self.value as i64, &mut buf) { + let digit_font = Font::DEMIBOLD; + let y_offset = digit_font.text_height() / 2 + Button::<&str>::BASELINE_OFFSET; + display::rect_fill(self.area, theme::BG); + display::text_center( + self.area.center() + Offset::y(y_offset), + text, + digit_font, + theme::FG, + theme::BG, + ); + } + self.dec.paint(); + self.inc.paint(); + } + + fn render(&mut self, target: &mut impl Renderer) { + let mut buf = [0u8; 10]; + + if let Some(text) = strutil::format_i64(self.value as i64, &mut buf) { + let digit_font = Font::DEMIBOLD; + let y_offset = digit_font.text_height() / 2 + Button::<&str>::BASELINE_OFFSET; + + shape::Bar::new(self.area).with_bg(theme::BG).render(target); + shape::Text::new(self.area.center() + Offset::y(y_offset), text) + .with_align(Alignment::Center) + .with_fg(theme::FG) + .with_font(digit_font) + .render(target); + } + + self.dec.render(target); + self.inc.render(target); + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + self.dec.bounds(sink); + self.inc.bounds(sink); + sink(self.area) + } +} + +#[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); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/page.rs b/core/embed/rust/src/ui/model_mercury/component/page.rs new file mode 100644 index 000000000..30ab3758f --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/page.rs @@ -0,0 +1,851 @@ +use crate::{ + error::Error, + micropython::buffer::StrBuffer, + time::Instant, + translations::TR, + ui::{ + component::{paginated::PageMsg, Component, ComponentExt, Event, EventCtx, Pad, Paginate}, + constant, + display::{self, Color}, + geometry::{Insets, Rect}, + shape::Renderer, + util::animation_disabled, + }, +}; + +use super::{ + theme, Button, ButtonContent, ButtonMsg, ButtonStyleSheet, Loader, LoaderMsg, ScrollBar, Swipe, + SwipeDirection, +}; + +/// Allows pagination of inner component. Shows scroll bar, confirm & cancel +/// buttons. Optionally handles hold-to-confirm with loader. +pub struct ButtonPage { + /// Inner component. + content: T, + /// Cleared when page changes. + pad: Pad, + /// Swipe controller. + swipe: Swipe, + scrollbar: ScrollBar, + /// Hold-to-confirm mode whenever this is `Some(loader)`. + loader: Option, + button_cancel: Option>, + button_confirm: Button, + button_prev: Button<&'static str>, + button_next: Button<&'static str>, + /// Show cancel button instead of back button. + cancel_from_any_page: bool, + /// Whether to pass-through left swipe to parent component. + swipe_left: bool, + /// Whether to pass-through right swipe to parent component. + swipe_right: bool, + /// Fade to given backlight level on next paint(). + fade: Option, +} + +impl ButtonPage +where + T: Paginate, + T: Component, +{ + pub fn with_hold(mut self) -> Result { + self.button_confirm = Button::with_text(TR::buttons__hold_to_confirm.try_into()?) + .styled(theme::button_confirm()); + self.loader = Some(Loader::new()); + Ok(self) + } +} + +impl ButtonPage +where + T: Paginate, + T: Component, + U: AsRef + From<&'static str>, +{ + pub fn new(content: T, background: Color) -> Self { + Self { + content, + pad: Pad::with_background(background), + swipe: Swipe::new(), + scrollbar: ScrollBar::vertical(), + loader: None, + button_cancel: Some(Button::with_icon(theme::ICON_CANCEL)), + button_confirm: Button::with_icon(theme::ICON_CONFIRM).styled(theme::button_confirm()), + button_prev: Button::with_icon(theme::ICON_UP).initially_enabled(false), + button_next: Button::with_icon(theme::ICON_DOWN), + cancel_from_any_page: false, + swipe_left: false, + swipe_right: false, + fade: None, + } + } + + pub fn without_cancel(mut self) -> Self { + self.button_cancel = None; + self + } + + pub fn with_cancel_confirm(mut self, left: Option, right: Option) -> Self { + let cancel = match left { + Some(verb) => match verb.as_ref() { + "^" => Button::with_icon(theme::ICON_UP), + "<" => Button::with_icon(theme::ICON_BACK), + _ => Button::with_text(verb), + }, + _ => Button::with_icon(theme::ICON_CANCEL), + }; + let confirm = match right { + Some(verb) => Button::with_text(verb).styled(theme::button_confirm()), + _ => Button::with_icon(theme::ICON_CONFIRM).styled(theme::button_confirm()), + }; + self.button_cancel = Some(cancel); + self.button_confirm = confirm; + self + } + + pub fn with_back_button(mut self) -> Self { + self.cancel_from_any_page = true; + self.button_prev = Button::with_icon(theme::ICON_BACK).initially_enabled(false); + self.button_cancel = Some(Button::with_icon(theme::ICON_BACK)); + self + } + + pub fn with_cancel_arrow(mut self) -> Self { + self.button_cancel = Some(Button::with_icon(theme::ICON_UP)); + self + } + + pub fn with_confirm_style(mut self, style: ButtonStyleSheet) -> Self { + self.button_confirm = self.button_confirm.styled(style); + self + } + + pub fn with_swipe_left(mut self) -> Self { + self.swipe_left = true; + self + } + + pub fn with_swipe_right(mut self) -> Self { + self.swipe_right = true; + self + } + + fn setup_swipe(&mut self) { + self.swipe.allow_up = self.scrollbar.has_next_page(); + self.swipe.allow_down = self.scrollbar.has_previous_page(); + self.swipe.allow_left = self.swipe_left; + self.swipe.allow_right = self.swipe_right; + } + + fn change_page(&mut self, ctx: &mut EventCtx, step: isize) { + // Advance scrollbar. + self.scrollbar.go_to_relative(step); + + // Adjust the swipe parameters according to the scrollbar. + self.setup_swipe(); + + // Enable/disable prev button. + self.button_prev + .enable_if(ctx, self.scrollbar.has_previous_page()); + + // Change the page in the content, make sure it gets completely repainted and + // clear the background under it. + self.content.change_page(self.scrollbar.active_page); + self.content.request_complete_repaint(ctx); + self.pad.clear(); + + // Swipe has dimmed the screen, so fade back to normal backlight after the next + // paint. + self.fade = Some(theme::BACKLIGHT_NORMAL); + } + + fn is_cancel_visible(&self) -> bool { + self.cancel_from_any_page || !self.scrollbar.has_previous_page() + } + + /// Area for drawing loader (and black rectangle behind it). Can be outside + /// bounds as we repaint entire UI tree after hiding the loader. + const fn loader_area() -> Rect { + constant::screen() + .inset(theme::borders()) + .inset(Insets::bottom(theme::BUTTON_HEIGHT + theme::BUTTON_SPACING)) + } + + fn handle_swipe( + &mut self, + ctx: &mut EventCtx, + event: Event, + ) -> HandleResult<::Msg> { + if let Some(swipe) = self.swipe.event(ctx, event) { + match swipe { + SwipeDirection::Up => { + // Scroll down, if possible. + return HandleResult::NextPage; + } + SwipeDirection::Down => { + // Scroll up, if possible. + return HandleResult::PrevPage; + } + SwipeDirection::Left if self.swipe_left => { + return HandleResult::Return(PageMsg::SwipeLeft); + } + SwipeDirection::Right if self.swipe_right => { + return HandleResult::Return(PageMsg::SwipeRight); + } + _ => { + // Ignore other directions. + } + } + } + + HandleResult::Continue + } + + fn handle_button( + &mut self, + ctx: &mut EventCtx, + event: Event, + ) -> HandleResult<(Option<::Msg>, Option)> { + if self.scrollbar.has_next_page() { + if let Some(ButtonMsg::Clicked) = self.button_next.event(ctx, event) { + return HandleResult::NextPage; + } + } else { + let result = self.button_confirm.event(ctx, event); + match result { + Some(ButtonMsg::Clicked) => { + return HandleResult::Return((Some(PageMsg::Confirmed), result)) + } + Some(_) => return HandleResult::Return((None, result)), + None => {} + } + } + if self.is_cancel_visible() { + if let Some(ButtonMsg::Clicked) = self.button_cancel.event(ctx, event) { + return HandleResult::Return((Some(PageMsg::Cancelled), None)); + } + } else if let Some(ButtonMsg::Clicked) = self.button_prev.event(ctx, event) { + return HandleResult::PrevPage; + } + + HandleResult::Continue + } + + fn handle_hold( + &mut self, + ctx: &mut EventCtx, + event: Event, + button_msg: &Option, + ) -> HandleResult<::Msg> { + let Some(loader) = &mut self.loader else { + return HandleResult::Continue; + }; + let now = Instant::now(); + + if let Some(LoaderMsg::ShrunkCompletely) = loader.event(ctx, event) { + // Switch it to the initial state, so we stop painting it. + loader.reset(); + // Re-draw the whole content tree. + self.content.request_complete_repaint(ctx); + // Loader overpainted our bounds, repaint entire screen from scratch. + ctx.request_repaint_root() + // This can be a result of an animation frame event, we should take + // care to not short-circuit here and deliver the event to the + // content as well. + } + match button_msg { + Some(ButtonMsg::Pressed) => { + loader.start_growing(ctx, now); + loader.pad.clear(); // Clear the remnants of the content. + } + Some(ButtonMsg::Released) => { + loader.start_shrinking(ctx, now); + } + Some(ButtonMsg::Clicked) => { + if loader.is_completely_grown(now) || animation_disabled() { + return HandleResult::Return(PageMsg::Confirmed); + } else { + loader.start_shrinking(ctx, now); + } + } + _ => {} + } + + HandleResult::Continue + } +} + +enum HandleResult { + Return(T), + PrevPage, + NextPage, + Continue, +} + +impl Component for ButtonPage +where + T: Paginate, + T: Component, + U: AsRef + From<&'static str>, +{ + type Msg = PageMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + let small_left_button = match (&self.button_cancel, &self.button_confirm) { + (None, _) => true, + (Some(cancel), confirm) => match (cancel.content(), confirm.content()) { + (ButtonContent::Text(t), _) => t.as_ref().len() <= 4, + (ButtonContent::Icon(_), ButtonContent::Icon(_)) => false, + _ => true, + }, + }; + let layout = PageLayout::new(bounds, small_left_button); + self.pad.place(bounds); + self.swipe.place(bounds); + self.button_cancel.place(layout.button_left); + self.button_confirm.place(layout.button_right); + self.button_prev.place(layout.button_left); + self.button_next.place(layout.button_right); + self.scrollbar.place(layout.scrollbar); + + // Layout the content. Try to fit it on a single page first, and reduce the area + // to make space for a scrollbar if it doesn't fit. + self.content.place(layout.content_single_page); + let page_count = { + let count = self.content.page_count(); + if count > 1 { + self.content.place(layout.content); + self.content.page_count() // Make sure to re-count it with the + // new size. + } else { + count // Content fits on a single page. + } + }; + + if page_count == 1 && self.button_cancel.is_none() { + self.button_confirm.place(layout.button_both); + } + + // Now that we finally have the page count, we can setup the scrollbar and the + // swiper. + self.scrollbar.set_count_and_active_page(page_count, 0); + self.setup_swipe(); + + self.loader.place(Self::loader_area()); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + ctx.set_page_count(self.scrollbar.page_count); + + match self.handle_swipe(ctx, event) { + HandleResult::Return(r) => return Some(r), + HandleResult::PrevPage => { + self.change_page(ctx, -1); + return None; + } + HandleResult::NextPage => { + self.change_page(ctx, 1); + return None; + } + HandleResult::Continue => {} + } + + if let Some(msg) = self.content.event(ctx, event) { + return Some(PageMsg::Content(msg)); + } + + let mut confirm_button_msg = None; + let mut button_result = None; + + match self.handle_button(ctx, event) { + HandleResult::Return((Some(r), None)) => return Some(r), + HandleResult::Return((r, m)) => { + button_result = r; + confirm_button_msg = m; + } + HandleResult::PrevPage => { + self.change_page(ctx, -1); + return None; + } + HandleResult::NextPage => { + self.change_page(ctx, 1); + return None; + } + HandleResult::Continue => {} + } + + if self.loader.is_some() { + return match self.handle_hold(ctx, event, &confirm_button_msg) { + HandleResult::Return(r) => Some(r), + HandleResult::Continue => None, + _ => unreachable!(), + }; + } + button_result + } + + fn paint(&mut self) { + self.pad.paint(); + match &self.loader { + Some(l) if l.is_animating() => self.loader.paint(), + _ => { + self.content.paint(); + if self.scrollbar.has_pages() { + self.scrollbar.paint(); + } + } + } + if self.button_cancel.is_some() && self.is_cancel_visible() { + self.button_cancel.paint(); + } else { + self.button_prev.paint(); + } + if self.scrollbar.has_next_page() { + self.button_next.paint(); + } else { + self.button_confirm.paint(); + } + if let Some(val) = self.fade.take() { + // Note that this is blocking and takes some time. + display::fade_backlight(val); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + self.pad.render(target); + match &self.loader { + Some(l) if l.is_animating() => self.loader.render(target), + _ => { + self.content.render(target); + if self.scrollbar.has_pages() { + self.scrollbar.render(target); + } + } + } + if self.button_cancel.is_some() && self.is_cancel_visible() { + self.button_cancel.render(target); + } else { + self.button_prev.render(target); + } + if self.scrollbar.has_next_page() { + self.button_next.render(target); + } else { + self.button_confirm.render(target); + } + if let Some(val) = self.fade.take() { + // Note that this is blocking and takes some time. + display::fade_backlight(val); + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + sink(self.pad.area); + self.scrollbar.bounds(sink); + self.content.bounds(sink); + self.button_cancel.bounds(sink); + self.button_confirm.bounds(sink); + self.button_prev.bounds(sink); + self.button_next.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ButtonPage +where + T: crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("ButtonPage"); + t.int("active_page", self.scrollbar.active_page as i64); + t.int("page_count", self.scrollbar.page_count as i64); + t.bool("hold", self.loader.is_some()); + t.child("content", &self.content); + } +} + +pub struct PageLayout { + /// Content when it fits on single page (no scrollbar). + pub content_single_page: Rect, + /// Content when multiple pages. + pub content: Rect, + /// Scroll bar when multiple pages. + pub scrollbar: Rect, + /// Controls displayed on last page. + pub button_left: Rect, + pub button_right: Rect, + pub button_both: Rect, +} + +impl PageLayout { + const SCROLLBAR_WIDTH: i16 = 8; + const SCROLLBAR_SPACE: i16 = 5; + + pub fn new(area: Rect, small_left_button: bool) -> Self { + let (area, button_both) = area.split_bottom(theme::BUTTON_HEIGHT); + let area = area.inset(Insets::bottom(theme::BUTTON_SPACING)); + let (_space, content) = area.split_left(theme::CONTENT_BORDER); + let (content_single_page, _space) = content.split_right(theme::CONTENT_BORDER); + let (content, scrollbar) = + content.split_right(Self::SCROLLBAR_SPACE + Self::SCROLLBAR_WIDTH); + let (_space, scrollbar) = scrollbar.split_left(Self::SCROLLBAR_SPACE); + + let width = if small_left_button { + theme::BUTTON_WIDTH + } else { + (button_both.width() - theme::BUTTON_SPACING) / 2 + }; + let (button_left, button_right) = button_both.split_left(width); + let button_right = button_right.inset(Insets::left(theme::BUTTON_SPACING)); + + Self { + content_single_page, + content, + scrollbar, + button_left, + button_right, + button_both, + } + } +} + +#[cfg(test)] +mod tests { + use serde_json; + + use crate::{ + strutil::SkipPrefix, + trace::tests::trace, + ui::{ + component::text::paragraphs::{Paragraph, Paragraphs}, + event::TouchEvent, + geometry::Point, + model_mercury::{constant, theme}, + }, + }; + + use super::*; + + const SCREEN: Rect = constant::screen().inset(theme::borders()); + + impl SkipPrefix for &str { + fn skip_prefix(&self, chars: usize) -> Self { + &self[chars..] + } + } + + fn swipe(component: &mut impl Component, points: &[(i16, i16)]) { + let last = points.len().saturating_sub(1); + let mut first = true; + let mut ctx = EventCtx::new(); + for (i, &(x, y)) in points.iter().enumerate() { + let p = Point::new(x, y); + let ev = if first { + TouchEvent::TouchStart(p) + } else if i == last { + TouchEvent::TouchEnd(p) + } else { + TouchEvent::TouchMove(p) + }; + component.event(&mut ctx, Event::Touch(ev)); + ctx.clear(); + first = false; + } + } + + fn swipe_up(component: &mut impl Component) { + swipe(component, &[(20, 100), (20, 60), (20, 20)]) + } + + fn swipe_down(component: &mut impl Component) { + swipe(component, &[(20, 20), (20, 60), (20, 100)]) + } + + #[test] + fn paragraphs_empty() { + let mut page = ButtonPage::<_, &'static str>::new( + Paragraphs::<[Paragraph<&'static str>; 0]>::new([]), + theme::BG, + ); + page.place(SCREEN); + + let expected = serde_json::json!({ + "component": "ButtonPage", + "active_page": 0, + "page_count": 1, + "content": { + "component": "Paragraphs", + "paragraphs": [], + }, + "hold": false, + }); + + assert_eq!(trace(&page), expected); + swipe_up(&mut page); + assert_eq!(trace(&page), expected); + swipe_down(&mut page); + assert_eq!(trace(&page), expected); + } + + #[test] + fn paragraphs_single() { + let mut page = ButtonPage::<_, &'static str>::new( + Paragraphs::new([ + Paragraph::new( + &theme::TEXT_NORMAL, + "This is the first paragraph and it should fit on the screen entirely.", + ), + Paragraph::new( + &theme::TEXT_BOLD, + "Second, bold, paragraph should also fit on the screen whole I think.", + ), + ]), + theme::BG, + ); + page.place(SCREEN); + + let expected = serde_json::json!({ + "component": "ButtonPage", + "active_page": 0, + "page_count": 1, + "content": { + "component": "Paragraphs", + "paragraphs": [ + ["This is the first", "\n", "paragraph and it should", "\n", "fit on the screen", "\n", "entirely."], + ["Second, bold, paragraph", "\n", "should also fit on the", "\n", "screen whole I think."], + ], + }, + "hold": false, + }); + + assert_eq!(trace(&page), expected); + swipe_up(&mut page); + assert_eq!(trace(&page), expected); + swipe_down(&mut page); + assert_eq!(trace(&page), expected); + } + + #[test] + fn paragraphs_one_long() { + let mut page = ButtonPage::<_, &'static str>::new( + Paragraphs::new( + Paragraph::new( + &theme::TEXT_BOLD, + "This is somewhat long paragraph that goes on and on and on and on and on and will definitely not fit on just a single screen. You have to swipe a bit to see all the text it contains I guess. There's just so much letters in it.", + ) + ), + theme::BG, + ); + page.place(SCREEN); + + let first_page = serde_json::json!({ + "component": "ButtonPage", + "active_page": 0, + "page_count": 2, + "content": { + "component": "Paragraphs", + "paragraphs": [ + [ + "This is somewhat long", "\n", + "paragraph that goes on", "\n", + "and on and on and on and", "\n", + "on and will definitely not", "\n", + "fit on just a single", "\n", + "screen. You have to", "\n", + "swipe a bit to see all the", "\n", + "text it contains I guess.", "...", + ], + ], + }, + "hold": false, + }); + let second_page = serde_json::json!({ + "component": "ButtonPage", + "active_page": 1, + "page_count": 2, + "content": { + "component": "Paragraphs", + "paragraphs": [ + ["There's just so much", "\n", "letters in it."], + ], + }, + "hold": false, + }); + + assert_eq!(trace(&page), first_page); + swipe_down(&mut page); + assert_eq!(trace(&page), first_page); + swipe_up(&mut page); + assert_eq!(trace(&page), second_page); + swipe_up(&mut page); + assert_eq!(trace(&page), second_page); + swipe_down(&mut page); + assert_eq!(trace(&page), first_page); + } + + #[test] + fn paragraphs_three_long() { + let mut page = ButtonPage::<_, &'static str>::new( + Paragraphs::new([ + Paragraph::new( + &theme::TEXT_BOLD, + "This paragraph is using a bold font. It doesn't need to be all that long.", + ), + Paragraph::new( + &theme::TEXT_MONO, + "And this one is using MONO. Monospace is nice for numbers, they have the same width and can be scanned quickly. Even if they span several pages or something.", + ), + Paragraph::new( + &theme::TEXT_BOLD, + "Let's add another one for a good measure. This one should overflow all the way to the third page with a bit of luck.", + ), + ]), + theme::BG, + ); + page.place(SCREEN); + + let first_page = serde_json::json!({ + "component": "ButtonPage", + "active_page": 0, + "page_count": 3, + "content": { + "component": "Paragraphs", + "paragraphs": [ + [ + "This paragraph is using a", "\n", + "bold font. It doesn't need", "\n", + "to be all that long.", + ], + [ + "And this one is u", "\n", + "sing MONO. Monosp", "\n", + "ace is nice for n", "\n", + "umbers, they", "...", + ], + ], + }, + "hold": false, + }); + let second_page = serde_json::json!({ + "component": "ButtonPage", + "active_page": 1, + "page_count": 3, + "content": { + "component": "Paragraphs", + "paragraphs": [ + [ + "...", "have the same", "\n", + "width and can be", "\n", + "scanned quickly.", "\n", + "Even if they span", "\n", + "several pages or", "\n", + "something.", + ], + [ + "Let's add another one", "...", + ], + ], + }, + "hold": false, + }); + let third_page = serde_json::json!({ + "component": "ButtonPage", + "active_page": 2, + "page_count": 3, + "content": { + "component": "Paragraphs", + "paragraphs": [ + [ + "for a good measure. This", "\n", + "one should overflow all", "\n", + "the way to the third page", "\n", + "with a bit of luck.", + ], + ], + }, + "hold": false, + }); + + assert_eq!(trace(&page), first_page); + swipe_down(&mut page); + assert_eq!(trace(&page), first_page); + swipe_up(&mut page); + assert_eq!(trace(&page), second_page); + swipe_up(&mut page); + assert_eq!(trace(&page), third_page); + swipe_up(&mut page); + assert_eq!(trace(&page), third_page); + swipe_down(&mut page); + assert_eq!(trace(&page), second_page); + swipe_down(&mut page); + assert_eq!(trace(&page), first_page); + swipe_down(&mut page); + assert_eq!(trace(&page), first_page); + } + + #[test] + fn paragraphs_hard_break() { + let mut page = ButtonPage::<_, &'static str>::new( + Paragraphs::new([ + Paragraph::new(&theme::TEXT_NORMAL, "Short one.").break_after(), + Paragraph::new(&theme::TEXT_NORMAL, "Short two.").break_after(), + Paragraph::new(&theme::TEXT_NORMAL, "Short three.").break_after(), + ]), + theme::BG, + ); + page.place(SCREEN); + + let first_page = serde_json::json!({ + "component": "ButtonPage", + "active_page": 0, + "page_count": 3, + "content": { + "component": "Paragraphs", + "paragraphs": [ + [ + "Short one.", + ], + ], + }, + "hold": false, + }); + let second_page = serde_json::json!({ + "component": "ButtonPage", + "active_page": 1, + "page_count": 3, + "content": { + "component": "Paragraphs", + "paragraphs": [ + [ + "Short two.", + ], + ], + }, + "hold": false, + }); + let third_page = serde_json::json!({ + "component": "ButtonPage", + "active_page": 2, + "page_count": 3, + "content": { + "component": "Paragraphs", + "paragraphs": [ + [ + "Short three.", + ], + ], + }, + "hold": false, + }); + + assert_eq!(trace(&page), first_page); + swipe_up(&mut page); + assert_eq!(trace(&page), second_page); + swipe_up(&mut page); + assert_eq!(trace(&page), third_page); + swipe_up(&mut page); + assert_eq!(trace(&page), third_page); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/progress.rs b/core/embed/rust/src/ui/model_mercury/component/progress.rs new file mode 100644 index 000000000..8b7f64c19 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/progress.rs @@ -0,0 +1,183 @@ +use core::mem; + +use crate::{ + error::Error, + strutil::StringType, + ui::{ + canvas::algo::PI4, + component::{ + base::ComponentExt, + paginated::Paginate, + text::paragraphs::{Paragraph, Paragraphs}, + Child, Component, Event, EventCtx, Label, Never, Pad, + }, + display::{self, Font}, + geometry::{Insets, Offset, Rect}, + model_mercury::constant, + shape, + shape::Renderer, + util::animation_disabled, + }, +}; + +use super::theme; + +pub struct Progress { + title: Child>, + value: u16, + loader_y_offset: i16, + indeterminate: bool, + description: Child>>, + description_pad: Pad, + update_description: fn(&str) -> Result, +} + +impl Progress +where + T: StringType, +{ + const AREA: Rect = constant::screen().inset(theme::borders()); + + pub fn new( + title: T, + indeterminate: bool, + description: T, + update_description: fn(&str) -> Result, + ) -> Self { + Self { + title: Label::centered(title, theme::label_progress()).into_child(), + value: 0, + loader_y_offset: 0, + indeterminate, + description: Paragraphs::new( + Paragraph::new(&theme::TEXT_NORMAL, description).centered(), + ) + .into_child(), + description_pad: Pad::with_background(theme::BG), + update_description, + } + } +} + +impl Component for Progress +where + T: StringType, +{ + type Msg = Never; + + fn place(&mut self, _bounds: Rect) -> Rect { + let description_lines = 1 + self + .description + .inner() + .inner() + .content() + .as_ref() + .chars() + .filter(|c| *c == '\n') + .count() as i16; + let (title, rest) = Self::AREA.split_top(self.title.inner().max_size().y); + let (loader, description) = + rest.split_bottom(Font::NORMAL.line_height() * description_lines); + let loader = loader.inset(Insets::top(theme::CONTENT_BORDER)); + self.title.place(title); + self.loader_y_offset = loader.center().y - constant::screen().center().y; + self.description.place(description); + self.description_pad.place(description); + Self::AREA + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Event::Progress(new_value, new_description) = event { + if mem::replace(&mut self.value, new_value) != new_value { + if !animation_disabled() { + ctx.request_paint(); + } + self.description.mutate(ctx, |ctx, para| { + if para.inner_mut().content().as_ref() != new_description { + let new_description = unwrap!((self.update_description)(new_description)); + para.inner_mut().update(new_description); + para.change_page(0); // Recompute bounding box. + ctx.request_paint(); + self.description_pad.clear(); + } + }); + } + } + None + } + + fn paint(&mut self) { + self.title.paint(); + if self.indeterminate { + display::loader_indeterminate( + self.value, + self.loader_y_offset, + theme::FG, + theme::BG, + None, + ); + } else { + display::loader(self.value, self.loader_y_offset, theme::FG, theme::BG, None); + } + self.description_pad.paint(); + self.description.paint(); + } + + fn render(&mut self, target: &mut impl Renderer) { + self.title.render(target); + + let center = constant::screen().center() + Offset::y(self.loader_y_offset); + let active_color = theme::FG; + let background_color = theme::BG; + let inactive_color = background_color.blend(active_color, 85); + + let (start, end) = if self.indeterminate { + let start = (self.value - 100) % 1000; + let end = (self.value + 100) % 1000; + let start = ((start as i32 * 8 * PI4 as i32) / 1000) as i16; + let end = ((end as i32 * 8 * PI4 as i32) / 1000) as i16; + (start, end) + } else { + let end = ((self.value as i32 * 8 * PI4 as i32) / 1000) as i16; + (0, end) + }; + + shape::Circle::new(center, constant::LOADER_OUTER) + .with_bg(inactive_color) + .render(target); + + shape::Circle::new(center, constant::LOADER_OUTER) + .with_bg(active_color) + .with_start_angle(start) + .with_end_angle(end) + .render(target); + + shape::Circle::new(center, constant::LOADER_INNER + 2) + .with_bg(active_color) + .render(target); + + shape::Circle::new(center, constant::LOADER_INNER) + .with_bg(background_color) + .render(target); + + self.description_pad.render(target); + self.description.render(target); + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + sink(Self::AREA); + self.title.bounds(sink); + self.description.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Progress +where + T: StringType, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("Progress"); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/scroll.rs b/core/embed/rust/src/ui/model_mercury/component/scroll.rs new file mode 100644 index 000000000..fd1c641b0 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/scroll.rs @@ -0,0 +1,179 @@ +use crate::ui::{ + component::{Component, Event, EventCtx, Never}, + display::toif::Icon, + geometry::{Alignment2D, Axis, LinearPlacement, Offset, Rect}, + shape, + shape::Renderer, +}; + +use super::theme; + +pub struct ScrollBar { + area: Rect, + layout: LinearPlacement, + pub page_count: usize, + pub active_page: usize, +} + +impl ScrollBar { + pub const DOT_SIZE: i16 = 8; + /// If there's more pages than this value then smaller dots are used at the + /// beginning/end of the scrollbar to denote the fact. + const MAX_DOTS: usize = 7; + /// Center to center. + const DOT_INTERVAL: i16 = 18; + + pub fn new(axis: Axis) -> Self { + let layout = LinearPlacement::new(axis); + Self { + area: Rect::zero(), + layout: layout.align_at_center().with_spacing(Self::DOT_INTERVAL), + page_count: 0, + active_page: 0, + } + } + + pub fn vertical() -> Self { + Self::new(Axis::Vertical) + } + + pub fn horizontal() -> Self { + Self::new(Axis::Horizontal) + } + + pub fn set_count_and_active_page(&mut self, page_count: usize, active_page: usize) { + self.page_count = page_count; + self.active_page = active_page; + } + + pub fn has_pages(&self) -> bool { + self.page_count > 1 + } + + pub fn has_next_page(&self) -> bool { + self.active_page < self.page_count - 1 + } + + pub fn has_previous_page(&self) -> bool { + self.active_page > 0 + } + + pub fn go_to_next_page(&mut self) { + self.go_to_relative(1) + } + + pub fn go_to_previous_page(&mut self) { + self.go_to_relative(-1) + } + + pub fn go_to_relative(&mut self, step: isize) { + self.go_to( + (self.active_page as isize + step).clamp(0, self.page_count as isize - 1) as usize, + ); + } + + pub fn go_to(&mut self, active_page: usize) { + self.active_page = active_page; + } +} + +impl Component for ScrollBar { + type Msg = Never; + + fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option { + None + } + + fn paint(&mut self) { + fn dotsize(distance: usize, nhidden: usize) -> Icon { + match (nhidden.saturating_sub(distance)).min(2 - distance) { + 0 => theme::DOT_INACTIVE, + 1 => theme::DOT_INACTIVE_HALF, + _ => theme::DOT_INACTIVE_QUARTER, + } + } + + // Number of visible dots. + let num_shown = self.page_count.min(Self::MAX_DOTS); + // Page indices corresponding to the first (and last) dot. + let first_shown = self + .active_page + .saturating_sub(Self::MAX_DOTS / 2) + .min(self.page_count.saturating_sub(Self::MAX_DOTS)); + let last_shown = first_shown + num_shown - 1; + + let mut cursor = self.area.center() + - Offset::on_axis( + self.layout.axis, + Self::DOT_INTERVAL * (num_shown.saturating_sub(1) as i16) / 2, + ); + for i in first_shown..(last_shown + 1) { + let icon = if i == self.active_page { + theme::DOT_ACTIVE + } else if i <= first_shown + 1 { + let before_first_shown = first_shown; + dotsize(i - first_shown, before_first_shown) + } else if i >= last_shown - 1 { + let after_last_shown = self.page_count - 1 - last_shown; + dotsize(last_shown - i, after_last_shown) + } else { + theme::DOT_INACTIVE + }; + icon.draw(cursor, Alignment2D::CENTER, theme::FG, theme::BG); + cursor = cursor + Offset::on_axis(self.layout.axis, Self::DOT_INTERVAL); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + fn dotsize(distance: usize, nhidden: usize) -> Icon { + match (nhidden.saturating_sub(distance)).min(2 - distance) { + 0 => theme::DOT_INACTIVE, + 1 => theme::DOT_INACTIVE_HALF, + _ => theme::DOT_INACTIVE_QUARTER, + } + } + + // Number of visible dots. + let num_shown = self.page_count.min(Self::MAX_DOTS); + // Page indices corresponding to the first (and last) dot. + let first_shown = self + .active_page + .saturating_sub(Self::MAX_DOTS / 2) + .min(self.page_count.saturating_sub(Self::MAX_DOTS)); + let last_shown = first_shown + num_shown - 1; + + let mut cursor = self.area.center() + - Offset::on_axis( + self.layout.axis, + Self::DOT_INTERVAL * (num_shown.saturating_sub(1) as i16) / 2, + ); + for i in first_shown..(last_shown + 1) { + let icon = if i == self.active_page { + theme::DOT_ACTIVE + } else if i <= first_shown + 1 { + let before_first_shown = first_shown; + dotsize(i - first_shown, before_first_shown) + } else if i >= last_shown - 1 { + let after_last_shown = self.page_count - 1 - last_shown; + dotsize(last_shown - i, after_last_shown) + } else { + theme::DOT_INACTIVE + }; + shape::ToifImage::new(cursor, icon.toif) + .with_align(Alignment2D::CENTER) + .with_fg(theme::FG) + .render(target); + cursor = cursor + Offset::on_axis(self.layout.axis, Self::DOT_INTERVAL); + } + } + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + bounds + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + sink(self.area); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/simple_page.rs b/core/embed/rust/src/ui/model_mercury/component/simple_page.rs new file mode 100644 index 000000000..556ec0dee --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/simple_page.rs @@ -0,0 +1,199 @@ +use crate::ui::{ + component::{base::ComponentExt, Component, Event, EventCtx, Pad, PageMsg, Paginate}, + display::{self, Color}, + geometry::{Axis, Insets, Rect}, + shape::Renderer, +}; + +use super::{theme, ScrollBar, Swipe, SwipeDirection}; + +const SCROLLBAR_HEIGHT: i16 = 18; +const SCROLLBAR_BORDER: i16 = 4; + +pub struct SimplePage { + content: T, + pad: Pad, + swipe: Swipe, + scrollbar: ScrollBar, + axis: Axis, + swipe_right_to_go_back: bool, + fade: Option, +} + +impl SimplePage +where + T: Paginate, + T: Component, +{ + pub fn new(content: T, axis: Axis, background: Color) -> Self { + Self { + content, + swipe: Swipe::new(), + pad: Pad::with_background(background), + scrollbar: ScrollBar::new(axis), + axis, + swipe_right_to_go_back: false, + fade: None, + } + } + + pub fn horizontal(content: T, background: Color) -> Self { + Self::new(content, Axis::Horizontal, background) + } + + pub fn vertical(content: T, background: Color) -> Self { + Self::new(content, Axis::Vertical, background) + } + + pub fn with_swipe_right_to_go_back(mut self) -> Self { + self.swipe_right_to_go_back = true; + self + } + + pub fn inner(&self) -> &T { + &self.content + } + + fn setup_swipe(&mut self) { + if self.is_horizontal() { + self.swipe.allow_left = self.scrollbar.has_next_page(); + self.swipe.allow_right = + self.scrollbar.has_previous_page() || self.swipe_right_to_go_back; + } else { + self.swipe.allow_up = self.scrollbar.has_next_page(); + self.swipe.allow_down = self.scrollbar.has_previous_page(); + self.swipe.allow_right = self.swipe_right_to_go_back; + } + } + + fn change_page(&mut self, ctx: &mut EventCtx, step: isize) { + // Advance scrollbar. + self.scrollbar.go_to_relative(step); + // Adjust the swipe parameters according to the scrollbar. + self.setup_swipe(); + + // Change the page in the content, make sure it gets completely repainted and + // clear the background under it. + self.content.change_page(self.scrollbar.active_page); + self.content.request_complete_repaint(ctx); + self.pad.clear(); + + // Swipe has dimmed the screen, so fade back to normal backlight after the next + // paint. + self.fade = Some(theme::BACKLIGHT_NORMAL); + } + + fn is_horizontal(&self) -> bool { + matches!(self.axis, Axis::Horizontal) + } +} + +impl Component for SimplePage +where + T: Paginate, + T: Component, +{ + type Msg = PageMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.swipe.place(bounds); + + let (content, scrollbar) = if self.is_horizontal() { + bounds.split_bottom(SCROLLBAR_HEIGHT + SCROLLBAR_BORDER) + } else { + bounds.split_right(SCROLLBAR_HEIGHT + SCROLLBAR_BORDER) + }; + + self.content.place(bounds); + if self.content.page_count() > 1 { + self.pad.place(content); + self.content.place(content); + } else { + self.pad.place(bounds); + } + + if self.is_horizontal() { + self.scrollbar + .place(scrollbar.inset(Insets::bottom(SCROLLBAR_BORDER))); + } else { + self.scrollbar + .place(scrollbar.inset(Insets::right(SCROLLBAR_BORDER))); + } + + self.scrollbar + .set_count_and_active_page(self.content.page_count(), 0); + self.setup_swipe(); + + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + ctx.set_page_count(self.scrollbar.page_count); + if let Some(swipe) = self.swipe.event(ctx, event) { + match (swipe, self.axis) { + (SwipeDirection::Left, Axis::Horizontal) | (SwipeDirection::Up, Axis::Vertical) => { + self.change_page(ctx, 1); + return None; + } + (SwipeDirection::Right, _) + if self.swipe_right_to_go_back && self.scrollbar.active_page == 0 => + { + return Some(PageMsg::Cancelled); + } + (SwipeDirection::Right, Axis::Horizontal) + | (SwipeDirection::Down, Axis::Vertical) => { + self.change_page(ctx, -1); + return None; + } + _ => { + // Ignore other directions. + } + } + } + self.content.event(ctx, event).map(PageMsg::Content) + } + + fn paint(&mut self) { + self.pad.paint(); + self.content.paint(); + if self.scrollbar.has_pages() { + self.scrollbar.paint(); + } + if let Some(val) = self.fade.take() { + // Note that this is blocking and takes some time. + display::fade_backlight(val); + } + } + + fn render(&mut self, target: &mut impl Renderer) { + self.pad.render(target); + self.content.render(target); + if self.scrollbar.has_pages() { + self.scrollbar.render(target); + } + if let Some(val) = self.fade.take() { + // Note that this is blocking and takes some time. + display::fade_backlight(val); + } + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, sink: &mut dyn FnMut(Rect)) { + sink(self.pad.area); + self.scrollbar.bounds(sink); + self.content.bounds(sink); + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for SimplePage +where + T: crate::trace::Trace, +{ + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("SimplePage"); + t.int("active_page", self.scrollbar.active_page as i64); + t.int("page_count", self.scrollbar.page_count as i64); + t.child("content", &self.content); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/swipe.rs b/core/embed/rust/src/ui/model_mercury/component/swipe.rs new file mode 100644 index 000000000..d8217a1b9 --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/swipe.rs @@ -0,0 +1,165 @@ +use crate::ui::{ + component::{Component, Event, EventCtx}, + display, + event::TouchEvent, + geometry::{Point, Rect}, + shape::Renderer, +}; + +use super::theme; + +pub enum SwipeDirection { + Up, + Down, + Left, + Right, +} + +pub struct Swipe { + pub area: Rect, + pub allow_up: bool, + pub allow_down: bool, + pub allow_left: bool, + pub allow_right: bool, + backlight_start: u16, + backlight_end: u16, + origin: Option, +} + +impl Swipe { + const DISTANCE: i32 = 120; + const THRESHOLD: f32 = 0.3; + + pub fn new() -> Self { + Self { + area: Rect::zero(), + allow_up: false, + allow_down: false, + allow_left: false, + allow_right: false, + backlight_start: theme::BACKLIGHT_NORMAL, + backlight_end: theme::BACKLIGHT_NONE, + origin: None, + } + } + + pub fn vertical() -> Self { + Self::new().up().down() + } + + pub fn horizontal() -> Self { + Self::new().left().right() + } + + pub fn up(mut self) -> Self { + self.allow_up = true; + self + } + + pub fn down(mut self) -> Self { + self.allow_down = true; + self + } + + pub fn left(mut self) -> Self { + self.allow_left = true; + self + } + + pub fn right(mut self) -> Self { + self.allow_right = true; + self + } + + fn is_active(&self) -> bool { + self.allow_up || self.allow_down || self.allow_left || self.allow_right + } + + fn ratio(&self, dist: i16) -> f32 { + (dist as f32 / Self::DISTANCE as f32).min(1.0) + } + + fn backlight(&self, ratio: f32) { + let start = self.backlight_start as f32; + let end = self.backlight_end as f32; + let value = start + ratio * (end - start); + display::set_backlight(value as u16); + } +} + +impl Component for Swipe { + type Msg = SwipeDirection; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + self.area + } + + fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option { + if !self.is_active() { + return None; + } + match (event, self.origin) { + (Event::Touch(TouchEvent::TouchStart(pos)), _) if self.area.contains(pos) => { + // Mark the starting position of this touch. + self.origin.replace(pos); + } + (Event::Touch(TouchEvent::TouchMove(pos)), Some(origin)) => { + // Consider our allowed directions and the touch distance and modify the display + // backlight accordingly. + let ofs = pos - origin; + let abs = ofs.abs(); + if abs.x > abs.y && (self.allow_left || self.allow_right) { + // Horizontal direction. + if (ofs.x < 0 && self.allow_left) || (ofs.x > 0 && self.allow_right) { + self.backlight(self.ratio(abs.x)); + } + } else if abs.x < abs.y && (self.allow_up || self.allow_down) { + // Vertical direction. + if (ofs.y < 0 && self.allow_up) || (ofs.y > 0 && self.allow_down) { + self.backlight(self.ratio(abs.y)); + } + }; + } + (Event::Touch(TouchEvent::TouchEnd(pos)), Some(origin)) => { + // Touch interaction is over, reset the position. + self.origin.take(); + + // Compare the touch distance with our allowed directions and determine if it + // constitutes a valid swipe. + let ofs = pos - origin; + let abs = ofs.abs(); + if abs.x > abs.y && (self.allow_left || self.allow_right) { + // Horizontal direction. + if self.ratio(abs.x) >= Self::THRESHOLD { + if ofs.x < 0 && self.allow_left { + return Some(SwipeDirection::Left); + } else if ofs.x > 0 && self.allow_right { + return Some(SwipeDirection::Right); + } + } + } else if abs.x < abs.y && (self.allow_up || self.allow_down) { + // Vertical direction. + if self.ratio(abs.y) >= Self::THRESHOLD { + if ofs.y < 0 && self.allow_up { + return Some(SwipeDirection::Up); + } else if ofs.y > 0 && self.allow_down { + return Some(SwipeDirection::Down); + } + } + }; + + // Swipe did not happen, reset the backlight. + self.backlight(0.0); + } + _ => { + // Do nothing. + } + } + None + } + + fn paint(&mut self) {} + + fn render(&mut self, _target: &mut impl Renderer) {} +} diff --git a/core/embed/rust/src/ui/model_mercury/layout.rs b/core/embed/rust/src/ui/model_mercury/layout.rs new file mode 100644 index 000000000..2d18aa1cd --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/layout.rs @@ -0,0 +1,2167 @@ +use core::{cmp::Ordering, convert::TryInto}; + +use crate::{ + error::Error, + micropython::{ + buffer::{get_buffer, StrBuffer}, + gc::Gc, + iter::IterBuf, + list::List, + map::Map, + module::Module, + obj::Obj, + qstr::Qstr, + util, + }, + strutil::{StringType, TString}, + translations::TR, + trezorhal::model, + ui::{ + component::{ + base::ComponentExt, + connect::Connect, + image::BlendedImage, + paginated::{PageMsg, Paginate}, + painter, + placed::GridPlaced, + text::{ + op::OpTextLayout, + paragraphs::{ + Checklist, Paragraph, ParagraphSource, ParagraphVecLong, ParagraphVecShort, + Paragraphs, VecExt, + }, + TextStyle, + }, + Border, Component, Empty, FormattedText, Label, Never, Qr, Timeout, + }, + display::tjpgd::jpeg_info, + geometry, + layout::{ + obj::{ComponentMsgObj, LayoutObj}, + result::{CANCELLED, CONFIRMED, INFO}, + util::{upy_disable_animation, ConfirmBlob, PropsList}, + }, + model_mercury::component::check_homescreen_format, + }, +}; + +use super::{ + component::{ + AddressDetails, Bip39Input, Button, ButtonMsg, ButtonPage, ButtonStyleSheet, + CancelConfirmMsg, CancelInfoConfirmMsg, CoinJoinProgress, Dialog, DialogMsg, FidoConfirm, + FidoMsg, Frame, FrameMsg, Homescreen, HomescreenMsg, IconDialog, Lockscreen, MnemonicInput, + MnemonicKeyboard, MnemonicKeyboardMsg, NumberInputDialog, NumberInputDialogMsg, + PassphraseKeyboard, PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg, Progress, + SelectWordCount, SelectWordCountMsg, SelectWordMsg, SimplePage, Slip39Input, + }, + theme, +}; + +impl TryFrom for Obj { + type Error = Error; + + fn try_from(value: CancelConfirmMsg) -> Result { + match value { + CancelConfirmMsg::Cancelled => Ok(CANCELLED.as_obj()), + CancelConfirmMsg::Confirmed => Ok(CONFIRMED.as_obj()), + } + } +} + +impl TryFrom for Obj { + type Error = Error; + + fn try_from(value: CancelInfoConfirmMsg) -> Result { + match value { + CancelInfoConfirmMsg::Cancelled => Ok(CANCELLED.as_obj()), + CancelInfoConfirmMsg::Info => Ok(INFO.as_obj()), + CancelInfoConfirmMsg::Confirmed => Ok(CONFIRMED.as_obj()), + } + } +} + +impl TryFrom for Obj { + type Error = Error; + + fn try_from(value: SelectWordMsg) -> Result { + match value { + SelectWordMsg::Selected(i) => i.try_into(), + } + } +} + +impl TryFrom for Obj { + type Error = Error; + + fn try_from(value: SelectWordCountMsg) -> Result { + match value { + SelectWordCountMsg::Selected(i) => i.try_into(), + } + } +} + +impl ComponentMsgObj for FidoConfirm +where + F: Fn(usize) -> T, + T: StringType, + U: Component, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + FidoMsg::Confirmed(page) => Ok((page as u8).into()), + FidoMsg::Cancelled => Ok(CANCELLED.as_obj()), + } + } +} + +impl ComponentMsgObj for Dialog +where + T: ComponentMsgObj, + U: Component, + ::Msg: TryInto, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + DialogMsg::Content(c) => Ok(self.inner().msg_try_into_obj(c)?), + DialogMsg::Controls(msg) => msg.try_into(), + } + } +} + +impl ComponentMsgObj for IconDialog +where + T: StringType, + U: Component, + ::Msg: TryInto, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + DialogMsg::Controls(msg) => msg.try_into(), + _ => unreachable!(), + } + } +} + +impl ComponentMsgObj for PinKeyboard +where + T: AsRef, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + PinKeyboardMsg::Confirmed => self.pin().try_into(), + PinKeyboardMsg::Cancelled => Ok(CANCELLED.as_obj()), + } + } +} + +impl ComponentMsgObj for PassphraseKeyboard { + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + PassphraseKeyboardMsg::Confirmed => self.passphrase().try_into(), + PassphraseKeyboardMsg::Cancelled => Ok(CANCELLED.as_obj()), + } + } +} + +impl ComponentMsgObj for MnemonicKeyboard +where + T: MnemonicInput, + U: AsRef, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + MnemonicKeyboardMsg::Confirmed => { + if let Some(word) = self.mnemonic() { + word.try_into() + } else { + panic!("invalid mnemonic") + } + } + MnemonicKeyboardMsg::Previous => "".try_into(), + } + } +} + +impl ComponentMsgObj for Frame +where + T: ComponentMsgObj, + U: AsRef, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + FrameMsg::Content(c) => self.inner().msg_try_into_obj(c), + FrameMsg::Button(b) => b.try_into(), + } + } +} + +impl ComponentMsgObj for ButtonPage +where + T: Component + Paginate, + U: AsRef + From<&'static str>, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + PageMsg::Content(_) => Err(Error::TypeError), + PageMsg::Confirmed => Ok(CONFIRMED.as_obj()), + PageMsg::Cancelled => Ok(CANCELLED.as_obj()), + PageMsg::SwipeLeft => Ok(INFO.as_obj()), + PageMsg::SwipeRight => Ok(CANCELLED.as_obj()), + } + } +} + +impl ComponentMsgObj for painter::Painter +where + F: FnMut(geometry::Rect), +{ + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + unreachable!() + } +} + +// Clippy/compiler complains about conflicting implementations +// TODO move the common impls to a common module +#[cfg(not(feature = "clippy"))] +impl ComponentMsgObj for Paragraphs +where + T: ParagraphSource, +{ + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + unreachable!() + } +} + +impl ComponentMsgObj for FormattedText +where + T: StringType + Clone, +{ + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + unreachable!() + } +} + +impl ComponentMsgObj for Checklist +where + T: ParagraphSource, +{ + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + unreachable!() + } +} + +impl ComponentMsgObj for NumberInputDialog +where + T: StringType, + F: Fn(u32) -> T, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + let value = self.value().try_into()?; + match msg { + NumberInputDialogMsg::Selected => Ok((CONFIRMED.as_obj(), value).try_into()?), + NumberInputDialogMsg::InfoRequested => Ok((CANCELLED.as_obj(), value).try_into()?), + } + } +} + +impl ComponentMsgObj for Border +where + T: ComponentMsgObj, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + self.inner().msg_try_into_obj(msg) + } +} + +impl ComponentMsgObj for Progress +where + T: StringType, +{ + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + unreachable!() + } +} + +impl ComponentMsgObj for Homescreen { + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + HomescreenMsg::Dismissed => Ok(CANCELLED.as_obj()), + } + } +} + +impl ComponentMsgObj for Lockscreen { + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + HomescreenMsg::Dismissed => Ok(CANCELLED.as_obj()), + } + } +} + +impl ComponentMsgObj for (GridPlaced>, GridPlaced>) +where + T: ParagraphSource, + S: StringType + Clone, +{ + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + unreachable!() + } +} + +// Clippy/compiler complains about conflicting implementations +#[cfg(not(feature = "clippy"))] +impl ComponentMsgObj for (Timeout, T) +where + T: Component, +{ + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + Ok(CANCELLED.as_obj()) + } +} + +impl ComponentMsgObj for Qr { + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + unreachable!(); + } +} + +impl ComponentMsgObj for SimplePage +where + T: ComponentMsgObj + Paginate, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + PageMsg::Content(inner_msg) => Ok(self.inner().msg_try_into_obj(inner_msg)?), + PageMsg::Cancelled => Ok(CANCELLED.as_obj()), + _ => Err(Error::TypeError), + } + } +} + +impl ComponentMsgObj for AddressDetails +where + T: StringType + Clone, +{ + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + Ok(CANCELLED.as_obj()) + } +} + +impl ComponentMsgObj for CoinJoinProgress +where + T: AsRef, + U: Component, +{ + fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result { + unreachable!(); + } +} + +impl ComponentMsgObj for super::component::bl_confirm::Confirm +where + T: AsRef, +{ + fn msg_try_into_obj(&self, msg: Self::Msg) -> Result { + match msg { + super::component::bl_confirm::ConfirmMsg::Cancel => Ok(CANCELLED.as_obj()), + super::component::bl_confirm::ConfirmMsg::Confirm => Ok(CONFIRMED.as_obj()), + } + } +} + +extern "C" fn new_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let action: Option = kwargs.get(Qstr::MP_QSTR_action)?.try_into_option()?; + let description: Option = + kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?; + let verb: Option = kwargs + .get(Qstr::MP_QSTR_verb) + .unwrap_or_else(|_| Obj::const_none()) + .try_into_option()?; + let verb_cancel: Option = kwargs + .get(Qstr::MP_QSTR_verb_cancel) + .unwrap_or_else(|_| Obj::const_none()) + .try_into_option()?; + let reverse: bool = kwargs.get_or(Qstr::MP_QSTR_reverse, false)?; + let hold: bool = kwargs.get_or(Qstr::MP_QSTR_hold, false)?; + let hold_danger: bool = kwargs.get_or(Qstr::MP_QSTR_hold_danger, false)?; + + let paragraphs = { + let action = action.unwrap_or_default(); + let description = description.unwrap_or_default(); + let mut paragraphs = ParagraphVecShort::new(); + if !reverse { + paragraphs + .add(Paragraph::new(&theme::TEXT_DEMIBOLD, action)) + .add(Paragraph::new(&theme::TEXT_NORMAL, description)); + } else { + paragraphs + .add(Paragraph::new(&theme::TEXT_NORMAL, description)) + .add(Paragraph::new(&theme::TEXT_DEMIBOLD, action)); + } + paragraphs.into_paragraphs() + }; + + let mut page = if hold { + ButtonPage::new(paragraphs, theme::BG).with_hold()? + } else { + ButtonPage::new(paragraphs, theme::BG).with_cancel_confirm(verb_cancel, verb) + }; + if hold && hold_danger { + page = page.with_confirm_style(theme::button_danger()) + } + let obj = LayoutObj::new(Frame::left_aligned(theme::label_title(), title, page))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_emphasized(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let verb: Option = kwargs + .get(Qstr::MP_QSTR_verb) + .unwrap_or_else(|_| Obj::const_none()) + .try_into_option()?; + + let items: Obj = kwargs.get(Qstr::MP_QSTR_items)?; + let mut ops = OpTextLayout::new(theme::TEXT_NORMAL); + for item in IterBuf::new().try_iterate(items)? { + if item.is_str() { + ops = ops.text_normal(item.try_into()?) + } else { + let [emphasis, text]: [Obj; 2] = util::iter_into_array(item)?; + let text: StrBuffer = text.try_into()?; + if emphasis.try_into()? { + ops = ops.text_demibold(text); + } else { + ops = ops.text_normal(text); + } + } + } + + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + title, + ButtonPage::new(FormattedText::new(ops).vertically_centered(), theme::BG) + .with_cancel_confirm(None, verb), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +struct ConfirmBlobParams { + title: StrBuffer, + subtitle: Option, + data: Obj, + description: Option, + extra: Option, + verb: Option, + verb_cancel: Option, + info_button: bool, + hold: bool, + chunkify: bool, + text_mono: bool, +} + +impl ConfirmBlobParams { + fn new( + title: StrBuffer, + data: Obj, + description: Option, + verb: Option, + verb_cancel: Option, + hold: bool, + ) -> Self { + Self { + title, + subtitle: None, + data, + description, + extra: None, + verb, + verb_cancel, + info_button: false, + hold, + chunkify: false, + text_mono: true, + } + } + + fn with_extra(mut self, extra: Option) -> Self { + self.extra = extra; + self + } + + fn with_subtitle(mut self, subtitle: Option) -> Self { + self.subtitle = subtitle; + self + } + + fn with_info_button(mut self, info_button: bool) -> Self { + self.info_button = info_button; + self + } + + fn with_chunkify(mut self, chunkify: bool) -> Self { + self.chunkify = chunkify; + self + } + + fn with_text_mono(mut self, text_mono: bool) -> Self { + self.text_mono = text_mono; + self + } + + fn into_layout(self) -> Result { + let paragraphs = ConfirmBlob { + description: self.description.unwrap_or_else(StrBuffer::empty), + extra: self.extra.unwrap_or_else(StrBuffer::empty), + data: self.data.try_into()?, + description_font: &theme::TEXT_NORMAL, + extra_font: &theme::TEXT_DEMIBOLD, + data_font: if self.chunkify { + let data: StrBuffer = self.data.try_into()?; + theme::get_chunkified_text_style(data.len()) + } else if self.text_mono { + &theme::TEXT_MONO + } else { + &theme::TEXT_NORMAL + }, + } + .into_paragraphs(); + + let mut page = ButtonPage::new(paragraphs, theme::BG); + if let Some(verb) = self.verb { + page = page.with_cancel_confirm(self.verb_cancel, Some(verb)) + } + if self.hold { + page = page.with_hold()? + } + let mut frame = Frame::left_aligned(theme::label_title(), self.title, page); + if let Some(subtitle) = self.subtitle { + frame = frame.with_subtitle(theme::label_subtitle(), subtitle); + } + if self.info_button { + frame = frame.with_info_button(); + } + let obj = LayoutObj::new(frame)?; + Ok(obj.into()) + } +} + +extern "C" fn new_confirm_blob(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let data: Obj = kwargs.get(Qstr::MP_QSTR_data)?; + let description: Option = + kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?; + let extra: Option = kwargs.get(Qstr::MP_QSTR_extra)?.try_into_option()?; + let verb: Option = kwargs + .get(Qstr::MP_QSTR_verb) + .unwrap_or_else(|_| Obj::const_none()) + .try_into_option()?; + let verb_cancel: Option = kwargs + .get(Qstr::MP_QSTR_verb_cancel) + .unwrap_or_else(|_| Obj::const_none()) + .try_into_option()?; + let hold: bool = kwargs.get_or(Qstr::MP_QSTR_hold, false)?; + let chunkify: bool = kwargs.get_or(Qstr::MP_QSTR_chunkify, false)?; + + ConfirmBlobParams::new(title, data, description, verb, verb_cancel, hold) + .with_extra(extra) + .with_chunkify(chunkify) + .into_layout() + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_address(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let description: Option = + kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?; + let verb: StrBuffer = + kwargs.get_or(Qstr::MP_QSTR_verb, TR::buttons__confirm.try_into()?)?; + let extra: Option = kwargs.get(Qstr::MP_QSTR_extra)?.try_into_option()?; + let data: Obj = kwargs.get(Qstr::MP_QSTR_data)?; + let chunkify: bool = kwargs.get_or(Qstr::MP_QSTR_chunkify, false)?; + + let data_style = if chunkify { + let address: StrBuffer = data.try_into()?; + theme::get_chunkified_text_style(address.len()) + } else { + &theme::TEXT_MONO + }; + + let paragraphs = ConfirmBlob { + description: description.unwrap_or_else(StrBuffer::empty), + extra: extra.unwrap_or_else(StrBuffer::empty), + data: data.try_into()?, + description_font: &theme::TEXT_NORMAL, + extra_font: &theme::TEXT_DEMIBOLD, + data_font: data_style, + } + .into_paragraphs(); + + let obj = LayoutObj::new( + Frame::left_aligned( + theme::label_title(), + title, + ButtonPage::new(paragraphs, theme::BG) + .with_swipe_left() + .with_cancel_confirm(None, Some(verb)), + ) + .with_info_button(), + )?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_properties(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let hold: bool = kwargs.get_or(Qstr::MP_QSTR_hold, false)?; + let items: Obj = kwargs.get(Qstr::MP_QSTR_items)?; + + let paragraphs = PropsList::new( + items, + &theme::TEXT_NORMAL, + &theme::TEXT_MONO, + &theme::TEXT_MONO, + )?; + let page: ButtonPage<_, StrBuffer> = if hold { + ButtonPage::new(paragraphs.into_paragraphs(), theme::BG).with_hold()? + } else { + ButtonPage::new(paragraphs.into_paragraphs(), theme::BG) + .with_cancel_confirm(None, Some(TR::buttons__confirm.try_into()?)) + }; + let obj = LayoutObj::new(Frame::left_aligned(theme::label_title(), title, page))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_homescreen(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let data: Obj = kwargs.get(Qstr::MP_QSTR_image)?; + + // Layout needs to hold the Obj to play nice with GC. Obj is resolved to &[u8] + // in every paint pass. + let buffer_func = move || { + // SAFETY: We expect no existing mutable reference. Resulting reference is + // discarded before returning to micropython. + let buffer = unsafe { unwrap!(get_buffer(data)) }; + // Incoming data may be empty, meaning we should display default homescreen + // image. + if buffer.is_empty() { + theme::IMAGE_HOMESCREEN + } else { + buffer + } + }; + + let size = match jpeg_info(buffer_func()) { + Some(info) => info.0, + _ => return Err(value_error!("Invalid image.")), + }; + + let tr_change: StrBuffer = TR::buttons__change.try_into()?; + let buttons = Button::cancel_confirm_text(None, Some(tr_change)); + let obj = LayoutObj::new(Frame::centered( + theme::label_title(), + title, + Dialog::new(painter::jpeg_painter(buffer_func, size, 1), buttons), + ))?; + Ok(obj.into()) + }; + + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_reset_device(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let button: StrBuffer = kwargs.get(Qstr::MP_QSTR_button)?.try_into()?; + + let par_array: [Paragraph; 3] = [ + Paragraph::new(&theme::TEXT_NORMAL, TR::reset__by_continuing.try_into()?) + .with_bottom_padding(17), // simulating a carriage return + Paragraph::new(&theme::TEXT_NORMAL, TR::reset__more_info_at.try_into()?), + Paragraph::new(&theme::TEXT_DEMIBOLD, TR::reset__tos_link.try_into()?), + ]; + let paragraphs = Paragraphs::new(par_array); + let buttons = Button::cancel_confirm( + Button::with_icon(theme::ICON_CANCEL), + Button::with_text(button).styled(theme::button_confirm()), + true, + ); + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + title, + Dialog::new(paragraphs, buttons), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_address_details(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let qr_title: StrBuffer = kwargs.get(Qstr::MP_QSTR_qr_title)?.try_into()?; + let details_title: StrBuffer = kwargs.get(Qstr::MP_QSTR_details_title)?.try_into()?; + let address: StrBuffer = kwargs.get(Qstr::MP_QSTR_address)?.try_into()?; + let case_sensitive: bool = kwargs.get(Qstr::MP_QSTR_case_sensitive)?.try_into()?; + let account: Option = kwargs.get(Qstr::MP_QSTR_account)?.try_into_option()?; + let path: Option = kwargs.get(Qstr::MP_QSTR_path)?.try_into_option()?; + + let xpubs: Obj = kwargs.get(Qstr::MP_QSTR_xpubs)?; + + let mut ad = AddressDetails::new( + qr_title, + address, + case_sensitive, + details_title, + account, + path, + )?; + + for i in IterBuf::new().try_iterate(xpubs)? { + let [xtitle, text]: [StrBuffer; 2] = util::iter_into_array(i)?; + ad.add_xpub(xtitle, text)?; + } + + let obj = + LayoutObj::new(SimplePage::horizontal(ad, theme::BG).with_swipe_right_to_go_back())?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_info_with_cancel(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let items: Obj = kwargs.get(Qstr::MP_QSTR_items)?; + let horizontal: bool = kwargs.get_or(Qstr::MP_QSTR_horizontal, false)?; + let chunkify: bool = kwargs.get_or(Qstr::MP_QSTR_chunkify, false)?; + + let mut paragraphs = ParagraphVecShort::new(); + + for para in IterBuf::new().try_iterate(items)? { + let [key, value]: [Obj; 2] = util::iter_into_array(para)?; + let key: StrBuffer = key.try_into()?; + let value: StrBuffer = value.try_into()?; + paragraphs.add(Paragraph::new(&theme::TEXT_NORMAL, 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, value)); + } + } + + let axis = match horizontal { + true => geometry::Axis::Horizontal, + _ => geometry::Axis::Vertical, + }; + + let obj = LayoutObj::new( + Frame::left_aligned( + theme::label_title(), + title, + SimplePage::new(paragraphs.into_paragraphs(), axis, theme::BG) + .with_swipe_right_to_go_back(), + ) + .with_cancel_button(), + )?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_value(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let subtitle: Option = kwargs.get(Qstr::MP_QSTR_subtitle)?.try_into_option()?; + let description: Option = + kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?; + let value: Obj = kwargs.get(Qstr::MP_QSTR_value)?; + let info_button: bool = kwargs.get_or(Qstr::MP_QSTR_info_button, false)?; + + let verb: Option = kwargs + .get(Qstr::MP_QSTR_verb) + .unwrap_or_else(|_| Obj::const_none()) + .try_into_option()?; + let verb_cancel: Option = kwargs + .get(Qstr::MP_QSTR_verb_cancel) + .unwrap_or_else(|_| Obj::const_none()) + .try_into_option()?; + let hold: bool = kwargs.get_or(Qstr::MP_QSTR_hold, false)?; + let chunkify: bool = kwargs.get_or(Qstr::MP_QSTR_chunkify, false)?; + let text_mono: bool = kwargs.get_or(Qstr::MP_QSTR_text_mono, true)?; + + ConfirmBlobParams::new(title, value, description, verb, verb_cancel, hold) + .with_subtitle(subtitle) + .with_info_button(info_button) + .with_chunkify(chunkify) + .with_text_mono(text_mono) + .into_layout() + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_total(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let items: Obj = kwargs.get(Qstr::MP_QSTR_items)?; + let info_button: bool = kwargs.get_or(Qstr::MP_QSTR_info_button, false)?; + let cancel_arrow: bool = kwargs.get_or(Qstr::MP_QSTR_cancel_arrow, false)?; + + let mut paragraphs = ParagraphVecShort::new(); + + for pair in IterBuf::new().try_iterate(items)? { + let [label, value]: [StrBuffer; 2] = util::iter_into_array(pair)?; + paragraphs.add(Paragraph::new(&theme::TEXT_NORMAL, label).no_break()); + paragraphs.add(Paragraph::new(&theme::TEXT_MONO, value)); + } + let mut page: ButtonPage<_, StrBuffer> = + ButtonPage::new(paragraphs.into_paragraphs(), theme::BG).with_hold()?; + if cancel_arrow { + page = page.with_cancel_arrow() + } + if info_button { + page = page.with_swipe_left(); + } + let mut frame = Frame::left_aligned(theme::label_title(), title, page); + if info_button { + frame = frame.with_info_button(); + } + let obj = LayoutObj::new(frame)?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_modify_output(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let sign: i32 = kwargs.get(Qstr::MP_QSTR_sign)?.try_into()?; + let amount_change: StrBuffer = kwargs.get(Qstr::MP_QSTR_amount_change)?.try_into()?; + let amount_new: StrBuffer = kwargs.get(Qstr::MP_QSTR_amount_new)?.try_into()?; + + let description = if sign < 0 { + TR::modify_amount__decrease_amount.try_into()? + } else { + TR::modify_amount__increase_amount.try_into()? + }; + + let paragraphs = Paragraphs::new([ + Paragraph::new(&theme::TEXT_NORMAL, description), + Paragraph::new(&theme::TEXT_MONO, amount_change), + Paragraph::new( + &theme::TEXT_NORMAL, + TR::modify_amount__new_amount.try_into()?, + ), + Paragraph::new(&theme::TEXT_MONO, amount_new), + ]); + + let tr_title: StrBuffer = TR::modify_amount__title.try_into()?; + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + tr_title, + ButtonPage::<_, StrBuffer>::new(paragraphs, theme::BG) + .with_cancel_confirm(Some("^".into()), Some(TR::buttons__continue.try_into()?)), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_modify_fee(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let sign: i32 = kwargs.get(Qstr::MP_QSTR_sign)?.try_into()?; + let user_fee_change: StrBuffer = kwargs.get(Qstr::MP_QSTR_user_fee_change)?.try_into()?; + let total_fee_new: StrBuffer = kwargs.get(Qstr::MP_QSTR_total_fee_new)?.try_into()?; + + let (description, change, total_label) = match sign { + s if s < 0 => ( + TR::modify_fee__decrease_fee.try_into()?, + user_fee_change, + TR::modify_fee__new_transaction_fee.try_into()?, + ), + s if s > 0 => ( + TR::modify_fee__increase_fee.try_into()?, + user_fee_change, + TR::modify_fee__new_transaction_fee.try_into()?, + ), + _ => ( + TR::modify_fee__no_change.try_into()?, + StrBuffer::empty(), + TR::modify_fee__transaction_fee.try_into()?, + ), + }; + + let paragraphs = Paragraphs::new([ + Paragraph::new(&theme::TEXT_NORMAL, description), + Paragraph::new(&theme::TEXT_MONO, change), + Paragraph::new(&theme::TEXT_NORMAL, total_label), + Paragraph::new(&theme::TEXT_MONO, total_fee_new), + ]); + + let obj = LayoutObj::new( + Frame::left_aligned( + theme::label_title(), + title, + ButtonPage::<_, StrBuffer>::new(paragraphs, theme::BG) + .with_hold()? + .with_swipe_left(), + ) + .with_info_button(), + )?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +fn new_show_modal( + kwargs: &Map, + icon: BlendedImage, + button_style: ButtonStyleSheet, +) -> Result { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let value: StrBuffer = kwargs.get_or(Qstr::MP_QSTR_value, StrBuffer::empty())?; + let description: StrBuffer = kwargs.get_or(Qstr::MP_QSTR_description, StrBuffer::empty())?; + let button: StrBuffer = + kwargs.get_or(Qstr::MP_QSTR_button, TR::buttons__continue.try_into()?)?; + let allow_cancel: bool = kwargs.get_or(Qstr::MP_QSTR_allow_cancel, true)?; + let time_ms: u32 = kwargs.get_or(Qstr::MP_QSTR_time_ms, 0)?; + + let no_buttons = button.as_ref().is_empty(); + let obj = if no_buttons && time_ms == 0 { + // No buttons and no timer, used when we only want to draw the dialog once and + // then throw away the layout object. + LayoutObj::new( + IconDialog::new(icon, title, Empty) + .with_value(value) + .with_description(description), + )? + .into() + } else if no_buttons && time_ms > 0 { + // Timeout, no buttons. + LayoutObj::new( + IconDialog::new( + icon, + title, + Timeout::new(time_ms).map(|_| Some(CancelConfirmMsg::Confirmed)), + ) + .with_value(value) + .with_description(description), + )? + .into() + } else if allow_cancel { + // Two buttons. + LayoutObj::new( + IconDialog::new( + icon, + title, + Button::cancel_confirm( + Button::with_icon(theme::ICON_CANCEL), + Button::with_text(button).styled(button_style), + false, + ), + ) + .with_value(value) + .with_description(description), + )? + .into() + } else { + // Single button. + LayoutObj::new( + IconDialog::new( + icon, + title, + theme::button_bar(Button::with_text(button).styled(button_style).map(|msg| { + (matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Confirmed) + })), + ) + .with_value(value) + .with_description(description), + )? + .into() + }; + + Ok(obj) +} + +extern "C" fn new_show_error(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let icon = BlendedImage::new( + theme::IMAGE_BG_CIRCLE, + theme::IMAGE_FG_ERROR, + theme::ERROR_COLOR, + theme::FG, + theme::BG, + ); + new_show_modal(kwargs, icon, theme::button_default()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_fido(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let app_name: StrBuffer = kwargs.get(Qstr::MP_QSTR_app_name)?.try_into()?; + let icon: Option = kwargs.get(Qstr::MP_QSTR_icon_name)?.try_into_option()?; + let accounts: Gc = kwargs.get(Qstr::MP_QSTR_accounts)?.try_into()?; + + // Cache the page count so that we can move `accounts` into the closure. + let page_count = accounts.len(); + // Closure to lazy-load the information on given page index. + // Done like this to allow arbitrarily many pages without + // the need of any allocation here in Rust. + let get_page = move |page_index| { + let account = unwrap!(accounts.get(page_index)); + account.try_into().unwrap_or_else(|_| "".into()) + }; + + let controls = Button::cancel_confirm( + Button::with_icon(theme::ICON_CANCEL), + Button::::with_text(TR::buttons__confirm.try_into()?) + .styled(theme::button_confirm()), + true, + ); + + let fido_page = FidoConfirm::new(app_name, get_page, page_count, icon, controls); + + let obj = LayoutObj::new(Frame::centered(theme::label_title(), title, fido_page))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_warning(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let icon = BlendedImage::new( + theme::IMAGE_BG_OCTAGON, + theme::IMAGE_FG_WARN, + theme::WARN_COLOR, + theme::FG, + theme::BG, + ); + new_show_modal(kwargs, icon, theme::button_reset()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_success(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let icon = BlendedImage::new( + theme::IMAGE_BG_CIRCLE, + theme::IMAGE_FG_SUCCESS, + theme::SUCCESS_COLOR, + theme::FG, + theme::BG, + ); + new_show_modal(kwargs, icon, theme::button_confirm()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_info(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let icon = BlendedImage::new( + theme::IMAGE_BG_CIRCLE, + theme::IMAGE_FG_INFO, + theme::INFO_COLOR, + theme::FG, + theme::BG, + ); + new_show_modal(kwargs, icon, theme::button_info()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_mismatch(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let description: StrBuffer = TR::addr_mismatch__contact_support_at.try_into()?; + let url: StrBuffer = TR::addr_mismatch__support_url.try_into()?; + let button: StrBuffer = TR::buttons__quit.try_into()?; + + let icon = BlendedImage::new( + theme::IMAGE_BG_OCTAGON, + theme::IMAGE_FG_WARN, + theme::WARN_COLOR, + theme::FG, + theme::BG, + ); + let obj = LayoutObj::new( + IconDialog::new( + icon, + title, + Button::cancel_confirm( + Button::with_icon(theme::ICON_BACK), + Button::with_text(button).styled(theme::button_reset()), + true, + ), + ) + .with_paragraph( + Paragraph::new(&theme::TEXT_NORMAL, description) + .centered() + .with_bottom_padding( + theme::TEXT_NORMAL.text_font.text_height() + - theme::TEXT_DEMIBOLD.text_font.text_height(), + ), + ) + .with_text(&theme::TEXT_DEMIBOLD, url), + )?; + + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_simple(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: Option = kwargs.get(Qstr::MP_QSTR_title)?.try_into_option()?; + let description: StrBuffer = + kwargs.get_or(Qstr::MP_QSTR_description, StrBuffer::empty())?; + let button: StrBuffer = kwargs.get_or(Qstr::MP_QSTR_button, StrBuffer::empty())?; + + let obj = if let Some(t) = title { + LayoutObj::new(Frame::left_aligned( + theme::label_title(), + t, + Dialog::new( + Paragraphs::new([Paragraph::new(&theme::TEXT_NORMAL, description)]), + theme::button_bar(Button::with_text(button).map(|msg| { + (matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Confirmed) + })), + ), + ))? + .into() + } else if !button.is_empty() { + LayoutObj::new(Border::new( + theme::borders(), + Dialog::new( + Paragraphs::new([Paragraph::new(&theme::TEXT_NORMAL, description)]), + theme::button_bar(Button::with_text(button).map(|msg| { + (matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Confirmed) + })), + ), + ))? + .into() + } else { + LayoutObj::new(Border::new( + theme::borders(), + Dialog::new( + Paragraphs::new( + [Paragraph::new(&theme::TEXT_DEMIBOLD, description).centered()], + ), + Empty, + ), + ))? + .into() + }; + + Ok(obj) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_with_info(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let button: StrBuffer = kwargs.get(Qstr::MP_QSTR_button)?.try_into()?; + let info_button: StrBuffer = kwargs.get(Qstr::MP_QSTR_info_button)?.try_into()?; + let items: Obj = kwargs.get(Qstr::MP_QSTR_items)?; + + let mut paragraphs = ParagraphVecShort::new(); + + for para in IterBuf::new().try_iterate(items)? { + let [font, text]: [Obj; 2] = util::iter_into_array(para)?; + let style: &TextStyle = theme::textstyle_number(font.try_into()?); + let text: StrBuffer = text.try_into()?; + paragraphs.add(Paragraph::new(style, text)); + if paragraphs.is_full() { + break; + } + } + + let buttons = Button::cancel_info_confirm(button, info_button); + + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + title, + Dialog::new(paragraphs.into_paragraphs(), buttons), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_more(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let button: StrBuffer = kwargs.get(Qstr::MP_QSTR_button)?.try_into()?; + let items: Obj = kwargs.get(Qstr::MP_QSTR_items)?; + + let mut paragraphs = ParagraphVecLong::new(); + + for para in IterBuf::new().try_iterate(items)? { + let [font, text]: [Obj; 2] = util::iter_into_array(para)?; + let style: &TextStyle = theme::textstyle_number(font.try_into()?); + let text: StrBuffer = text.try_into()?; + paragraphs.add(Paragraph::new(style, text)); + } + + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + title, + ButtonPage::new(paragraphs.into_paragraphs(), theme::BG) + .with_cancel_confirm(None, Some(button)) + .with_confirm_style(theme::button_default()) + .with_back_button(), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_coinjoin(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let max_rounds: StrBuffer = kwargs.get(Qstr::MP_QSTR_max_rounds)?.try_into()?; + let max_feerate: StrBuffer = kwargs.get(Qstr::MP_QSTR_max_feerate)?.try_into()?; + + let paragraphs = Paragraphs::new([ + Paragraph::new(&theme::TEXT_NORMAL, TR::coinjoin__max_rounds.try_into()?), + Paragraph::new(&theme::TEXT_MONO, max_rounds), + Paragraph::new( + &theme::TEXT_NORMAL, + TR::coinjoin__max_mining_fee.try_into()?, + ), + Paragraph::new(&theme::TEXT_MONO, max_feerate), + ]); + + let tr_title: StrBuffer = TR::coinjoin__title.try_into()?; + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + tr_title, + ButtonPage::<_, StrBuffer>::new(paragraphs, theme::BG).with_hold()?, + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_request_pin(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; + let subprompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_subprompt)?.try_into()?; + let allow_cancel: bool = kwargs.get_or(Qstr::MP_QSTR_allow_cancel, true)?; + let warning: bool = kwargs.get_or(Qstr::MP_QSTR_wrong_pin, false)?; + let warning = if warning { + Some(TR::pin__wrong_pin.try_into()?) + } else { + None + }; + let obj = LayoutObj::new(PinKeyboard::new(prompt, subprompt, warning, allow_cancel))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_request_passphrase(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let _prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; + let _max_len: u32 = kwargs.get(Qstr::MP_QSTR_max_len)?.try_into()?; + let obj = LayoutObj::new(PassphraseKeyboard::new())?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_request_bip39(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; + let prefill_word: StrBuffer = kwargs.get(Qstr::MP_QSTR_prefill_word)?.try_into()?; + let can_go_back: bool = kwargs.get(Qstr::MP_QSTR_can_go_back)?.try_into()?; + let obj = LayoutObj::new(MnemonicKeyboard::new( + Bip39Input::prefilled_word(prefill_word.as_ref()), + prompt, + can_go_back, + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_request_slip39(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; + let prefill_word: StrBuffer = kwargs.get(Qstr::MP_QSTR_prefill_word)?.try_into()?; + let can_go_back: bool = kwargs.get(Qstr::MP_QSTR_can_go_back)?.try_into()?; + let obj = LayoutObj::new(MnemonicKeyboard::new( + Slip39Input::prefilled_word(prefill_word.as_ref()), + prompt, + can_go_back, + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_select_word(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let description: StrBuffer = kwargs.get(Qstr::MP_QSTR_description)?.try_into()?; + let words_iterable: Obj = kwargs.get(Qstr::MP_QSTR_words)?; + let words: [StrBuffer; 3] = util::iter_into_array(words_iterable)?; + + let paragraphs = Paragraphs::new([Paragraph::new(&theme::TEXT_DEMIBOLD, description)]); + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + title, + Dialog::new(paragraphs, Button::select_word(words)), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_share_words(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let pages: Obj = kwargs.get(Qstr::MP_QSTR_pages)?; + + let mut paragraphs = ParagraphVecLong::new(); + for page in IterBuf::new().try_iterate(pages)? { + let text: StrBuffer = page.try_into()?; + paragraphs.add(Paragraph::new(&theme::TEXT_MONO, text).break_after()); + } + + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + title, + ButtonPage::<_, StrBuffer>::new(paragraphs.into_paragraphs(), theme::BG) + .with_hold()? + .without_cancel(), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_request_number(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let min_count: u32 = kwargs.get(Qstr::MP_QSTR_min_count)?.try_into()?; + let max_count: u32 = kwargs.get(Qstr::MP_QSTR_max_count)?.try_into()?; + let count: u32 = kwargs.get(Qstr::MP_QSTR_count)?.try_into()?; + let description_callback: Obj = kwargs.get(Qstr::MP_QSTR_description)?; + assert!(description_callback != Obj::const_none()); + + let callback = move |i: u32| { + StrBuffer::try_from( + description_callback + .call_with_n_args(&[i.try_into().unwrap()]) + .unwrap(), + ) + .unwrap() + }; + + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + title, + NumberInputDialog::new(min_count, max_count, count, callback)?, + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_checklist(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let button: StrBuffer = kwargs.get(Qstr::MP_QSTR_button)?.try_into()?; + let active: usize = kwargs.get(Qstr::MP_QSTR_active)?.try_into()?; + let items: Obj = kwargs.get(Qstr::MP_QSTR_items)?; + + let mut paragraphs = ParagraphVecLong::new(); + for (i, item) in IterBuf::new().try_iterate(items)?.enumerate() { + let style = match i.cmp(&active) { + Ordering::Less => &theme::TEXT_CHECKLIST_DONE, + Ordering::Equal => &theme::TEXT_CHECKLIST_SELECTED, + Ordering::Greater => &theme::TEXT_CHECKLIST_DEFAULT, + }; + let text: StrBuffer = item.try_into()?; + paragraphs.add(Paragraph::new(style, text)); + } + + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + title, + Dialog::new( + Checklist::from_paragraphs( + theme::ICON_LIST_CURRENT, + theme::ICON_LIST_CHECK, + active, + paragraphs + .into_paragraphs() + .with_spacing(theme::CHECKLIST_SPACING), + ) + .with_check_width(theme::CHECKLIST_CHECK_WIDTH) + .with_current_offset(theme::CHECKLIST_CURRENT_OFFSET) + .with_done_offset(theme::CHECKLIST_DONE_OFFSET), + theme::button_bar(Button::with_text(button).map(|msg| { + (matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Confirmed) + })), + ), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_confirm_recovery(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let description: StrBuffer = kwargs.get(Qstr::MP_QSTR_description)?.try_into()?; + let button: StrBuffer = kwargs.get(Qstr::MP_QSTR_button)?.try_into()?; + let dry_run: bool = kwargs.get(Qstr::MP_QSTR_dry_run)?.try_into()?; + let info_button: bool = kwargs.get_or(Qstr::MP_QSTR_info_button, false)?; + + let paragraphs = Paragraphs::new([ + Paragraph::new(&theme::TEXT_DEMIBOLD, title), + Paragraph::new(&theme::TEXT_NORMAL, description), + ]) + .with_spacing(theme::RECOVERY_SPACING); + + let notification: StrBuffer = if dry_run { + TR::recovery__title_dry_run.try_into()? + } else { + TR::recovery__title.try_into()? + }; + + let obj = if info_button { + LayoutObj::new(Frame::left_aligned( + theme::label_title(), + notification, + Dialog::new( + paragraphs, + Button::::cancel_info_confirm( + TR::buttons__continue.try_into()?, + TR::buttons__more_info.try_into()?, + ), + ), + ))? + } else { + LayoutObj::new(Frame::left_aligned( + theme::label_title(), + notification, + Dialog::new(paragraphs, Button::cancel_confirm_text(None, Some(button))), + ))? + }; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_select_word_count(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let dry_run: bool = kwargs.get(Qstr::MP_QSTR_dry_run)?.try_into()?; + let title: StrBuffer = if dry_run { + TR::recovery__title_dry_run.try_into()? + } else { + TR::recovery__title.try_into()? + }; + + let paragraphs = Paragraphs::new(Paragraph::::new( + &theme::TEXT_DEMIBOLD, + TR::recovery__select_num_of_words.try_into()?, + )); + + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + title, + Dialog::new(paragraphs, SelectWordCount::new()), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_group_share_success( + n_args: usize, + args: *const Obj, + kwargs: *mut Map, +) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let lines_iterable: Obj = kwargs.get(Qstr::MP_QSTR_lines)?; + let lines: [StrBuffer; 4] = util::iter_into_array(lines_iterable)?; + + let obj = LayoutObj::new(IconDialog::new_shares( + lines, + theme::button_bar( + Button::::with_text(TR::buttons__continue.try_into()?).map(|msg| { + (matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Confirmed) + }), + ), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_remaining_shares(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let pages_iterable: Obj = kwargs.get(Qstr::MP_QSTR_pages)?; + + let mut paragraphs = ParagraphVecLong::new(); + for page in IterBuf::new().try_iterate(pages_iterable)? { + let [title, description]: [StrBuffer; 2] = util::iter_into_array(page)?; + paragraphs + .add(Paragraph::new(&theme::TEXT_DEMIBOLD, title)) + .add(Paragraph::new(&theme::TEXT_NORMAL, description).break_after()); + } + + let tr_title: StrBuffer = TR::recovery__title_remaining_shares.try_into()?; + let obj = LayoutObj::new(Frame::left_aligned( + theme::label_title(), + tr_title, + ButtonPage::<_, StrBuffer>::new(paragraphs.into_paragraphs(), theme::BG) + .with_cancel_confirm(None, Some(TR::buttons__continue.try_into()?)) + .with_confirm_style(theme::button_default()) + .without_cancel(), + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_progress(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let indeterminate: bool = kwargs.get_or(Qstr::MP_QSTR_indeterminate, false)?; + let description: StrBuffer = + kwargs.get_or(Qstr::MP_QSTR_description, StrBuffer::empty())?; + + // Description updates are received as &str and we need to provide a way to + // convert them to StrBuffer. + let obj = LayoutObj::new(Progress::new( + title, + indeterminate, + description, + StrBuffer::alloc, + ))?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_progress_coinjoin(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let indeterminate: bool = kwargs.get_or(Qstr::MP_QSTR_indeterminate, false)?; + let time_ms: u32 = kwargs.get_or(Qstr::MP_QSTR_time_ms, 0)?; + let skip_first_paint: bool = kwargs.get_or(Qstr::MP_QSTR_skip_first_paint, false)?; + + // The second type parameter is actually not used in `new()` but we need to + // provide it. + let progress = CoinJoinProgress::<_, Never>::new(title, indeterminate)?; + let obj = if time_ms > 0 && indeterminate { + let timeout = Timeout::new(time_ms); + LayoutObj::new((timeout, progress.map(|_msg| None)))? + } else { + LayoutObj::new(progress)? + }; + if skip_first_paint { + obj.skip_first_paint(); + } + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_homescreen(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let label: TString<'static> = kwargs + .get(Qstr::MP_QSTR_label)? + .try_into_option()? + .unwrap_or_else(|| model::FULL_NAME.into()); + let notification: Option> = + kwargs.get(Qstr::MP_QSTR_notification)?.try_into_option()?; + let notification_level: u8 = kwargs.get_or(Qstr::MP_QSTR_notification_level, 0)?; + let hold: bool = kwargs.get(Qstr::MP_QSTR_hold)?.try_into()?; + let skip_first_paint: bool = kwargs.get(Qstr::MP_QSTR_skip_first_paint)?.try_into()?; + + let notification = notification.map(|w| (w, notification_level)); + let obj = LayoutObj::new(Homescreen::new(label, notification, hold))?; + if skip_first_paint { + obj.skip_first_paint(); + } + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_lockscreen(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + let block = move |_args: &[Obj], kwargs: &Map| { + let label: TString<'static> = kwargs + .get(Qstr::MP_QSTR_label)? + .try_into_option()? + .unwrap_or_else(|| model::FULL_NAME.into()); + let bootscreen: bool = kwargs.get(Qstr::MP_QSTR_bootscreen)?.try_into()?; + let coinjoin_authorized: bool = kwargs.get_or(Qstr::MP_QSTR_coinjoin_authorized, false)?; + let skip_first_paint: bool = kwargs.get(Qstr::MP_QSTR_skip_first_paint)?.try_into()?; + + let obj = LayoutObj::new(Lockscreen::new(label, bootscreen, coinjoin_authorized))?; + if skip_first_paint { + obj.skip_first_paint(); + } + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +pub extern "C" fn upy_check_homescreen_format(data: Obj) -> Obj { + let block = || { + let buffer = unsafe { get_buffer(data) }?; + + Ok(check_homescreen_format(buffer).into()) + }; + + unsafe { util::try_or_raise(block) } +} + +#[no_mangle] +extern "C" fn new_confirm_firmware_update( + n_args: usize, + args: *const Obj, + kwargs: *mut Map, +) -> Obj { + use super::component::bl_confirm::{Confirm, ConfirmTitle}; + let block = move |_args: &[Obj], kwargs: &Map| { + let description: StrBuffer = kwargs.get(Qstr::MP_QSTR_description)?.try_into()?; + let fingerprint: StrBuffer = kwargs.get(Qstr::MP_QSTR_fingerprint)?.try_into()?; + + let title_str = TR::firmware_update__title.try_into()?; + let title = Label::left_aligned(title_str, theme::TEXT_BOLD).vertically_centered(); + let msg = Label::left_aligned(description, theme::TEXT_NORMAL); + + let left = + Button::with_text(TR::buttons__cancel.try_into()?).styled(theme::button_default()); + let right = + Button::with_text(TR::buttons__install.try_into()?).styled(theme::button_confirm()); + + let obj = LayoutObj::new( + Confirm::new(theme::BG, left, right, ConfirmTitle::Text(title), msg).with_info( + TR::firmware_update__title_fingerprint.try_into()?, + fingerprint, + theme::button_moreinfo(), + ), + )?; + Ok(obj.into()) + }; + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } +} + +extern "C" fn new_show_wait_text(message: Obj) -> Obj { + let block = || { + let message: TString<'static> = message.try_into()?; + let obj = LayoutObj::new(Connect::new(message, theme::FG, theme::BG))?; + Ok(obj.into()) + }; + + unsafe { util::try_or_raise(block) } +} + +#[no_mangle] +pub static mp_module_trezorui2: Module = obj_module! { + Qstr::MP_QSTR___name__ => Qstr::MP_QSTR_trezorui2.to_obj(), + + /// CONFIRMED: object + Qstr::MP_QSTR_CONFIRMED => CONFIRMED.as_obj(), + + /// CANCELLED: object + Qstr::MP_QSTR_CANCELLED => CANCELLED.as_obj(), + + /// INFO: object + Qstr::MP_QSTR_INFO => INFO.as_obj(), + + /// def disable_animation(disable: bool) -> None: + /// """Disable animations, debug builds only.""" + Qstr::MP_QSTR_disable_animation => obj_fn_1!(upy_disable_animation).as_obj(), + + /// def check_homescreen_format(data: bytes) -> bool: + /// """Check homescreen format and dimensions.""" + Qstr::MP_QSTR_check_homescreen_format => obj_fn_1!(upy_check_homescreen_format).as_obj(), + + /// def confirm_action( + /// *, + /// title: str, + /// action: str | None, + /// description: str | None, + /// verb: str | None = None, + /// verb_cancel: str | None = None, + /// hold: bool = False, + /// hold_danger: bool = False, + /// reverse: bool = False, + /// ) -> object: + /// """Confirm action.""" + Qstr::MP_QSTR_confirm_action => obj_fn_kw!(0, new_confirm_action).as_obj(), + + /// def confirm_emphasized( + /// *, + /// title: str, + /// items: Iterable[str | tuple[bool, str]], + /// verb: str | None = None, + /// ) -> object: + /// """Confirm formatted text that has been pre-split in python. For tuples + /// the first component is a bool indicating whether this part is emphasized.""" + Qstr::MP_QSTR_confirm_emphasized => obj_fn_kw!(0, new_confirm_emphasized).as_obj(), + + /// def confirm_homescreen( + /// *, + /// title: str, + /// image: bytes, + /// ) -> object: + /// """Confirm homescreen.""" + Qstr::MP_QSTR_confirm_homescreen => obj_fn_kw!(0, new_confirm_homescreen).as_obj(), + + /// def confirm_blob( + /// *, + /// title: str, + /// data: str | bytes, + /// description: str | None, + /// extra: str | None, + /// verb: str | None = None, + /// verb_cancel: str | None = None, + /// hold: bool = False, + /// chunkify: bool = False, + /// ) -> object: + /// """Confirm byte sequence data.""" + Qstr::MP_QSTR_confirm_blob => obj_fn_kw!(0, new_confirm_blob).as_obj(), + + /// def confirm_address( + /// *, + /// title: str, + /// data: str | bytes, + /// description: str | None, + /// verb: str | None = "CONFIRM", + /// extra: str | None, + /// chunkify: bool = False, + /// ) -> object: + /// """Confirm address. Similar to `confirm_blob` but has corner info button + /// and allows left swipe which does the same thing as the button.""" + Qstr::MP_QSTR_confirm_address => obj_fn_kw!(0, new_confirm_address).as_obj(), + + /// def confirm_properties( + /// *, + /// title: str, + /// items: list[tuple[str | None, str | bytes | None, bool]], + /// hold: bool = False, + /// ) -> object: + /// """Confirm list of key-value pairs. The third component in the tuple should be True if + /// the value is to be rendered as binary with monospace font, False otherwise.""" + Qstr::MP_QSTR_confirm_properties => obj_fn_kw!(0, new_confirm_properties).as_obj(), + + /// def confirm_reset_device( + /// *, + /// title: str, + /// button: str, + /// ) -> object: + /// """Confirm TOS before device setup.""" + Qstr::MP_QSTR_confirm_reset_device => obj_fn_kw!(0, new_confirm_reset_device).as_obj(), + + /// def show_address_details( + /// *, + /// qr_title: str, + /// address: str, + /// case_sensitive: bool, + /// details_title: str, + /// account: str | None, + /// path: str | None, + /// xpubs: list[tuple[str, str]], + /// ) -> object: + /// """Show address details - QR code, account, path, cosigner xpubs.""" + Qstr::MP_QSTR_show_address_details => obj_fn_kw!(0, new_show_address_details).as_obj(), + + /// def show_info_with_cancel( + /// *, + /// title: str, + /// items: Iterable[Tuple[str, str]], + /// horizontal: bool = False, + /// chunkify: bool = False, + /// ) -> object: + /// """Show metadata for outgoing transaction.""" + Qstr::MP_QSTR_show_info_with_cancel => obj_fn_kw!(0, new_show_info_with_cancel).as_obj(), + + /// def confirm_value( + /// *, + /// title: str, + /// value: str, + /// description: str | None, + /// subtitle: str | None, + /// verb: str | None = None, + /// verb_cancel: str | None = None, + /// info_button: bool = False, + /// hold: bool = False, + /// chunkify: bool = False, + /// text_mono: bool = True, + /// ) -> object: + /// """Confirm value. Merge of confirm_total and confirm_output.""" + Qstr::MP_QSTR_confirm_value => obj_fn_kw!(0, new_confirm_value).as_obj(), + + /// def confirm_total( + /// *, + /// title: str, + /// items: Iterable[tuple[str, str]], + /// info_button: bool = False, + /// cancel_arrow: bool = False, + /// ) -> object: + /// """Transaction summary. Always hold to confirm.""" + Qstr::MP_QSTR_confirm_total => obj_fn_kw!(0, new_confirm_total).as_obj(), + + /// def confirm_modify_output( + /// *, + /// sign: int, + /// amount_change: str, + /// amount_new: str, + /// ) -> object: + /// """Decrease or increase output amount.""" + Qstr::MP_QSTR_confirm_modify_output => obj_fn_kw!(0, new_confirm_modify_output).as_obj(), + + /// def confirm_modify_fee( + /// *, + /// title: str, + /// sign: int, + /// user_fee_change: str, + /// total_fee_new: str, + /// fee_rate_amount: str | None, # ignored + /// ) -> object: + /// """Decrease or increase transaction fee.""" + Qstr::MP_QSTR_confirm_modify_fee => obj_fn_kw!(0, new_confirm_modify_fee).as_obj(), + + /// def confirm_fido( + /// *, + /// title: str, + /// app_name: str, + /// icon_name: str | None, + /// accounts: list[str | None], + /// ) -> int | object: + /// """FIDO confirmation. + /// + /// Returns page index in case of confirmation and CANCELLED otherwise. + /// """ + Qstr::MP_QSTR_confirm_fido => obj_fn_kw!(0, new_confirm_fido).as_obj(), + + /// def show_error( + /// *, + /// title: str, + /// button: str = "CONTINUE", + /// description: str = "", + /// allow_cancel: bool = False, + /// time_ms: int = 0, + /// ) -> object: + /// """Error modal. No buttons shown when `button` is empty string.""" + Qstr::MP_QSTR_show_error => obj_fn_kw!(0, new_show_error).as_obj(), + + /// def show_warning( + /// *, + /// title: str, + /// button: str = "CONTINUE", + /// value: str = "", + /// description: str = "", + /// allow_cancel: bool = False, + /// time_ms: int = 0, + /// ) -> object: + /// """Warning modal. No buttons shown when `button` is empty string.""" + Qstr::MP_QSTR_show_warning => obj_fn_kw!(0, new_show_warning).as_obj(), + + /// def show_success( + /// *, + /// title: str, + /// button: str = "CONTINUE", + /// description: str = "", + /// allow_cancel: bool = False, + /// time_ms: int = 0, + /// ) -> object: + /// """Success modal. No buttons shown when `button` is empty string.""" + Qstr::MP_QSTR_show_success => obj_fn_kw!(0, new_show_success).as_obj(), + + /// def show_info( + /// *, + /// title: str, + /// button: str = "CONTINUE", + /// description: str = "", + /// allow_cancel: bool = False, + /// time_ms: int = 0, + /// ) -> object: + /// """Info modal. No buttons shown when `button` is empty string.""" + Qstr::MP_QSTR_show_info => obj_fn_kw!(0, new_show_info).as_obj(), + + /// def show_mismatch(*, title: str) -> object: + /// """Warning modal, receiving address mismatch.""" + Qstr::MP_QSTR_show_mismatch => obj_fn_kw!(0, new_show_mismatch).as_obj(), + + /// def show_simple( + /// *, + /// title: str | None, + /// description: str = "", + /// button: str = "", + /// ) -> object: + /// """Simple dialog with text and one button.""" + Qstr::MP_QSTR_show_simple => obj_fn_kw!(0, new_show_simple).as_obj(), + + /// def confirm_with_info( + /// *, + /// title: str, + /// button: str, + /// info_button: str, + /// items: Iterable[tuple[int, str]], + /// ) -> object: + /// """Confirm given items but with third button. Always single page + /// without scrolling.""" + Qstr::MP_QSTR_confirm_with_info => obj_fn_kw!(0, new_confirm_with_info).as_obj(), + + /// def confirm_more( + /// *, + /// title: str, + /// button: str, + /// items: Iterable[tuple[int, str]], + /// ) -> object: + /// """Confirm long content with the possibility to go back from any page. + /// Meant to be used with confirm_with_info.""" + Qstr::MP_QSTR_confirm_more => obj_fn_kw!(0, new_confirm_more).as_obj(), + + /// def confirm_coinjoin( + /// *, + /// max_rounds: str, + /// max_feerate: str, + /// ) -> object: + /// """Confirm coinjoin authorization.""" + Qstr::MP_QSTR_confirm_coinjoin => obj_fn_kw!(0, new_confirm_coinjoin).as_obj(), + + /// def request_pin( + /// *, + /// prompt: str, + /// subprompt: str, + /// allow_cancel: bool = True, + /// wrong_pin: bool = False, + /// ) -> str | object: + /// """Request pin on device.""" + Qstr::MP_QSTR_request_pin => obj_fn_kw!(0, new_request_pin).as_obj(), + + /// def request_passphrase( + /// *, + /// prompt: str, + /// max_len: int, + /// ) -> str | object: + /// """Passphrase input keyboard.""" + Qstr::MP_QSTR_request_passphrase => obj_fn_kw!(0, new_request_passphrase).as_obj(), + + /// def request_bip39( + /// *, + /// prompt: str, + /// prefill_word: str, + /// can_go_back: bool, + /// ) -> str: + /// """BIP39 word input keyboard.""" + Qstr::MP_QSTR_request_bip39 => obj_fn_kw!(0, new_request_bip39).as_obj(), + + /// def request_slip39( + /// *, + /// prompt: str, + /// prefill_word: str, + /// can_go_back: bool, + /// ) -> str: + /// """SLIP39 word input keyboard.""" + Qstr::MP_QSTR_request_slip39 => obj_fn_kw!(0, new_request_slip39).as_obj(), + + /// def select_word( + /// *, + /// title: str, + /// description: str, + /// words: Iterable[str], + /// ) -> int: + /// """Select mnemonic word from three possibilities - seed check after backup. The + /// iterable must be of exact size. Returns index in range `0..3`.""" + Qstr::MP_QSTR_select_word => obj_fn_kw!(0, new_select_word).as_obj(), + + /// def show_share_words( + /// *, + /// title: str, + /// pages: Iterable[str], + /// ) -> object: + /// """Show mnemonic for backup. Expects the words pre-divided into individual pages.""" + Qstr::MP_QSTR_show_share_words => obj_fn_kw!(0, new_show_share_words).as_obj(), + + /// def request_number( + /// *, + /// title: str, + /// count: int, + /// min_count: int, + /// max_count: int, + /// description: Callable[[int], str] | None = None, + /// ) -> object: + /// """Number input with + and - buttons, description, and info button.""" + Qstr::MP_QSTR_request_number => obj_fn_kw!(0, new_request_number).as_obj(), + + /// def show_checklist( + /// *, + /// title: str, + /// items: Iterable[str], + /// active: int, + /// button: str, + /// ) -> object: + /// """Checklist of backup steps. Active index is highlighted, previous items have check + /// mark next to them.""" + Qstr::MP_QSTR_show_checklist => obj_fn_kw!(0, new_show_checklist).as_obj(), + + /// def confirm_recovery( + /// *, + /// title: str, + /// description: str, + /// button: str, + /// dry_run: bool, + /// info_button: bool = False, + /// ) -> object: + /// """Device recovery homescreen.""" + Qstr::MP_QSTR_confirm_recovery => obj_fn_kw!(0, new_confirm_recovery).as_obj(), + + /// def select_word_count( + /// *, + /// dry_run: bool, + /// ) -> int | str: # TT returns int + /// """Select mnemonic word count from (12, 18, 20, 24, 33).""" + Qstr::MP_QSTR_select_word_count => obj_fn_kw!(0, new_select_word_count).as_obj(), + + /// def show_group_share_success( + /// *, + /// lines: Iterable[str] + /// ) -> int: + /// """Shown after successfully finishing a group.""" + Qstr::MP_QSTR_show_group_share_success => obj_fn_kw!(0, new_show_group_share_success).as_obj(), + + /// def show_remaining_shares( + /// *, + /// pages: Iterable[tuple[str, str]], + /// ) -> int: + /// """Shows SLIP39 state after info button is pressed on `confirm_recovery`.""" + Qstr::MP_QSTR_show_remaining_shares => obj_fn_kw!(0, new_show_remaining_shares).as_obj(), + + /// def show_progress( + /// *, + /// title: str, + /// indeterminate: bool = False, + /// description: str = "", + /// ) -> object: + /// """Show progress loader. Please note that the number of lines reserved on screen for + /// description is determined at construction time. If you want multiline descriptions + /// make sure the initial description has at least that amount of lines.""" + Qstr::MP_QSTR_show_progress => obj_fn_kw!(0, new_show_progress).as_obj(), + + /// def show_progress_coinjoin( + /// *, + /// title: str, + /// indeterminate: bool = False, + /// time_ms: int = 0, + /// skip_first_paint: bool = False, + /// ) -> object: + /// """Show progress loader for coinjoin. Returns CANCELLED after a specified time when + /// time_ms timeout is passed.""" + Qstr::MP_QSTR_show_progress_coinjoin => obj_fn_kw!(0, new_show_progress_coinjoin).as_obj(), + + /// def show_homescreen( + /// *, + /// label: str | None, + /// hold: bool, + /// notification: str | None, + /// notification_level: int = 0, + /// skip_first_paint: bool, + /// ) -> CANCELLED: + /// """Idle homescreen.""" + Qstr::MP_QSTR_show_homescreen => obj_fn_kw!(0, new_show_homescreen).as_obj(), + + /// def show_lockscreen( + /// *, + /// label: str | None, + /// bootscreen: bool, + /// skip_first_paint: bool, + /// coinjoin_authorized: bool = False, + /// ) -> CANCELLED: + /// """Homescreen for locked device.""" + Qstr::MP_QSTR_show_lockscreen => obj_fn_kw!(0, new_show_lockscreen).as_obj(), + + /// def confirm_firmware_update( + /// *, + /// description: str, + /// fingerprint: str, + /// ) -> None: + /// """Ask whether to update firmware, optionally show fingerprint. Shared with bootloader.""" + Qstr::MP_QSTR_confirm_firmware_update => obj_fn_kw!(0, new_confirm_firmware_update).as_obj(), + + /// def show_wait_text(/, message: str) -> None: + /// """Show single-line text in the middle of the screen.""" + Qstr::MP_QSTR_show_wait_text => obj_fn_1!(new_show_wait_text).as_obj(), +}; + +#[cfg(test)] +mod tests { + use serde_json; + + use crate::{ + trace::tests::trace, + ui::{component::text::op::OpTextLayout, geometry::Rect, model_mercury::constant}, + }; + + use super::*; + + const SCREEN: Rect = constant::screen().inset(theme::borders()); + + #[test] + fn trace_example_layout() { + let buttons = + Button::cancel_confirm(Button::with_text("Left"), Button::with_text("Right"), false); + + let ops = OpTextLayout::new(theme::TEXT_NORMAL) + .text_normal("Testing text layout, with some text, and some more text. And ") + .text_bold("parameters!"); + let formatted = FormattedText::new(ops); + let mut layout = Dialog::new(formatted, buttons); + layout.place(SCREEN); + + let expected = serde_json::json!({ + "component": "Dialog", + "content": { + "component": "FormattedText", + "text": ["Testing text layout, with", "\n", "some text, and some", "\n", + "more text. And ", "paramet", "-", "\n", "ers!"], + "fits": true, + }, + "controls": { + "component": "FixedHeightBar", + "inner": { + "component": "Split", + "first": { + "component": "Button", + "text": "Left", + }, + "second": { + "component": "Button", + "text": "Right", + }, + }, + }, + }); + + assert_eq!(trace(&layout), expected); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_apple.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_apple.toif new file mode 100644 index 0000000000000000000000000000000000000000..d6c97f897e8e8eeb119cc65065b6955ad799fc6a GIT binary patch literal 697 zcmV;q0!IB*Pf12V06+k(0ssKT(xI;MFcb&yiiwIVDkoWjUz0!KG;HVR4D| zy?)B>bt)@Ump;Qp-ut^q-|NrryoV zhIRY(ql>g>K>|KbQZ@vh`V1SEZ~Jy-4mxl8_R+;2_O{N@n&8hTyoyS3`(r8tes*U+ z)ZLJgm+|lW(A>^A_Gb89JGiTNt9yxk`~STG+B(18yxvaPtCJX@UG#g!>#4;hUPWIC zm0dTR{WPps{yXE{HD5ba&TUnFpZBd_JMe9l$p;)lZw`?Q(&bMXluqT@?b0h#Wt-l6 zyUGTR6DYkhyY?c@7H!PUfPnp{5u?ORCh?;+jA@oup z#bg}9KgFtk39@}7Pt_+9!auvJKK)}K=KbpPuf^Z)-#&-%_qnP+P9XfS*bp^;7s2>b zrsiW0#;?T$tN23;#t(~Cq~cF7l`uY)zOB;1I)|D7k(Jr)PZ^ZobkIEm!-DghUl@5^ z9h3sHgRofV&!>O*d zdNZf^Q@s`k6Mc~1Iw-|1I=f3}=^WjZ1)R#CZ&e@ZO|aLlP30*ztjB#Fd8KIUm-zk3 fw`-?to!4TYelnJ6?Q>7`ZiL^HCgIjRI$Ay=L||efoMgHYJqdR)^iy4cZ;Y zVCQ3RO*PT)R1v=b&O&Llo+_gCr=!=`-|@oC-~gECo8^Fx$!=3ZER1*Xi|rnYp3R`O zCk5`9bHB9iA}g5x%Wk?pR4iz4b{U~b#4-=Uzu#B>3 z)v+R7U$CZwT*w>$8afApDAtvTN85Rvk4q8P&vs6-tpBO%U8$z&wpg@$3H_YMG1Fs3 z#1*Lteq9NYob%Cz9-S$70osg$^}~(v4wpI%uLPUYd3>)jSpP7Lc9&Jm?O#hMy4lk8 z?-z)lf`-`kv2Kh@A%~ zhhaJjQ|kI1k0YyQq!%CWkV*GB6bq#_qqkd|+ytN+oo{%4E=6JuezpTqqSze|Dh+al z_M0uFjaH>OA4XHsE&WD;T%_v-jrBu6T?tp$^|ds)m%G5x9WRcx^EgK9m%E6x=A+;d z;XPji-r1&zGzO870!?Dq@o_}LDbWxUhi?bSJs z=@bguml(s$xog_(fGSwSy$XqeD!LwN(fPX8yfpaJJN|KhOip-GdzH)TSaCjzeYE3U zb`ReA>J0>4{(WvZ76=Yc=3@iFPGPHtD!XUk%RvmWL}BLNj1kDs5x;<^ifAM zg71B63%h7xXqO1%^-6oTbdQxRv1FcxIQfi#Se%jP;_yeqUs5 zJQ@&xSnBkZ2J`tXx~RLvEv&gv5>V9xgZ;AoZIM+nv{Ti#(RV=PUS3z`5}|rVxW<{p zdnII@+>f$9)N#4qr$437{KFgw>vBL5p0270Mz`z20dq~iPdp@cG2e2aj=22|RF7C1 z?16lSMoqaalnlRQ(5L2v1G!vpV(kXM#DlU+>nM~G4|Ugb7*vFW>6zDmYMWUf)Xq@l z;VJIzJgMeh-i6vuBvsF7Hi3QK>?80B>te~CVcI8Z+fXG!^Zm9k*ql)!;+C>&s9rKU aDj^>vlFVnr?Jj|F;J|?c2M!$k9{&N%8GAwi literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_binance.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_binance.toif new file mode 100644 index 0000000000000000000000000000000000000000..8d9ba3649866cd8f60d22e8faed3ad2af8f68c40 GIT binary patch literal 555 zcmV+`0@VFfPf12V06+jA0ssKT(yeX+K@^7Jg&csZ=WMbUNgWIR*G&Z3J=Y7;x-7`nb%JbNFA(dZ0M%6jR96e+dMcpm zlz^(!0$l$SG+%4CANbSxaoCnmD>b_Z>b&c|&3)}o(SCXr1XzIUDnYq#voA?K9$7%w zDFI!_g0wCR)O93K*JXiN7X|uyR-mto0=b?FP<`F2tZE@8K=sX}F{1>yeilS~RF8jk zLkMs^c45JGOVxF^h6J6Es#5~H5w`{j==$XXv2G#|>z4`Sx=tY1-37R=65zV4VDUtq zh6QweclzFULVLlRg+N`OR^2whRKQa=74X$f1ibZM!9r1Z=dSDSfWPh{$hU4Q$iHqQ t;HjGm`06GC-nve}UsnnI)G2|#IuiJ;X9fQ2{{_X=6G3tHcp9@y>pvn~2H^kz literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_bitbucket.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_bitbucket.toif new file mode 100644 index 0000000000000000000000000000000000000000..61c4b67f2fca6df9086f4415bfb37d1933dda611 GIT binary patch literal 865 zcmV-n1D^a;Pf12V06+j$0{{T+(mzZSQ5*;GU%SwEUFf@^=H76*+zpBg=wbp%V@H`F zI*@b%Ct(5;5`@WaNF0nNrs)V1>H>)n$_7lvI20lgM_@7}CMA{x?e!mby?5{Z+Ny8i z_gnkR=i7H*fa_5?Sy(fYOwFztSr}s}N8OGw(^2W0&YZ2%pR85_fV%!9QROcZ*WUqE{%*|mEijen zrgI-^^UPM^=j=KUMMH&OG8%qARpF1&hQCNu_^YJh@5V5{KeDGOW@l)D6Yt>TXtcBlk6!EDrf zQ!+6AB*FNf35)v^Y;qF(jCgMbXnTzn%|p%g-2`Ppf|ovjkzo8J!}wpDzNiJaeg1I_ z;~ya!n1;dlVBDMFkegt|cV&U;U=lo+`g<_c^hG^Px7_7yjQ>E|z$r4Mhb6db!EChk zu4G{RUTFi*NJjT2sJEacfgbQjD8}D6Wd3U{qgN8d0||UB_z-EmdqFV%mb8I}UiGUl zFc{i`;edY}!}z8%F4QMrzUuqGL9hjTfy*?Q4kp1(VFOQ_elUTz1rwaxEgR$iNQnF; zvY_)5cw6u>%DX)`F#ejbfp@e8{VG9N3m$R(5sLA1!Ui5t*Ow*u65-vhCU6b31b#-( z*L_hBr?~z}EQM;|Ni0myLY`ID_X4~YoN}+zV50nc>=ku)rn#f_U8hw)m>}*?@Hiy@ zmW_J>_ba&j+jjTT{?6Rpu56+1=LIb&Yhmw~WLV#upyB%y@OoJD--P<3v|ZnyU^|e2 z*FzfWuO{$5K0#{_mm*>QNvs`T+5*OJ0SWcfU^<;Az_uU@;q%Sz#FzEZ+X5E11b&jG zobD5dT5zBVc$lW``l1%_65JK~?-K3%(jMyG1n-H+?;)M|DO3w;3Hl-;e;mv%e0dAz z^<@yxk)8U72{O7R@o%usd}#}+34T!t57V?$zoiA-9zK=&pAwz>@*bWMncqXY_rqH- rEcF4#*xz}9))o}l0?W#INLM&BZMNCltw~`i4dQH)!ycE*-wEY4mduO`*?zugtAmi_GPcGdp7(G~elnaZRuPHx~` zkhk44=MPsPJ%x`v7C)#Jqk2?bx9(mS7aqY$C&u!hIA?`-{mzr7F}+9hte9Zt(>^Xl zmRSp=Wnan*578$xUbqR@(J*C4^a+i`8C>uxI3Q&5Nbxk!Fj zoaloDwx9Z*B7u2>>4>)M@N*}%{nWF>6YT4V&KlwNT~=F(?WImw-fQroz4GTF{GJjo z<-46HO@p1+G*&vIdH2}HD@7hQ)1ORfc_Nk<-r5()Aw7M;S8v%XJ(YR!e)gNj?%sLoRlD@22i-mEiN2}d!kooVFIXB}oqSdd z(h)7=P5)Dom!2nonbQB~)|G`9?Dv$l{(8<&uNZ5+E8e@oI_iv1S8vrIug&YEzp+&b zRP8-F{nuM>=;VfnJzLJ6zS!RBJ{{4#yLxMU%156!F6B-AgNi!){hY-!c><~0U2#*# z@{6}$NA#@dzc&(jWUgswoA@$!H|`-F(bseT%|1@P^(%D?2O4~K4(q5h68(f0tYrGY z$!4~73FNFJI=y*IUpABY-XYkjrg2P1bb6ok_F3N37vQHlD<0PoEo1-fsTnu#Q*%4- zew~EUSKHU%z>YP!lU6}MO?1==-ZW}3NdFd6NA#@df04e3dg}$-lmSRG zJG=d?96fzAXW)ELrC>Z{>iibS`m3<^vqP{3gKY^kjqp066Mgk%(+D?rf3M9lT(F|( z3$H7#zWv8MY|ovleWmWV0T_w%Mid=UXsy47*8Zty&ZALRBygxq`Jo+6NAyxY>@yi! zS85G{BSrASi>kMr%@elopKbclxKc+VI9eq*y^m@yyVFy4eU|NlRWZscmNgKr>Wc2h z(CKG;`C)DaNx)u#feGHp{&f!%k(cjfZwp8&QFjVAQFv)Rq%JYn!dR zBQd-@63h6r{oUgs`}!9#OuDmj89$nNvfw7Oo;SkG{`LHYtGZ6zZOFZs@q+Uo481;% zlDMA2Bk6D5$yre{w@ThJ@f>u4d_4!TN&58A~8Zb;Tz$UKRvW#v0&J3Rf0X`q?oe=^3eAGW#J9my4!LdO@gzs R27XmZK!c@aYmnzX{|7MWRww`f literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_bitwarden.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_bitwarden.toif new file mode 100644 index 0000000000000000000000000000000000000000..6779de1d2fd2b12359641d0d91fbbc4addbe5aa1 GIT binary patch literal 617 zcmV-v0+#($Pf12V06+j;0ssK*(lKb;KokaGErpa!9x_gC3WXbMHx|0-n=>2b# z?%pTeJ%*w2^N#6qJ^tVbf~>0vo`nX#&z2)}iLC(m%Jn3CEui%V4?#YqbGy{t8gSbu zpQrBD`$BN)Dj;wFN$@AJ%R!KT9j{NIC!pRh4?V=Oh2Vt9&@s-Z~gLtxF*_+!yuquJ;+(SF-XHV9;xFV) zy>1M}G=BUYY_8q>T5GZ-Oh2oDVJv3Z$6&I^~Lp9 zXE2EJ|9qazx1TF&d{=xQg6qCDNBN)5+Q#;?iCygA^d#{isCG2l!ARXOWxS4L{_2*c z2tF7p9KiWN>STZ&|Xf9 zA4q%j1kZ2qq&*?rQUM4QZdU~S+^z4+lGziM#7pl>y(P4>V`tbivGa~>()#V6i;5q?k|$8d${rH z*s7mgKVBcdKV6=@H}6`Ccm=PJi=|nHiABb*V?Eqt?>s0rnw`JoGq>JP)|c{=)a~WT zYX913vd(e)LWWcw`{OY;)&yA}$@&8s;MdWaqYvM&ygL-R!05hLAld(-RDf4IF4Xej z;eDw{uI`Yd;O^;TSJ&oU=KX`0!iuTnLZHkLM_fbgBx*AQAC-0Ci%j?Q+ zS2+MvrY~T1Z6iVU-5Jg)Mtc{onYOVDHK}Mt-y!2SP=T7t4|RDvUbmuemv6RV$hmj+ zFYH*ee?`0ZQFUJ3Dp|AtIqm-aXmtNj*z}MSPf12V06+jN0ssK*lA(^=Fc5~Ng@J}WV30>BEHGGT8otEvpbJ-iir{&R zrDlP7g@J*7gsFRxR;}tu?AUj!N|~V^fB*dR!v)Sh`|Pv-qedYIFl6Oj-pPWI1Bovp zYZw($q7=eN`t!)XLz^c5rD8$ryY_TvT%gQy0ZdD_>fJC0#io!om;m4^D`Lv zbXC5A@$>Gs+W(j5PuEOuZZPCbqexHtIhXI}^baI5{X=G6*Z5bE z;F0-ZL9y?iTA2HdxqM_cAi6#Lw)2 literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_dashlane.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_dashlane.toif new file mode 100644 index 0000000000000000000000000000000000000000..02d618c4068e8d4ba68f6b8af34e395fd775bd6c GIT binary patch literal 2158 zcmV-!2$A#1Ke8pYLCp+0EzK=XnsswYrO}g3uCuZ0)vkdLz9C|MD8YGQV&%FTTsf^E>SJ7ck)uPGa$WhJ> z?gT2}O`z5pM+Ml*m~6^^7qMtC_wIt^6r_{092htZPrzi#-iYs(mp2$pG^nsX^N~|x z`(1p7?&R_~&dOEDF#l~&F~$bB>L8x@OX^jPfwnEV!LRfO>1JW-c^~ak z8BFC3iSpO*F)M!qZ#@R%={fE1^0`c2w&`G1OtP}jKrgrqw7Mxb^&P_6e;a!hv+>E( zmJZ%aIG|D)=|!&J+Ak|UCcSyk8jNF$6_B@`5wTB9sDYG?Y3@d9?7P97)ReFa zvZBEi{c*bwHsvR&96#GY9%p=!l`kRT1_nrJsq^|Rjo_(1hW?psH92~^(#Yn+oiqK$ zK-T|F;jEsdn($-!;Hkq5u8W_J;zgCg3im&B=WAQV6%oLn}Svp_4)nqOaKElV=|2~l2fIiFKu6<(|{TUzH*;M}#Y2SQqCbBwY_(*Hck7VQwJJo7wSGWLPGyrAH+_rAX)#6{Mk2ELw!=#Pau+2v`Z!I z=q4FIB!zF!FA)kum<>>^M(a19w4Ww7VG@v*f>smlVhIX?PkDrNQ33XNIk-V- zX&G5Y@#wpS>6J#^-eWS*fvi(@$j{NYpI@6FnH-iQz6Ul$ zu8|uze!J}K()yq4T7Gg}H_#!9WAVr0Ye3vKI-Y$v(l1>fxW@A9r|4xDmtMTzc)t<5 z=pVQUqYPB#c=6Q+v#_Pv}vUe~^&yLuFET9hh2B~X%kLix-igm?W zuOBpX>diN!55Jrbvps)%cHn|^W(&SU7m9nCfF3-q4>;I^hTeZ^$?+wJmmGc>CSSV& zS%eC3KXN8RpRP~uP47LbJF4~iZ>3%R^zG=1p_4;@108_0|MksaWYJ2yf4ilFbX~fx zM%Q@HdK2ac^%pzJJIaU3K?fj_2fpw{7Pand{+%vLpQcai1<|GHIzbQY9LgTlw^i4H zRJFsDx0gpG1t5!G0baaMqXwh&AiYsc4@KJZ-}Bpw)J2U&jn$1z4|SrGoM7a+)i;|D zU->=gAyOaN3j%sb(}Jke)Ub=xy1_jG?#OelwQ*;!i3UY_5T*wv+DJ9}@%vuL3o1_B k2N&E1-Sli1_pWL8zwe8@;5r!e<-x<(&@b1}MD({mjTZ zn9KKX>#^t>c#E;0i=(NpCD`g{ro0m!UpnvsOUmBE?VgRNN?*}e5`Bf9g04xJsH0i% zSx)gG>zV!ujZ_;?i`|oagPlZcqbGSbp0U`8enBn%d)1s@%FRh`VbjR(w$R7D z)(S0l_xk6fs^uH(;4o#GDy^~LvmhZQ#U_QUC3?VxmAgWfKGNbS_zFE_(Zq^u!4gJ)%zGvhC z4;8l9g=sw8@R1#3U&KUS49~u9!i)4o4E5Wl$!(5|{A}xo+SY}&*PppKTHe9so}Wlj zLziW`r=tmYp?r}g36A_L3rgNc^~lC^OHZk@Y5t0Cj((O!s*WaPE%PgC5@z}-%c;JL zR;G=o!KXyu!K0FYpnqg-j77z+XThG>2fnA}o3eWiGYqJ%*VE@{$M>~;o5PiLVQJfV z=HkG?Tul8$iuh%$qj{IS#%9lW`3ys}jaCSFAv(Q1oB<1)5(t{wUol_a-ADBOI57OijYvH5Tk`PhMaCKV(^A|3k?)1*!3Ho4G=RPn&;sc3%CS zzM`*g|Cyd^2@=-``D1f(8!rd%9eq`NE2UIl7AVDmb+p0_z0I#P44B;tH^E=gg-&*N z33ws#$u2YR_g#t`|F+{_SrBN)##kaf7boXtcjsfTA|@+8*`7&1U5vKPs^sqd@mBF& zk1)hJQj<`5_h_cdUCU1gk6L@j^FaR$yZ6Lo@AWKLKlv7XJkWIi3ca(uKv|~RX9!q2 zTLS-KFuFep!B={%!i5psU@s+RqulWOXAG?bH literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_duo.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_duo.toif new file mode 100644 index 0000000000000000000000000000000000000000..173f1d0372d78c29fcfd69d09cce3412d1b777e0 GIT binary patch literal 463 zcmV;=0WkhlPf12V06+l40RRB)lEG@iKoEu}6v|$6+}p;Wls)b>7jaQMt?3+-jT|G2aNmt+D!5ClOG1VIpgN2-dVWynQta>;Jz zLT;E^=CfqiHwA=T%T!I}${Od+M$zm}`hMiZndyYz&a9Q)Sa|s){BkR1U8nD7@3h?P z`F;QC8KQywjo+5n?sK*}K>3epI*VE6O``$#ge-@?k5&uAzw+=ml^p7ILtm|L0 z>gK(|`Df9GuIry`%zKR)&OeJ@Ssj0X?43%i-)_|L?<`zv%f<)%T%pVSy_`B{)FRNQ+0d0I8$kuc>h4+T6-?mpKt+vAL|b}uC?pn=H9$E z0L(SkFJOr3-B~#QULEw%;aEQaX0tjk|AAqSCD1VNg5rxC<=V9Ccz6Agcc?v&Ju?7ualj%PjaU!I z_@c+_deO(&!-#3rA`Uc{6bx!(tnV)y4Vu<0n~;!ldG{+4V%B7Ls4r!2%f}9_`VArR zlc67bEnw-rFo^=Ud55PxxxqmXhyGb){R#&_? zdpRa$j!t{Ue-&%66AXJ$B(POqZMSiS@pLSq^Cq4VuyDU@nJv4Xxj`ZC> zo@JT3AL||Z@$cm?Z=O{1{SOmJq<23#&9Z8KtSA3(z?R?rZFS$qjh=$6UOfHF=h=Dx zWG2`pee(?}?>{Yg`szxqi$T?+)6lXf$Gn^dr{8L;5`&AJiY?i+)I7O|Q=vtI79FAvbAV6HxQ#iHI(8e`MyHYwjg6aHrUc@lC6KOtM+ z1RvY>eKg@c9on|vp&$751Uzih_jdk1Pe|JILjqs2WNrCN2Al*ES6IdOF#vvC(uVI6 zD9n&H?GGvJO~jh?BL*cIz?3!RPuT#B^_tC@jVljVn=h?;LVR|2F)FO^_zkf~wb~gY3 literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_fastmail.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_fastmail.toif new file mode 100644 index 0000000000000000000000000000000000000000..859d3b6f609a31a78921c933e90d0c21200c7a2d GIT binary patch literal 1185 zcmV;S1YY}8Pf12V06+kh1ONcll0Rr7SscfI^M+i8G%*J*%x$Bo+!4;K2QFl`SQQRD z8*>cV!m&|ib4V<<*@eR*B+FgksIKS8#uIGxq>>kQ^p5Y4GYq6KzMptXILonjJu$MjJLTjbX zN_mifi?-2-YtDMaZKJm+Xz7C6L=M6Hy#$||ZS{;XafZvG1Y_QztF$Lf^Hk=Nws(_I zHqC2-k8n=u#@n|pvz*~>(RvdMAz_lBuWR<88Y8R zCGb)u=u$7XQHXh(cC3a{;I&dvL-aPyPhJ77-8+3T0q#NnpL)1dHy)Dk zRRN!1i*SIC=-)Ou>+Id}KRI z_JcfF^DSLiDQEEk@>l}eX*zu`ksY7q0;!zMKUC-hPB4DV8QZVSQ_kk+0j{lHQn&%E|@E4^KasK0=@D7xc%uD7TY<0UH=Dv{qUU? z-Kqb0{h?B`X=vwC4`=AIfu|_O%PaFZzYBui^BLYIf=h7Rs01SBt((P!#$qss4!J?cO)! zxz~#K35vpNg?#luW@ofRP1;wzZQ;E^dq+)LoF4@88hL(STR(~C;y(3SUK<4L?o_Ee zjY>{Vx@IRKC)4j#9ckIxZ5SpH3sK1pJt~O>`i+LRwd?Hm)~?ZmMrEoVOGW#5DDoO(xJ^`8OMtE=@CeDMY1iwrYAW?>a?;Ku0xuY0j^c&gQI16LSXt9N+4oehW+SnP#?Wd?K!;yaW$f3|gplJ86)kc5-iW+x%?q01A#9G`;BvF_ZD z^HXFq5q|O@_s@5?<00Z3OBRC6bDxQ4_wSw=wmR(oTfMe8XFC&wJ^xo$>YWiRu4!-~H@!0iS^Nr9PZM z0kQd-V%LAuyienPZ1{Wr^8})RP3MKZw{}0f?|nLq`DxpisITntLa%D~KkRy+zAxo$hoAf%X^HsO-CoZNs;eLC-+%Sfzk0d0!BVf!v+FedK;$X@5u(}F z{kuzS`d}hIERLSNn_t297Wv-rQQ3`gbed15X3dwshxrxJEnj8AcV>3>0lR+KeK(wc zn36iHQ_${LL|Sgvifnq@=;a3gygr}yurE4j^J4-{U+C}d=frF=yKnY!757fGU*1}M zr??w$i3j2+dC+>m@^47a%+hBUso8x^;RU}fbEd!Sc>RuRYI@Zv`L_TteKL9O`tSmLk1&m%DOE8_UmpO#yPkvq zkbXzRZ;*ERZ*d!c{c)95KSBsN{WLKF`VC0kFkU2BeVGFkpBKUsd4rB<&%sIppZrC= zZ2HZ;(_bbg@NoT6?W4BU=v9xaH*0s#3`((PFE|5$o6id^TL$(v+Qz8*u*;}J0K9x& zxISWgn!Y!#I_Fro^`{r@vd;hd&)cQs`-w-RUD zxQg6%j#reDfl0f7>KqumqMkEg>Rb8#tD}IuN?a1E=!m)1ewR@S2B991Id=DdeG_vt literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_gandi.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_gandi.toif new file mode 100644 index 0000000000000000000000000000000000000000..19c8063166b2d24d58caa0dcde3e01b37a83fcbb GIT binary patch literal 1237 zcmV;`1Sb9fWi!n4v=khZ{QF;E;cdwaIC$ zwMqPqckkWvzR&vr@H-gLgdXf1@8r;xkTUyaviCYjdVbl@pu z&RtCzaqVE14{OHR%d83A(+{N0UblnG)1$QELIW3r?&&`YhW^=bmG*Og60J^UOhw(( z&+=xc2lVn(Gi{}T-Av$hOf@C%vebkO@UUIUyp>nlJ$(=BHDyhGPheMC%ybja{Rrir zJ_9-x{Toz$&N-=83{G;LN}M-5&G^ZANxD)Vt}2^w&i$*vf)-+asXphzpKjldi^0|T zh!c^udQUN`Ug@s1%!-I6x!-hfLhSl%xblDjcL^&GuEy;z^%Qm7GHx2!7u{b!kS6~> zZW-R5`=|A=rheYYxTj2VgRdP$C|FR5w>PEz^~=I~wx)HX0|t25`^2O0r%)DrQA7Jf z{ZWuH`F2|0e*?Z~1Fv^*+!xQKZ*{ns>bmS=!Hd{Ju;b|a#=ZT00Ata7l6#q2KOg!(dx(|QB{ zTwBI}A2NR6AQ9?1Mi1p5_0Us}(kTi!P9EMT{yu!~gNXiBJS=bt=G!#NxGHHO2Dqnw zln!gw+Nypwyh}tV*TmlwFv(eauj(}=kj_H@+*P+upI1Y<0S#OxkhYq=fsb1mKX4r_ zWVQF2;_k**G$B3Q-1%|K!-9j5<<$+}VIf5wM!gAZ%DQ@-XEy3mR1-F#^IeTNb)5n@HtZ=qg&uY)zNm9uFyORBPXFJ7`F93TQQViYxkAQW=_$lGSuW} zd)F}|bu~rbL8oGO2a_DB-wv*BPS_;cr61PpI)Jw74`{DOrv7YrJ6_L2oVTZ67Pk4% zfN9;Fwxe{4%EOxyssC1YHh;*JKM&iLmbYGQ&M}{7h5z3B!G1wq7P7hP2|MKuI zfpT%)*m55Bleo|YcYboq%OCS_d;NTEU#Bg`UI4&FOvYpe{}i^( zl<|{p>H4E!Q~rIU^pN~h#(xT%TV;S2x*e014cra_+l)BUfe#O=Qaflk8=*4(o05q1 zHL8-m?Jr4DRY~7KL{$mp0_n1FQ+nUWQNSgduq^Zxb<5l@Ggxri z_e}=$oI7p3%#ujP>bt6p!&*&wtB<$=&49zefIQ6jspv(_7u7;1Y>GORveWzzxBq(+ literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_gemini.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_gemini.toif new file mode 100644 index 0000000000000000000000000000000000000000..10e81eab143d6600e33be76b28efe226609ea274 GIT binary patch literal 1042 zcmV+t1nv7&Pf12V06+i+1ONctk}GiINDzkGaHdK`_>812je+s1@8Dq~ORr#)mfJNPRr ze+IMm)rc@{F3lHGCS7B}PR;^HxY%=xM^+BI0hfHc_dk544@gh=aX?mj$hT+oxm5yv zo}fE8WcPdCy6_ttB_?PaMJG6`%WmJWRqr2p-v_;}>Jai`cbA<;NNBpMCiRpF^84U3 zk(+d|TT5TrCAOm43(3d7GvXc|nzd}T-I~uwf732-H95+~-akXC0 zr(a8W*Fh`y^bYoO`p1QlP+*c#L7&H!Uvm2wV94SwZ6iya1%bG|n;#GNIs3ekc`a8oN=VIzHDaOAMN}i-}K!4-=NA|=$;aeL!h0+$Cp3Ot# z22ALQ5s262r;YpB8auC${GQGp{tH=}5mukO_n)#ung=*KkU@%9(n-%B{AYpBw|OUq z#>T`sqSmF?k_&3^o|OXF)S?*Gb?f0Daja##6R9zPV63#xv< zW$Nb1f93IKAU&e)_ZN|_b8tYm>&oI!#g=WweW$QC zird?0DZaNA*X^z>gMUHgmKu}aCsb@Gq#`eS6C*p?=7r0=}!b?tA)MlvlRP6Xoz{FlFlI5w;C$t0olF-H@{Q4vvY& zPK+SIj_`?FA8MY3`#c=&P3e(QidVZbz*}}`=4x|kK9Ix8R+pWPvl>TlRz(IF@+;an zWEtiD_GZ!|DV6ka&Tko^JvbgAkE?D76pAs4JI#6es#g|QX{;E3rN^D789H6ixGLnu z_)^KY3`)8X2)Eo9px-NftW1?{;lN>m3k<~_{fs(J#04De=cM<+!MID4W~)5~ThzAd z`S`7R++l5IuhxWi*{A;=O)BdrIqDm*Hsg8^Ka*_L-I^!p*G4}59!Ma1*x1c5L6^wu z1(_<7ajV?GOMQhVmAgl%F(?o|mz}=hS-pSM^R0TI(9h~nC@lEJh?IUK9J1M-w=R5P zBUE3{c>{`u1y4LmbPX`%D}6wEKVI};KvsIl15Ca(U-)rL! MK4dS%f}bP)1HK0b^#A|> literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_github.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_github.toif new file mode 100644 index 0000000000000000000000000000000000000000..7092c7eaf0fc64b8365a1985ab1ae8b52375aa66 GIT binary patch literal 1092 zcmV-K1iSlGPf12V06+jZ1ONc-k|BsHYZS*PY?xq}V3=@&f?)>?3L6~QqF}fK6V|3+ zxQIapgUAaaQ$#k17>416MGOmWSa=(R-5_WYG%U;wf`$bRh6x+K@WK~1d~fCAb(}fQ zI{FDO+=-=AUr$g2WOUMNVi^0Ug-x%$tltQ4fY5I8$G&VH9Py<#j0q$Q~s zn*Lpiv*%!&qQBe0)|~qsB!jd5mIW}H&jvD(DZPQO@x9qF3!f;8W#$(iEvKS(}s#< zWp|>~w0o+k#zA~dcfQ5$=3Pa_>8k0@z7anJa^}mO%J=1*u0uR>q%cy4{<3(M_Y)GS z?{)>4EQ0!=OL-IYtAu1xV5h4f!CCIlPUZJ!uyaAOc3pMhuBY;;{HHSPeATsfso?eJ z1BQa;1ISxN+m72PykkKOc?6;F%5;vs+hJ6#_OXLX1fiW*`k;bXC>c7W=&Nt1tVItiWMFNCghem)^?d==5; zLl<`E6B1`>(c>&kaNjNzq{UAPkpK4oKNe8^HA9R4nt}X37MnUAK=qzZi{H~Be>~XK zr){WyZPMajn~?vsy{XFzoR3C|2ECDj-DPFdaT|p@?q-3L=>g_$2D{%zw_PfNdr;FX;h@L<1V z7TJ>(satbD5nJVNrgh@w`7!l_J%n82UNdtS_F9p;eQLxH@utdsR!z|quWYGUlGs?& zW#u9WaYMmYdD^ZROY)V5c*HyBOU-QuKdx~X!ASkDf1K0(8GMHy?AwAIq*4>Un`+5G zY`p%VM#`Cs-Q*ma;;mFv6mJyH?Vq>iW{B@U_mq{V zoh16ZZv4NWzoO3vZu2AF`hxWZl0sfE#iue?ziAeg_sqWA{ZdbNr)LqAJgZD`d+!eZ zmGsL*Beml1W`WO)CIz?Yo2DCmN!SGQrF@nYye&dO#)I7XzT9ChMa7zeNd3aHL%?-X zbGd&}Is6^6vOD|DjE(1mw2?(O&Exp8!gx|6)xnUDrhDfPaU;ceR#NECXz=^9yuci# zI9qC-%GdNb%bcKxXl#5eKQ?}dG89yhwmZ1p7r;80r(km=Dm6E z`|`}ws#o0Ks1k}Rm^}`9-UTx1EP6! zTIkGsdHUx)Rvz5|ofbM*_j&rS?pb*>11wsYm~t%It{n67NCRwI`3&&$NCRwI`3#8V z@o42SAe#5k@Aw+`NOS9V_9M;tX&wKeAMdwj4m1MZk^HKc{YXAP&4Jb|YS(3HD{YMf zFQ{j3uY3F?1EM}%4*OsG=-=;$`-0nb*8bZymn6h(ul_%UttqqGB;L*2XyvyvOesvuGCT%rX%mACd z29r(d81=iGo}e$0?PEUo^qAfBHac+wYl8kd`d=qV zZ=Bf$!ZT4S2@h^;4@mm=-bfUv|HdyN0fhg%(OlC&{)3ftL;)6f#Ikj-sJMN;{~uAS=}w zCCyNX#)I5hIJP2vy8qRC<9+x2-uK=EFd8hbC8^B%{bE_HOAXhbQbT$oRjiHH(Q2GrIEfXLe<=%=!)W-IFcZr>iM7g!B5hU98%xt5c{Yb?f!< z>DV*Qw*wS^8mG?X#;MnmdT$l0T&}h%oL|PQe;KdM?<6KRvL#o`ijF-}LtB;_l&}^i z+<Y2CAv5bMTuVO~owmY1>4%Mc|P(*gE0_MXdupoYC+QWLMb7$5 z+*N3XiQRvpXM3#qBEk#A?*Hw)Kj|H}Ki>YkyyM+JW&Tj<5+|_Ww0~8(0k?@0NIUD# za#*>6eMs_Q-&y}bT)hF`8?M*d;VZ1BpGEV*(fM73$b@se}7!-#`E2 zO}m$_8MFS`=n}E}d+6j`>cZ|Hmp&P;t0@M|=o>+@{z0_=>?BRr)eOyGgL-m=zQo<| z_MZ;ANBcYzrmIM=orZ2?l>TFU@SkgxRs~ba99T^o!x`Te{r0>+v zLo1l&L}L9RN^b@F^1sC^1O8la)a(##Zw2o$U-s$mM@Mf@3a;B+!Mn^4ujlX}p4Qyo z0_1G2fM(r3+J*^hx>vfu*RTk0^7KNYJ}puq6s02I?{Wa)kMX+Oo+$4h&oF5T&&d pQ_8M!J^`GI0zC4Hfv>1Aoulh?C)SD8=>&a9g=RS4iW?ie?G^eaGtdA4 literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_invity.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_invity.toif new file mode 100644 index 0000000000000000000000000000000000000000..d13db26677984936a8e50c9699d1325b8392d4c3 GIT binary patch literal 136 zcmV;30C)dXPf12V06+kI0002((#Z{gAP_)N81P89)<6ZeITd*EC@u-IsEPhRSD9x} zV~B`|S_*C+&L=p0eEj;-Z7QX2@-08V4IdxB-dEY4&rNTCe;)r|{@edww84%3s(#$w q>K`lHhL4Ybm#>n0TBWx?`E^^g&--QdzkmFa`2P6ms2Cz5qD>17q(kZe literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_keeper.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_keeper.toif new file mode 100644 index 0000000000000000000000000000000000000000..e15958b4f9fc97579dea8a9ec1474a59682eeeff GIT binary patch literal 1507 zcmV<91swWRPf12V06+lO1polul0Rr0NfgGXIJhlRY>`6c7;@n4PUUthaVb`i!dq@N z-oyt|D$As?njs1*ER*6C+$hDIt+)uA6hZE`7>K}yEv`6J3I>ACg%K$Zr8GCQf25VP zD~%la%-u{ivv2nI-uHa~tiC>HiiMARyN93N{PyPOS5KaA=8skIFRdL-ef8qQ(b>`2 z!*{!fU#nj$TfC1y-@e}alDTX4w({h8z}`K)c8Z1Ry;<1Na?0J&k5%Gp1!vpZOp4v%bxNbsIZyeUaJc^ z<@Mgt*^3VszTav3KH50fPqRus$N^j<|FKpqy!h~OugIr=tfkj|fue&zgL#c^zpnUS zcMpq&3%gi=OF9)UgdQmCp>Hcn{z@0@#nT7tT(5-+EU%SYUoz*q|3{wu+|rqj)pPwy z7kniT*W~_b*5v;?unsJl`da9KSvXeL#V`7NvmmEeP`|DWPWVMBKI9n)_~-h4@Pz{b ze=~m{`U7x!C;a#S8Gh}a`8!&~hyMfqj#kT>EAbzPe6x^KZqGFo!vQVY%)7t1KeYyp zzZ$<*unsh=8p>VsvC7J@hX>#WCT{SJf9nzty0{K1xWr7PQwWuH@R4d%Q6CS)hBvEi zI^tb>+wL=W(5zK|eEz$7i`jDl`!@W9j)tIXv{9XuNY0Q_gRGSw-$<9?Q&XPLMx=uq zY@e2C8CKZ|=z;~+c&&Ww0c&1So*tWaQeNa__)Lc1oBWGpsA<^9fi~8W8qHwz=U)*i zKCqVYK`WRWscawiW*oUBCGpizpgWgEQw9&0@M$xs!wwy$=sWzNS zjRM>yS+DadfW0Z*EE&Gxr1SAWI1B-5Pbo4a9`;awz5|kBqgDB60U!E*ATeFh0NOB0 zVos!vbVxH$hi&o8O6;{Q!^fqFPG?Ty>DHi$8jlI8NQLoCqv(xtuf!v9_-D~zxiD@;*Qhiv}Q~B^(6g~?>Dd_HNu?FxQ{(? zOT+ZB5mu*asxo{u;SXc;`^lbU2`2JIMj?pOhw4)=ZVV|tZY1%!vLK!;<{z;)mC#EN zujv`w3w9af*Jb!5kii(U9S@6h3WErt{C`uwRpw*%nmz@U1BwbCFi#3`y}p zD}_H4enP+}jr4S4vU*GyP5g(6EErAn^^7;?s@{KXMxO zum@c3vx`00TbxQ>DY4k4Jb#sq2DE%1d@?lJoU8%KMM`K5d^|v&QHK_TVD+V;_Ue2x zq#e+LRl`O)QowgE>=^w;d^`YM)TT{XMGnmY&G>HO?4lXaLVegH9iz=oa2-@|30Q=) JunN~k`3E)f1?vC+ literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_kraken.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_kraken.toif new file mode 100644 index 0000000000000000000000000000000000000000..9aaacd6e1f6b48b0acc45421a0dd50b0abb07a7f GIT binary patch literal 654 zcmV;90&)FRPf12V06+kO0ssK*k}1HO1i8WtL6*(YPzi5z)VkY9rXUV5EEp| ze#7hG_s1(==ex8nx~zaT6o%{{AN_?-@+pAj`z=NoppYzUNe%Gm+E-S$&e$JcV zFZ?5Yj+&s$vZ3U=v?+c@BtPX%@k0i{SJ)K2Ki+TvV%!|RKOVxa(uMJY-A1 z5ZCjYu^BsD^&G^4g)Fbwv97c7^{+XPFnCu0Lsr#yOO753^eQuSsdY4gH8IU8s6N?2 z3?aDC!Z&qZlS_~J63O!u%AkDlds!`-IncJJLl^>|dj zo9fAkzGXVUoW9gjn=&J+N8f=FRXq{bH%;dUcktI5U*7<)<+uEn-|}02%Wr@`x`Y3V z@0AG(VXMbL$kcNYRy|vydU}rPnIP4(^hZ?vqTxZN^gakn^9-Tddye!@YTmm+05{^Y z^TcopfK}-Qw#4LdOE(eBWnsyCS67~5ApHjHtC^$ot1^=A(gJh?$@)#`uHZ|QzkPyhe` literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_login.gov.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_login.gov.toif new file mode 100644 index 0000000000000000000000000000000000000000..fc02cd70d467b85c2adf9a3c3d3f5f64c1e6e36a GIT binary patch literal 651 zcmV;60(AXUPf12V06+kL0ssK*(!XmHVHm*iXFPDZK{tn-Ae>tkAq9sdf@DmGN^ok7 zln7Ni2zF6I2%-TcgBZc(Xt7PCV&T`QfZ@Y^vFitCGvm0A`+Ue z=T=E=$NqSxy&xKLqC5BZE0eb%S19|+_zv*#&h%OQdI8T1LHho91K6&F82HM$|Nr>0 zy>s7B|Aqdn>W{xXD4I{vNl(y1pR2L-IsNkp{Z$vWG(1hswFMam&KL3BC_s-Y*GkWo%y}pIiDhuD}6-_(8rIF}f z_@X{%*}+DKO!vY!dXFuCV4kV}!SelY`s{p8?^?G1<&d3!;j8ufy6EaUD?dI&HeIW+ z@?Vea{D&GVzirz28y!~uWjh`nePiQ`x-E}x9I^422ME)xDhq#oi1YI`7QQ*c`7Mow zKX;7tFZNmZAN7O5r{SZKIl65Y&4vE;zSf!VywdWuC71gb0wn&~N$-)}{OQysm{3`v z?Pn8{{Ah@>F98P=lM6KcWEiH!t4(r0=EyH)fS_oY+;23=^&JU#AnM)<4L>3Raq?TQ z{bUTB#FZ;v`q@Qr8rM*~^0R4}C9bS^;m`2xe_kLm3`-?W-tf~eWg;T@(9O6kl~x5;_7A&cR+#_* literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_microsoft.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_microsoft.toif new file mode 100644 index 0000000000000000000000000000000000000000..fd54d3ec5c1eacafb994f32ae0c9b66bbf11a0af GIT binary patch literal 165 zcmWIX_jGe$aA26pz`*eKk|Eb21BteWOB5{9dZKS1G``X_!Dp|VU%-mR4-_gUOo>xT zFA~2Z^Dz2%-Frs4A3vkF^JN{JeSeep+&hJ`ars;8R{o2S%UXH(I1;$uE?={Mg>CzO zdB4<<)92pQ%$_N|b@STq`={)V%2ThlU2)d;@BZkYE0%WjBZ2+=_Oni|%3b!$&eife FD*y@aRto?C literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_mojeid.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_mojeid.toif new file mode 100644 index 0000000000000000000000000000000000000000..f08b3544ccb0c70639fa99bef039035431d0771f GIT binary patch literal 1330 zcmV-21G&fmC8z_S3>cv^gboNYR8EI9K{illptu9V8H|mAq8q@u zAv!#y`0D5Laev;kITF(@{{F`Mz5BdBKLF~fr=I^e5U?}|!XWhet^d95cQWZffi9Kl zg6$*qd|c?}jn+tFc%2VgJha7#&eGHcT~I1e071NKcwM}4HuCS^u6nP#c_yvarGl-s z@TIu8pNFd4gcsN8LNru6ALUb~(mnW}k$m@lZGDu7>*IvkW@kx0ldipwZs9TZu52I4 z_eXBS7sbWA#iR3W6s89XZr|EIP<2oNr+D6`pk0ye=gONnJ z;RTv|b+M<_d}J@?7x__s)obkTnyJM+X$S9|H}%n}CA*tTgU0eHlsMekx`zN>$gHeb+@AybJ&_I#=Fu`wJ8RdU0o?5o&lTQ*4N`4e(2dRBZ z_Yc!H!0E2nzt$hD#ee=>z@PAsf7xX%{##n*j z@Il;fEX|)}%leXFPHR_2VR&0_hjYLSZj|3%;XWS(;AmO0Wqn-dXFFp#y*~ElCMQRs zEsJ^FU-e49S|1mV50rIq!i3#BoPfpu?Jey1jQbt^A|YEg0AkB!yR7#Jn`Pf^p(A?x zNXT{q&`iBfisG4JAA(c{*XNVyTWndY%RXa`{fXWCDRcnP;*svsxc^s>bo$O|*ugqyCiRki?6RThZ5WNI5yu?ZWs$wk2??*G;=j@wIr> zNsRf;l#3(jwz%{?98z?<&?K01$A6P_zXbs6r1}p={3!=n7scLosbegp)$PL3B{uW> zj8%JfWB$vbjXYUzi_1M@A+=sSeNor?fm8JE9sT-$RP!M?^UWv4iuuJMcLqYzt0UW- z+brp7KPJH9oN!KjhWOx=122oGqrY*bn-{C(?xeC)X)KJrB^>U=M??QkH}rM@?BgrrJWQ{2J-QA<2({4-c{E@^vC#VqftXz>% literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_namecheap.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_namecheap.toif new file mode 100644 index 0000000000000000000000000000000000000000..23de6ffcbf2173c9910a090ae3a17bfca2aeede4 GIT binary patch literal 867 zcmV-p1DyO+Pf12V06+j&0{{T+lD|*eKpe#n8YISy9j`zo#*FP2go@IQ{s&G|C>^_% zEpC2PUD?d!1Q4Pd?L@mcA#EjhDokdIuSiJ8cIjkgs4s5F`TRZG39H{*&-Xs>oe|2E zDO09QnKFg7^!$5!`&U!)#(90uEzEG;A}U*_$hOXkdJ!ib#`)Yy!}&1S8A2`%8++4* zYpeL8>W!^2^d_FQa$0w`d><C=n(BAKKIMrHN+`M1|@&pmYN&can`s3KTe zOPgOca__g#RuHz5K8 zZvT?hXpN2-=(5ky9NcGtBIfCV!}(?lI$z#xvo<@R6NXp!TEH|+Pqgp{=urDM>9Q^p z87g%41vG53aK1oLh@RJX2_vlg-(!YDI_m;*`!Rv(_0NE}%6BNwk21`*0B>)3ChRA` zpQ^i_%Ul*^sMw{y(=T8emKRt~SGD)^J&3{GtnW>`fQBtL&W|vt+D|MnytMs44C%faC?jLlTd!po~x01 z0S$MU>-9Z|0d7Az3AYaxSRJYe=GVgQ$KT5g=quFTq@!iLD8;0G`UN!X|Gh#7kpONl zyiSNgv%@FTE#Ml_R}5}MpTYJ_`a>q(lusuwAh#D8p!S+>c~SFpxqZ3?ItF1bgBalU zRc3nJ0K+B%6u*FmyOc2QC;g|aMwf9DCR03$Z69TT+m9C5SLHV^ z+qBKP3}9f%?L`Jv`_Teey+H2vz$b_@%z^DSR`nDFB^0A0ELE6DA0PXdv z7g$cmHj0&wOUea$wBnmnOc>@qLAT(malXtD-#&hUpMzzxe#a2JMzsLlA*PEIN$rys zI3g=VWFRN-9sKp7{N(mY3tYQ7lDlJo+auKPKnoxnrBd7iTcqcX8Q!;4H}o|qKVAEz z1?tYB({rwU3ay~Np??ILrGG)pn=^rnXtwI0* literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_proton.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_proton.toif new file mode 100644 index 0000000000000000000000000000000000000000..478a38a570fc20aacb10a25c17151e0b35fd3006 GIT binary patch literal 587 zcmV-R0<`^9Pf12V06+jg0ssK*(y?ySKp4RBFPj&L)h;L1kxaUnskZ21iJWA?;_)Op zxC1AhaDjV(K0+LJB6x)O1se4g(uFb2Go%t7Nf;oiP#SXA>Kyw!3Gw}R|Lo5nz2ui;17g!(brsn4xts@feJ)4D zuHO@{9h*LvQ)btH{0ek_=|BGOx=umD*b@)=o@h1Z|Cy%|t-OIce1P25Z()%&?*qJZ zd)hb1-R6COiLdk95rz*iX6k2+Qyjn4Y@j1>{1#BnALu$5KEO3Iy}*gT$W#qT75^et z`+XNsM&}m<$2Z@9ozsH1#`jm}OnkLY1FS9-|({F0~^Hdx~EifIg zmfatmwxh0NjPHDTJuzlsTo9-@!&LX&8GDn=gM##3UAbH3i#JDg`Il@z{uX{9(D>x; zw!IhMhPUBI1RbE<`1>7kob1J`zK4J^^I)wnvlq#JeAg!*+h1nK$+|Na*Z0Ipaz9+3 z%JcMOBV7NIou`{&`W^8l{k!)B&!4fcTNP+N{k{F|@O)3qvh4ekQ5SZB%~0r2$CX*ML-L|S6t9Sh>froflU$c76C0REK+P?u|-c@s0+1EP?+gI42A05~juoNwI&u*#Sgd_s2oUCYTw%2D$`u%o}uKWYNR4bNpk zWv_1L%L+i!e5wRfJARwr8I&@mjJNhVepKuXMu3VN$HIT{eZ*VqS{Plm0utjd^cgQPxaD+2t!XZ(F#R%GSJ<>&Zj4DdS4dl-K-H_T>Qv2P<2ynRXFZO+7d z%;)R;yr-0*dfYfGvW>z`V%856ax6Sv?9XnWDp8Te%gT8(YuvQhj84R#wZAiX>W95G z%-GdO``@G6H7Eb5S+%xlNfY;YA>8+Es%m|2e%HEl@PF1|%Q{tf{!3!p{HF@fAJeY2 zI-{IvTXpGnZ;~}`2i28-B5VBJ`8&&zuS4Qc+*po~xeoPcTC-oZ?gfc??>wc<;^lok zAo=G0yeFn5O=qR68Pr2O|M6lI9~DfY_on4@c%x!uf_|zW2)zF<|Lbrw|M7y4uZKo! z_@_!_f|k|@@e!=)c0kCEe_DAS#*LVUe+m-**%{yzf0dtyStG4sj{Pv3>0A7`;jC|O zvdV9$cF!@QGr(yEq{yGh>oWS=Er8K1-mVy6I%l3YH}SjHj-TiMD!|B!O=`7|zcplmtWz(@MKEgV-iO=?T(c{s(_^-o&6w`K7SR)}nE*ISp z=6<%r47@_Fdv6ChdRJ$!(An8o{Mz2|eD{PCAEwx97A;yW>d6Oq63 zTrTcBmksr>xSoGqJ`wxjV2aO|v93b(&`ToD`G4OxRNkDMRLb!Dei+`V(Fgr+iRwJx rNA@PLRdp@4MYf(Quw?}#o*&a({{P9Bm3C{pb`>J7T)A@P>Raj`oFNvc literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_stripe.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_stripe.toif new file mode 100644 index 0000000000000000000000000000000000000000..a198d7a381dfdbaf898a6f95ae2ce5258b673af2 GIT binary patch literal 668 zcmV;N0%QGDPf12V06+kc0ssK*lA&VTFc5~1O;BtKtDvBu%_=rQ1ubF|YuE}?XjVhR zz=drveFQ_Zx^J*R4=shWW1p>LUe>hBLi8VqqO3)EqprD|jprD|jQ^aAz$mBBY zN^Z^4EuB&4x?9dx^w0U0eja`y%U$(CUWSjgkMT?MWMq899k~quU&)a|t~Il4S{kV| z=N`W_G^TOW#auKi8atwSrZcU`H`(rJK4cU6oqbl%W?NS}^o}D{R2pWRnqT_dTrAvj zFH!;UZ2XfKX$*4`18l?cJJI*G0^W1IO#68VN16>iH|YPKaA#W+FOq2*805s zndQ5e$Gb1;i7vbxu5T5GD@f>EthLWKDra?2pHP}qq~aRJ_zQP09Led=zW;e$t%}+; zvyK2L@_vdr{A!I4)d~5H^I8oP#+@<#TDQ4R{f)Ca%k0ogBh9;8>!Ht?lktZAosWd$ zd;p_xc8VaQl5^2SB{4_%{=BxP?cweGRNzEbble+F00=&_`+I`Fp8wcS2WNZd@}qnJ z4)>z;{2tq3VyE&SdVV3%ttcLG?BK*w$}YNq;A@FX!(Eem%w+=g-`V0x0s)zMUUgN7`>4VRnq4 z3crIpcdL@_L_(kX%(Hp%{HH@FnMQge4nLyBUT;O`ZACUrbImvG`oNtls=44fZO>y2 z72CKvaA&eR<+D*&kkbz}iA<7_>|6Bv>-=8@{-S1A(YwiI_>MqOP*6}%P*70N3-k-) CW>+Wx literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_tutanota.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_tutanota.toif new file mode 100644 index 0000000000000000000000000000000000000000..80edb2ca31226a61fd63b70c4c4dfbbe027d1309 GIT binary patch literal 646 zcmV;10(t#ZPf12V06+kG0ssK*(y@!$PyomAq#)Gc;0_!doE(HJWDr_#uy9<#vCxAK zHv|qG?yw+-kWvaM89GFSD{(k5!yClOH{6h|mob7v9~2Jb5O6bE5T4M3S8!>gw6?w4 zOJZKKB;P;a^UHgWVVsXWV9y^&;l-Y_2h2le_3eghuQxEe%YQ$MLSHU8`@t8Y(w&$B zdlIG3p0IBs^&{2e;^N}s;)3XzG?t@Xkd-PbY)8A{O5GZ9a(k}iv*AiPW4I(J$YaKYAkF(R`J-fkIf;vHRjhK)K`T*x9Ub|F&g0D+oE2$v)-6-Li-P&T1@z{P(hDyICOOrcP(UXU&c8Bp@2^vC($x1M(UL@4xck15yk87OI*TU&KMb zM0MoF2V`M)ef)vH^*rN9q$4?uq}|cZS+Vng9R* literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/model_mercury/res/fido/icon_webauthn.toif b/core/embed/rust/src/ui/model_mercury/res/fido/icon_webauthn.toif new file mode 100644 index 0000000000000000000000000000000000000000..58817cf1dc458c9b51f7eaf32e2aed084547244a GIT binary patch literal 1135 zcmV-#1d#hwPf12V06+j^1ONcFQX!1$N))ZAK#Zt(q5@M_Q9+XxkL0PkPJ;MN5Hvwm zHDpzTAY>gukRQa$=8KmNf@Bd*K>TD^kbp$?i%nQT0s>J{QSn6uVm?E6|FZjNdk5&b z24-&WxpU6pI5zN-U)Hf}1SYbSw?bB6x`vMZGB0ttVWiqPFcmAk0@KDL^}5VkQP#4V zKW`yxTk#s)yrg4fDQ6V2bbP5FvUVQ##}2Y~E_YXT4aK^=T%&tN9tsz}{Id!LCKZN) zjTP&wSJvd$JO!cY_*Q}sfR)b>H#ku^XnMOK8Pq&&6_yK=M$&q(eptB-9X15 z>{}7``o2TiH{|8v)a}c|=CDEEfhn*RS-o$0l>O&@(*4hSx080a$(3qTUix^OEjs9=*@b$hn2y7*^}P z^YyOpQ0M7($Wn5w)?tHrBrd}C`&rl)7xRdow>AfAd3HO$nz8X zw7_Kh;J)w2Q}p`|Y^q=8 zC7A0mdv?O2S-b&E176}dNCIPRZypgK(SJSB-j)YRAGe2{nSh(e`0X%F(VGVUK4f*; znDUR*^sY){uuu1HjTmxGb8r`Cb&XW+fOH3%WBQs>M`7UqJhcH#OtuyL01pHa}z)woe==9AD>-K+TE8eNq;6Y_;BAWPP`i@-}dMrU&C z7@D|rY(-fMOcw6Il(ntMT@@qM#@Sm0rj17`mm7G=FYDMf0uvo69hk16W53L<_kX8I BN^}4K literal 0 HcmV?d00001 diff --git a/core/embed/rust/src/ui/shape/model/model_mercury.rs b/core/embed/rust/src/ui/shape/model/model_mercury.rs new file mode 100644 index 000000000..02841b8dd --- /dev/null +++ b/core/embed/rust/src/ui/shape/model/model_mercury.rs @@ -0,0 +1,77 @@ +use crate::ui::{ + canvas::{BasicCanvas, Viewport}, + display::Color, + geometry::{Offset, Rect}, + shape::{DrawingCache, ProgressiveRenderer}, +}; + +use crate::trezorhal::bitmap::{BitmapView, Dma2d}; + +use static_alloc::Bump; + +pub fn render_on_display(clip: Option, bg_color: Option, mut func: F) +where + F: FnMut(&mut ProgressiveRenderer, DisplayModelMercury>), +{ + #[link_section = ".no_dma_buffers"] + static mut BUMP_A: Bump<[u8; 40 * 1024]> = Bump::uninit(); + + #[link_section = ".buf"] + static mut BUMP_B: Bump<[u8; 16 * 1024]> = Bump::uninit(); + + let bump_a = unsafe { &mut *core::ptr::addr_of_mut!(BUMP_A) }; + let bump_b = unsafe { &mut *core::ptr::addr_of_mut!(BUMP_B) }; + { + let cache = DrawingCache::new(bump_a, bump_b); + let mut canvas = DisplayModelMercury::acquire().unwrap(); + + if let Some(clip) = clip { + canvas.set_viewport(Viewport::new(clip)); + } + + let mut target = ProgressiveRenderer::new(&mut canvas, bg_color, &cache, bump_a, 30); + + func(&mut target); + + target.render(16); + } + bump_a.reset(); + bump_b.reset(); +} + +pub struct DisplayModelMercury { + size: Offset, + viewport: Viewport, +} + +impl DisplayModelMercury { + pub fn acquire() -> Option { + let size = Offset::new(240, 240); // TODO + let viewport = Viewport::from_size(size); + Some(Self { size, viewport }) + } +} + +impl BasicCanvas for DisplayModelMercury { + fn viewport(&self) -> Viewport { + self.viewport + } + + fn set_viewport(&mut self, viewport: Viewport) { + self.viewport = viewport.absolute_clip(self.bounds()); + } + + fn size(&self) -> Offset { + self.size + } + + fn fill_rect(&mut self, r: Rect, color: Color) { + let r = r.translate(self.viewport.origin); + Dma2d::wnd565_fill(r, self.viewport.clip, color); + } + + fn draw_bitmap(&mut self, r: Rect, bitmap: BitmapView) { + let r = r.translate(self.viewport.origin); + Dma2d::wnd565_copy(r, self.viewport.clip, &bitmap); + } +} diff --git a/core/mocks/generated/trezorui2.pyi b/core/mocks/generated/trezorui2.pyi index 00e6279a9..45d31e6be 100644 --- a/core/mocks/generated/trezorui2.pyi +++ b/core/mocks/generated/trezorui2.pyi @@ -4,6 +4,468 @@ CANCELLED: UiResult INFO: UiResult +# rust/src/ui/model_mercury/layout.rs +def disable_animation(disable: bool) -> None: + """Disable animations, debug builds only.""" + + +# rust/src/ui/model_mercury/layout.rs +def check_homescreen_format(data: bytes) -> bool: + """Check homescreen format and dimensions.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_action( + *, + title: str, + action: str | None, + description: str | None, + verb: str | None = None, + verb_cancel: str | None = None, + hold: bool = False, + hold_danger: bool = False, + reverse: bool = False, +) -> object: + """Confirm action.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_emphasized( + *, + title: str, + items: Iterable[str | tuple[bool, str]], + verb: str | None = None, +) -> object: + """Confirm formatted text that has been pre-split in python. For tuples + the first component is a bool indicating whether this part is emphasized.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_homescreen( + *, + title: str, + image: bytes, +) -> object: + """Confirm homescreen.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_blob( + *, + title: str, + data: str | bytes, + description: str | None, + extra: str | None, + verb: str | None = None, + verb_cancel: str | None = None, + hold: bool = False, + chunkify: bool = False, +) -> object: + """Confirm byte sequence data.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_address( + *, + title: str, + data: str | bytes, + description: str | None, + verb: str | None = "CONFIRM", + extra: str | None, + chunkify: bool = False, +) -> object: + """Confirm address. Similar to `confirm_blob` but has corner info button + and allows left swipe which does the same thing as the button.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_properties( + *, + title: str, + items: list[tuple[str | None, str | bytes | None, bool]], + hold: bool = False, +) -> object: + """Confirm list of key-value pairs. The third component in the tuple should be True if + the value is to be rendered as binary with monospace font, False otherwise.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_reset_device( + *, + title: str, + button: str, +) -> object: + """Confirm TOS before device setup.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_address_details( + *, + qr_title: str, + address: str, + case_sensitive: bool, + details_title: str, + account: str | None, + path: str | None, + xpubs: list[tuple[str, str]], +) -> object: + """Show address details - QR code, account, path, cosigner xpubs.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_info_with_cancel( + *, + title: str, + items: Iterable[Tuple[str, str]], + horizontal: bool = False, + chunkify: bool = False, +) -> object: + """Show metadata for outgoing transaction.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_value( + *, + title: str, + value: str, + description: str | None, + subtitle: str | None, + verb: str | None = None, + verb_cancel: str | None = None, + info_button: bool = False, + hold: bool = False, + chunkify: bool = False, + text_mono: bool = True, +) -> object: + """Confirm value. Merge of confirm_total and confirm_output.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_total( + *, + title: str, + items: Iterable[tuple[str, str]], + info_button: bool = False, + cancel_arrow: bool = False, +) -> object: + """Transaction summary. Always hold to confirm.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_modify_output( + *, + sign: int, + amount_change: str, + amount_new: str, +) -> object: + """Decrease or increase output amount.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_modify_fee( + *, + title: str, + sign: int, + user_fee_change: str, + total_fee_new: str, + fee_rate_amount: str | None, # ignored +) -> object: + """Decrease or increase transaction fee.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_fido( + *, + title: str, + app_name: str, + icon_name: str | None, + accounts: list[str | None], +) -> int | object: + """FIDO confirmation. + Returns page index in case of confirmation and CANCELLED otherwise. + """ + + +# rust/src/ui/model_mercury/layout.rs +def show_error( + *, + title: str, + button: str = "CONTINUE", + description: str = "", + allow_cancel: bool = False, + time_ms: int = 0, +) -> object: + """Error modal. No buttons shown when `button` is empty string.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_warning( + *, + title: str, + button: str = "CONTINUE", + value: str = "", + description: str = "", + allow_cancel: bool = False, + time_ms: int = 0, +) -> object: + """Warning modal. No buttons shown when `button` is empty string.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_success( + *, + title: str, + button: str = "CONTINUE", + description: str = "", + allow_cancel: bool = False, + time_ms: int = 0, +) -> object: + """Success modal. No buttons shown when `button` is empty string.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_info( + *, + title: str, + button: str = "CONTINUE", + description: str = "", + allow_cancel: bool = False, + time_ms: int = 0, +) -> object: + """Info modal. No buttons shown when `button` is empty string.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_mismatch(*, title: str) -> object: + """Warning modal, receiving address mismatch.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_simple( + *, + title: str | None, + description: str = "", + button: str = "", +) -> object: + """Simple dialog with text and one button.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_with_info( + *, + title: str, + button: str, + info_button: str, + items: Iterable[tuple[int, str]], +) -> object: + """Confirm given items but with third button. Always single page + without scrolling.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_more( + *, + title: str, + button: str, + items: Iterable[tuple[int, str]], +) -> object: + """Confirm long content with the possibility to go back from any page. + Meant to be used with confirm_with_info.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_coinjoin( + *, + max_rounds: str, + max_feerate: str, +) -> object: + """Confirm coinjoin authorization.""" + + +# rust/src/ui/model_mercury/layout.rs +def request_pin( + *, + prompt: str, + subprompt: str, + allow_cancel: bool = True, + wrong_pin: bool = False, +) -> str | object: + """Request pin on device.""" + + +# rust/src/ui/model_mercury/layout.rs +def request_passphrase( + *, + prompt: str, + max_len: int, +) -> str | object: + """Passphrase input keyboard.""" + + +# rust/src/ui/model_mercury/layout.rs +def request_bip39( + *, + prompt: str, + prefill_word: str, + can_go_back: bool, +) -> str: + """BIP39 word input keyboard.""" + + +# rust/src/ui/model_mercury/layout.rs +def request_slip39( + *, + prompt: str, + prefill_word: str, + can_go_back: bool, +) -> str: + """SLIP39 word input keyboard.""" + + +# rust/src/ui/model_mercury/layout.rs +def select_word( + *, + title: str, + description: str, + words: Iterable[str], +) -> int: + """Select mnemonic word from three possibilities - seed check after backup. The + iterable must be of exact size. Returns index in range `0..3`.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_share_words( + *, + title: str, + pages: Iterable[str], +) -> object: + """Show mnemonic for backup. Expects the words pre-divided into individual pages.""" + + +# rust/src/ui/model_mercury/layout.rs +def request_number( + *, + title: str, + count: int, + min_count: int, + max_count: int, + description: Callable[[int], str] | None = None, +) -> object: + """Number input with + and - buttons, description, and info button.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_checklist( + *, + title: str, + items: Iterable[str], + active: int, + button: str, +) -> object: + """Checklist of backup steps. Active index is highlighted, previous items have check + mark next to them.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_recovery( + *, + title: str, + description: str, + button: str, + dry_run: bool, + info_button: bool = False, +) -> object: + """Device recovery homescreen.""" + + +# rust/src/ui/model_mercury/layout.rs +def select_word_count( + *, + dry_run: bool, +) -> int | str: # TT returns int + """Select mnemonic word count from (12, 18, 20, 24, 33).""" + + +# rust/src/ui/model_mercury/layout.rs +def show_group_share_success( + *, + lines: Iterable[str] +) -> int: + """Shown after successfully finishing a group.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_remaining_shares( + *, + pages: Iterable[tuple[str, str]], +) -> int: + """Shows SLIP39 state after info button is pressed on `confirm_recovery`.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_progress( + *, + title: str, + indeterminate: bool = False, + description: str = "", +) -> object: + """Show progress loader. Please note that the number of lines reserved on screen for + description is determined at construction time. If you want multiline descriptions + make sure the initial description has at least that amount of lines.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_progress_coinjoin( + *, + title: str, + indeterminate: bool = False, + time_ms: int = 0, + skip_first_paint: bool = False, +) -> object: + """Show progress loader for coinjoin. Returns CANCELLED after a specified time when + time_ms timeout is passed.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_homescreen( + *, + label: str | None, + hold: bool, + notification: str | None, + notification_level: int = 0, + skip_first_paint: bool, +) -> CANCELLED: + """Idle homescreen.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_lockscreen( + *, + label: str | None, + bootscreen: bool, + skip_first_paint: bool, + coinjoin_authorized: bool = False, +) -> CANCELLED: + """Homescreen for locked device.""" + + +# rust/src/ui/model_mercury/layout.rs +def confirm_firmware_update( + *, + description: str, + fingerprint: str, +) -> None: + """Ask whether to update firmware, optionally show fingerprint. Shared with bootloader.""" + + +# rust/src/ui/model_mercury/layout.rs +def show_wait_text(/, message: str) -> None: + """Show single-line text in the middle of the screen.""" +CONFIRMED: object +CANCELLED: object +INFO: object + + # rust/src/ui/model_tr/layout.rs def disable_animation(disable: bool) -> None: """Disable animations, debug builds only.""" diff --git a/core/src/trezor/ui/layouts/mercury/__init__.py b/core/src/trezor/ui/layouts/mercury/__init__.py new file mode 100644 index 000000000..134d1d0ea --- /dev/null +++ b/core/src/trezor/ui/layouts/mercury/__init__.py @@ -0,0 +1,1524 @@ +from typing import TYPE_CHECKING + +import trezorui2 +from trezor import TR, io, loop, ui +from trezor.enums import ButtonRequestType +from trezor.wire import ActionCancelled +from trezor.wire.context import wait as ctx_wait + +from ..common import button_request, interact + +if TYPE_CHECKING: + from typing import Any, Awaitable, Iterable, NoReturn, Sequence, TypeVar + + from ..common import ExceptionType, PropertyType + + T = TypeVar("T") + + +BR_TYPE_OTHER = ButtonRequestType.Other # global_import_cache + +CONFIRMED = trezorui2.CONFIRMED +CANCELLED = trezorui2.CANCELLED +INFO = trezorui2.INFO + + +if __debug__: + from trezor.utils import DISABLE_ANIMATION + + trezorui2.disable_animation(bool(DISABLE_ANIMATION)) + + +class RustLayout(ui.Layout): + BACKLIGHT_LEVEL = ui.style.BACKLIGHT_NORMAL + + # pylint: disable=super-init-not-called + def __init__(self, layout: Any): + self.layout = layout + self.timer = loop.Timer() + self.layout.attach_timer_fn(self.set_timer) + + def set_timer(self, token: int, deadline: int) -> None: + self.timer.schedule(deadline, token) + + def request_complete_repaint(self) -> None: + msg = self.layout.request_complete_repaint() + assert msg is None + + def _paint(self) -> None: + import storage.cache as storage_cache + + painted = self.layout.paint() + + ui.refresh() + if storage_cache.homescreen_shown is not None and painted: + storage_cache.homescreen_shown = None + + if __debug__: + + def create_tasks(self) -> tuple[loop.AwaitableTask, ...]: + return ( + self.handle_timers(), + self.handle_input_and_rendering(), + self.handle_swipe(), + self.handle_click_signal(), + self.handle_result_signal(), + ) + + async def handle_result_signal(self) -> None: + """Enables sending arbitrary input - ui.Result. + + Waits for `result_signal` and carries it out. + """ + from storage import debug as debug_storage + + from apps.debug import result_signal + + while True: + event_id, result = await result_signal() + debug_storage.new_layout_event_id = event_id + raise ui.Result(result) + + def read_content_into(self, content_store: list[str]) -> None: + """Reads all the strings/tokens received from Rust into given list.""" + + def callback(*args: Any) -> None: + for arg in args: + content_store.append(str(arg)) + + content_store.clear() + self.layout.trace(callback) + + async def handle_swipe(self): + from trezor.enums import DebugSwipeDirection + + from apps.debug import notify_layout_change, swipe_signal + + while True: + event_id, direction = await swipe_signal() + orig_x = orig_y = 120 + off_x, off_y = { + DebugSwipeDirection.UP: (0, -30), + DebugSwipeDirection.DOWN: (0, 30), + DebugSwipeDirection.LEFT: (-30, 0), + DebugSwipeDirection.RIGHT: (30, 0), + }[direction] + + for event, x, y in ( + (io.TOUCH_START, orig_x, orig_y), + (io.TOUCH_MOVE, orig_x + 1 * off_x, orig_y + 1 * off_y), + (io.TOUCH_END, orig_x + 2 * off_x, orig_y + 2 * off_y), + ): + msg = self.layout.touch_event(event, x, y) + self._paint() + if msg is not None: + raise ui.Result(msg) + + notify_layout_change(self, event_id) + + async def _click( + self, + event_id: int | None, + x: int, + y: int, + hold_ms: int | None, + ) -> Any: + from storage import debug as debug_storage + from trezor import workflow + + from apps.debug import notify_layout_change + + self.layout.touch_event(io.TOUCH_START, x, y) + self._paint() + if hold_ms is not None: + await loop.sleep(hold_ms) + msg = self.layout.touch_event(io.TOUCH_END, x, y) + + if msg is not None: + debug_storage.new_layout_event_id = event_id + raise ui.Result(msg) + + # So that these presses will keep trezor awake + # (it will not be locked after auto_lock_delay_ms) + workflow.idle_timer.touch() + + self._paint() + notify_layout_change(self, event_id) + + async def handle_click_signal(self) -> None: + """Enables clicking somewhere on the screen. + + Waits for `click_signal` and carries it out. + """ + from apps.debug import click_signal + + while True: + event_id, x, y, hold_ms = await click_signal() + await self._click(event_id, x, y, hold_ms) + + else: + + def create_tasks(self) -> tuple[loop.AwaitableTask, ...]: + return self.handle_timers(), self.handle_input_and_rendering() + + def _first_paint(self) -> None: + ui.backlight_fade(ui.style.BACKLIGHT_NONE) + self._paint() + + if __debug__ and self.should_notify_layout_change: + from storage import debug as debug_storage + + from apps.debug import notify_layout_change + + # notify about change and do not notify again until next await. + # (handle_rendering might be called multiple times in a single await, + # because of the endless loop in __iter__) + self.should_notify_layout_change = False + + # Possibly there is an event ID that caused the layout change, + # so notifying with this ID. + event_id = None + if debug_storage.new_layout_event_id is not None: + event_id = debug_storage.new_layout_event_id + debug_storage.new_layout_event_id = None + + notify_layout_change(self, event_id) + + # Turn the brightness on again. + ui.backlight_fade(self.BACKLIGHT_LEVEL) + + def handle_input_and_rendering(self) -> loop.Task: # type: ignore [awaitable-is-generator] + from trezor import workflow + + touch = loop.wait(io.TOUCH) + self._first_paint() + while True: + # Using `yield` instead of `await` to avoid allocations. + event, x, y = yield touch + workflow.idle_timer.touch() + msg = None + if event in (io.TOUCH_START, io.TOUCH_MOVE, io.TOUCH_END): + msg = self.layout.touch_event(event, x, y) + if msg is not None: + raise ui.Result(msg) + self._paint() + + def handle_timers(self) -> loop.Task: # type: ignore [awaitable-is-generator] + while True: + # Using `yield` instead of `await` to avoid allocations. + token = yield self.timer + msg = self.layout.timer(token) + if msg is not None: + raise ui.Result(msg) + self._paint() + + def page_count(self) -> int: + return self.layout.page_count() + + +def draw_simple(layout: Any) -> None: + # Simple drawing not supported for layouts that set timers. + def dummy_set_timer(token: int, deadline: int) -> None: + raise RuntimeError + + layout.attach_timer_fn(dummy_set_timer) + ui.backlight_fade(ui.style.BACKLIGHT_DIM) + layout.paint() + ui.refresh() + ui.backlight_fade(ui.style.BACKLIGHT_NORMAL) + + +async def raise_if_not_confirmed(a: Awaitable[T], exc: Any = ActionCancelled) -> T: + result = await a + if result is not CONFIRMED: + raise exc + return result + + +async def confirm_action( + br_type: str, + title: str, + action: str | None = None, + description: str | None = None, + description_param: str | None = None, + verb: str | None = None, + verb_cancel: str | None = None, + hold: bool = False, + hold_danger: bool = False, + reverse: bool = False, + exc: ExceptionType = ActionCancelled, + br_code: ButtonRequestType = BR_TYPE_OTHER, +) -> None: + if verb is not None: + verb = verb.upper() + if verb_cancel is not None: + verb_cancel = verb_cancel.upper() + + if description is not None and description_param is not None: + description = description.format(description_param) + + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.confirm_action( + title=title.upper(), + action=action, + description=description, + verb=verb, + verb_cancel=verb_cancel, + hold=hold, + hold_danger=hold_danger, + reverse=reverse, + ) + ), + br_type, + br_code, + ), + exc, + ) + + +async def confirm_single( + br_type: str, + title: str, + description: str, + description_param: str | None = None, + verb: str | None = None, +) -> None: + if verb is not None: + verb = verb.upper() + description_param = description_param or "" + + # Placeholders are coming from translations in form of {0} + template_str = "{0}" + if template_str not in description: + template_str = "{}" + + begin, _separator, end = description.partition(template_str) + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.confirm_emphasized( + title=title.upper(), + items=(begin, (True, description_param), end), + verb=verb, + ) + ), + br_type, + ButtonRequestType.ProtectCall, + ) + ) + + +async def confirm_reset_device(title: str, recovery: bool = False) -> None: + if recovery: + button = TR.reset__button_recover + else: + button = TR.reset__button_create + + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.confirm_reset_device( + title=title.upper(), + button=button, + ) + ), + "recover_device" if recovery else "setup_device", + ( + ButtonRequestType.ProtectCall + if recovery + else ButtonRequestType.ResetDevice + ), + ) + ) + + +# TODO cleanup @ redesign +async def prompt_backup() -> bool: + result = await interact( + RustLayout( + trezorui2.confirm_action( + title=TR.words__title_success, + action=TR.backup__new_wallet_successfully_created, + description=TR.backup__it_should_be_backed_up, + verb=TR.buttons__back_up, + verb_cancel=TR.buttons__skip, + ) + ), + "backup_device", + ButtonRequestType.ResetDevice, + ) + if result is CONFIRMED: + return True + + result = await interact( + RustLayout( + trezorui2.confirm_action( + title=TR.words__warning.upper(), + action=TR.backup__want_to_skip, + description=TR.backup__can_back_up_anytime, + verb=TR.buttons__back_up, + verb_cancel=TR.buttons__skip, + ) + ), + "backup_device", + ButtonRequestType.ResetDevice, + ) + return result is CONFIRMED + + +async def confirm_path_warning( + path: str, + path_type: str | None = None, +) -> None: + title = ( + TR.addr_mismatch__wrong_derivation_path + if not path_type + else f"{TR.words__unknown} {path_type.lower()}." + ) + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.show_warning( + title=title, + value=path, + description=TR.words__continue_anyway, + button=TR.buttons__continue, + ) + ), + "path_warning", + br_code=ButtonRequestType.UnknownDerivationPath, + ) + ) + + +async def confirm_homescreen( + image: bytes, +) -> None: + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.confirm_homescreen( + title=TR.homescreen__title_set, + image=image, + ) + ), + "set_homesreen", + ButtonRequestType.ProtectCall, + ) + ) + + +async def show_address( + address: str, + *, + title: str | None = None, + address_qr: str | None = None, + case_sensitive: bool = True, + path: str | None = None, + account: str | None = None, + network: str | None = None, + multisig_index: int | None = None, + xpubs: Sequence[str] = (), + mismatch_title: str | None = None, + details_title: str | None = None, + br_type: str = "show_address", + br_code: ButtonRequestType = ButtonRequestType.Address, + chunkify: bool = False, +) -> None: + mismatch_title = mismatch_title or TR.addr_mismatch__mismatch # def_arg + send_button_request = True + + if title is None: + title = TR.address__title_receive_address + if multisig_index is not None: + title = f"{title}\n(MULTISIG)" + details_title = TR.send__title_receiving_to + elif details_title is None: + details_title = title + + layout = RustLayout( + trezorui2.confirm_address( + title=title, + data=address, + description=network or "", + extra=None, + chunkify=chunkify, + ) + ) + + while True: + if send_button_request: + send_button_request = False + await button_request( + br_type, + br_code, + pages=layout.page_count(), + ) + layout.request_complete_repaint() + result = await ctx_wait(layout) + + # User pressed right button. + if result is CONFIRMED: + break + + # User pressed corner button or swiped left, go to address details. + elif result is INFO: + + def xpub_title(i: int) -> str: + result = f"MULTISIG XPUB #{i + 1}\n" + result += ( + f"({TR.address__title_yours})" + if i == multisig_index + else f"({TR.address__title_cosigner})" + ) + return result + + result = await ctx_wait( + RustLayout( + trezorui2.show_address_details( + qr_title=title, + address=address if address_qr is None else address_qr, + case_sensitive=case_sensitive, + details_title=details_title, + account=account, + path=path, + xpubs=[(xpub_title(i), xpub) for i, xpub in enumerate(xpubs)], + ) + ) + ) + assert result is CANCELLED + + else: + result = await ctx_wait( + RustLayout(trezorui2.show_mismatch(title=mismatch_title)) + ) + assert result in (CONFIRMED, CANCELLED) + # Right button aborts action, left goes back to showing address. + if result is CONFIRMED: + raise ActionCancelled + + +def show_pubkey( + pubkey: str, + title: str | None = None, + *, + account: str | None = None, + path: str | None = None, + mismatch_title: str | None = None, + br_type: str = "show_pubkey", +) -> Awaitable[None]: + title = title or TR.address__public_key # def_arg + mismatch_title = mismatch_title or TR.addr_mismatch__key_mismatch # def_arg + return show_address( + address=pubkey, + title=title.upper(), + account=account, + path=path, + br_type=br_type, + br_code=ButtonRequestType.PublicKey, + mismatch_title=mismatch_title, + chunkify=False, + ) + + +async def show_error_and_raise( + br_type: str, + content: str, + subheader: str | None = None, + button: str | None = None, + exc: ExceptionType = ActionCancelled, +) -> NoReturn: + button = button or TR.buttons__try_again # def_arg + await interact( + RustLayout( + trezorui2.show_error( + title=subheader or "", + description=content, + button=button.upper(), + allow_cancel=False, + ) + ), + br_type, + BR_TYPE_OTHER, + ) + raise exc + + +async def show_warning( + br_type: str, + content: str, + subheader: str | None = None, + button: str | None = None, + br_code: ButtonRequestType = ButtonRequestType.Warning, +) -> None: + button = button or TR.buttons__continue # def_arg + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.show_warning( + title=content, + description=subheader or "", + button=button.upper(), + ) + ), + br_type, + br_code, + ) + ) + + +async def show_success( + br_type: str, + content: str, + subheader: str | None = None, + button: str | None = None, +) -> None: + button = button or TR.buttons__continue # def_arg + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.show_success( + title=content, + description=subheader or "", + button=button.upper(), + allow_cancel=False, + ) + ), + br_type, + ButtonRequestType.Success, + ) + ) + + +async def confirm_output( + address: str, + amount: str, + title: str | None = None, + hold: bool = False, + br_code: ButtonRequestType = ButtonRequestType.ConfirmOutput, + address_label: str | None = None, + output_index: int | None = None, + chunkify: bool = False, +) -> None: + if title is not None: + # TODO: handle translation + if title.upper().startswith("CONFIRM "): + title = title[len("CONFIRM ") :] + amount_title = title + recipient_title = title + elif output_index is not None: + amount_title = f"{TR.words__amount} #{output_index + 1}" + recipient_title = f"{TR.words__recipient} #{output_index + 1}" + else: + amount_title = TR.send__confirm_sending + recipient_title = TR.send__title_sending_to + + while True: + result = await interact( + RustLayout( + trezorui2.confirm_value( + title=recipient_title.upper(), + subtitle=address_label, + description=None, + value=address, + verb=TR.buttons__continue, + hold=False, + info_button=False, + chunkify=chunkify, + ) + ), + "confirm_output", + br_code, + ) + if result is not CONFIRMED: + raise ActionCancelled + + result = await interact( + RustLayout( + trezorui2.confirm_value( + title=amount_title.upper(), + subtitle=None, + description=None, + value=amount, + verb=None if hold else TR.buttons__confirm, + verb_cancel="^", + hold=hold, + info_button=False, + ) + ), + "confirm_output", + br_code, + ) + if result is CONFIRMED: + return + + +async def confirm_payment_request( + recipient_name: str, + amount: str, + memos: list[str], +) -> bool: + result = await interact( + RustLayout( + trezorui2.confirm_with_info( + title=TR.send__title_sending, + items=[(ui.NORMAL, f"{amount} to\n{recipient_name}")] + + [(ui.NORMAL, memo) for memo in memos], + button=TR.buttons__confirm, + info_button=TR.buttons__details, + ) + ), + "confirm_payment_request", + ButtonRequestType.ConfirmOutput, + ) + + # When user pressed INFO, returning False, which gets processed in higher function + # to differentiate it from CONFIRMED. Raising otherwise. + if result is CONFIRMED: + return True + elif result is INFO: + return False + else: + raise ActionCancelled + + +async def should_show_more( + title: str, + para: Iterable[tuple[int, str]], + button_text: str | None = None, + br_type: str = "should_show_more", + br_code: ButtonRequestType = BR_TYPE_OTHER, + confirm: str | bytes | None = None, +) -> bool: + """Return True if the user wants to show more (they click a special button) + and False when the user wants to continue without showing details. + + Raises ActionCancelled if the user cancels. + """ + button_text = button_text or TR.buttons__show_all # def_arg + if confirm is None or not isinstance(confirm, str): + confirm = TR.buttons__confirm + + result = await interact( + RustLayout( + trezorui2.confirm_with_info( + title=title.upper(), + items=para, + button=confirm.upper(), + info_button=button_text.upper(), + ) + ), + br_type, + br_code, + ) + + if result is CONFIRMED: + return False + elif result is INFO: + return True + else: + assert result is CANCELLED + raise ActionCancelled + + +async def _confirm_ask_pagination( + br_type: str, + title: str, + data: bytes | str, + description: str, + br_code: ButtonRequestType, +) -> None: + paginated: ui.Layout | None = None + # TODO: make should_show_more/confirm_more accept bytes directly + if isinstance(data, bytes): + from ubinascii import hexlify + + data = hexlify(data).decode() + while True: + if not await should_show_more( + title, + para=[(ui.NORMAL, description), (ui.MONO, data)], + br_type=br_type, + br_code=br_code, + ): + return + + if paginated is None: + paginated = RustLayout( + trezorui2.confirm_more( + title=title, + button=TR.buttons__close, + items=[(ui.MONO, data)], + ) + ) + else: + paginated.request_complete_repaint() + + result = await interact(paginated, br_type, br_code) + assert result in (CONFIRMED, CANCELLED) + + assert False + + +async def confirm_blob( + br_type: str, + title: str, + data: bytes | str, + description: str | None = None, + verb: str | None = None, + verb_cancel: str | None = None, + hold: bool = False, + br_code: ButtonRequestType = BR_TYPE_OTHER, + ask_pagination: bool = False, + chunkify: bool = False, +) -> None: + verb = verb or TR.buttons__confirm # def_arg + title = title.upper() + layout = RustLayout( + trezorui2.confirm_blob( + title=title, + description=description, + data=data, + extra=None, + hold=hold, + verb=verb, + verb_cancel=verb_cancel, + chunkify=chunkify, + ) + ) + + if ask_pagination and layout.page_count() > 1: + assert not hold + await _confirm_ask_pagination(br_type, title, data, description or "", br_code) + + else: + await raise_if_not_confirmed( + interact( + layout, + br_type, + br_code, + ) + ) + + +async def confirm_address( + title: str, + address: str, + description: str | None = None, + br_type: str = "confirm_address", + br_code: ButtonRequestType = BR_TYPE_OTHER, +) -> None: + return await confirm_value( + title, + address, + description or "", + br_type, + br_code, + verb=TR.buttons__confirm, + ) + + +async def confirm_text( + br_type: str, + title: str, + data: str, + description: str | None = None, + br_code: ButtonRequestType = BR_TYPE_OTHER, +) -> None: + return await confirm_value( + title, + data, + description or "", + br_type, + br_code, + verb=TR.buttons__confirm, + ) + + +def confirm_amount( + title: str, + amount: str, + description: str | None = None, + br_type: str = "confirm_amount", + br_code: ButtonRequestType = BR_TYPE_OTHER, +) -> Awaitable[None]: + description = description or f"{TR.words__amount}:" # def_arg + return confirm_value( + title, + amount, + description, + br_type, + br_code, + verb=TR.buttons__confirm, + ) + + +def confirm_value( + title: str, + value: str, + description: str, + br_type: str, + br_code: ButtonRequestType = BR_TYPE_OTHER, + *, + verb: str | None = None, + subtitle: str | None = None, + hold: bool = False, + value_text_mono: bool = True, + info_items: Iterable[tuple[str, str]] | None = None, + info_title: str | None = None, + chunkify_info: bool = False, +) -> Awaitable[None]: + """General confirmation dialog, used by many other confirm_* functions.""" + + if not verb and not hold: + raise ValueError("Either verb or hold=True must be set") + + if verb: + verb = verb.upper() + + info_items = info_items or [] + info_layout = RustLayout( + trezorui2.show_info_with_cancel( + title=info_title if info_title else TR.words__title_information, + items=info_items, + chunkify=chunkify_info, + ) + ) + + return raise_if_not_confirmed( + with_info( + RustLayout( + trezorui2.confirm_value( + title=title.upper(), + subtitle=subtitle, + description=description, + value=value, + verb=verb, + hold=hold, + info_button=bool(info_items), + text_mono=value_text_mono, + ) + ), + info_layout, + br_type, + br_code, + ) + ) + + +async def confirm_properties( + br_type: str, + title: str, + props: Iterable[PropertyType], + hold: bool = False, + br_code: ButtonRequestType = ButtonRequestType.ConfirmOutput, +) -> None: + # Monospace flag for values that are bytes. + items = [(prop[0], prop[1], isinstance(prop[1], bytes)) for prop in props] + + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.confirm_properties( + title=title.upper(), + items=items, + hold=hold, + ) + ), + br_type, + br_code, + ) + ) + + +async def confirm_total( + total_amount: str, + fee_amount: str, + title: str | None = None, + total_label: str | None = None, + fee_label: str | None = None, + account_label: str | None = None, + fee_rate_amount: str | None = None, + br_type: str = "confirm_total", + br_code: ButtonRequestType = ButtonRequestType.SignTx, +) -> None: + title = title or TR.words__title_summary # def_arg + total_label = total_label or TR.send__total_amount # def_arg + fee_label = fee_label or TR.send__including_fee # def_arg + + items = [ + (total_label, total_amount), + (fee_label, fee_amount), + ] + info_items = [] + if account_label: + info_items.append((TR.confirm_total__sending_from_account, account_label)) + if fee_rate_amount: + info_items.append((TR.confirm_total__fee_rate, fee_rate_amount)) + + await confirm_summary( + items, + TR.words__title_summary, + info_items=info_items, + br_type=br_type, + br_code=br_code, + ) + + +async def confirm_summary( + items: Iterable[tuple[str, str]], + title: str | None = None, + info_items: Iterable[tuple[str, str]] | None = None, + info_title: str | None = None, + br_type: str = "confirm_total", + br_code: ButtonRequestType = ButtonRequestType.SignTx, +) -> None: + title = title or TR.words__title_summary # def_arg + + total_layout = RustLayout( + trezorui2.confirm_total( + title=title.upper(), + items=items, + info_button=bool(info_items), + ) + ) + info_items = info_items or [] + info_layout = RustLayout( + trezorui2.show_info_with_cancel( + title=info_title.upper() if info_title else TR.words__title_information, + items=info_items, + ) + ) + await raise_if_not_confirmed(with_info(total_layout, info_layout, br_type, br_code)) + + +async def confirm_ethereum_tx( + recipient: str, + total_amount: str, + maximum_fee: str, + items: Iterable[tuple[str, str]], + br_type: str = "confirm_ethereum_tx", + br_code: ButtonRequestType = ButtonRequestType.SignTx, + chunkify: bool = False, +) -> None: + total_layout = RustLayout( + trezorui2.confirm_total( + title=TR.words__title_summary, + items=[ + (f"{TR.words__amount}:", total_amount), + (TR.send__maximum_fee, maximum_fee), + ], + info_button=True, + cancel_arrow=True, + ) + ) + info_layout = RustLayout( + trezorui2.show_info_with_cancel( + title=TR.confirm_total__title_fee, + items=items, + ) + ) + + while True: + # Allowing going back and forth between recipient and summary/details + await confirm_blob( + br_type, + TR.words__recipient.upper(), + recipient, + verb=TR.buttons__continue, + chunkify=chunkify, + ) + + try: + total_layout.request_complete_repaint() + await raise_if_not_confirmed( + with_info(total_layout, info_layout, br_type, br_code) + ) + break + except ActionCancelled: + continue + + +async def confirm_ethereum_staking_tx( + title: str, + intro_question: str, + verb: str, + total_amount: str, + maximum_fee: str, + address: str, + address_title: str, + info_items: Iterable[tuple[str, str]], + chunkify: bool = False, + br_type: str = "confirm_ethereum_staking_tx", + br_code: ButtonRequestType = ButtonRequestType.SignTx, +) -> None: + + # intro + await confirm_value( + title, + intro_question, + "", + br_type, + br_code, + verb=verb, + value_text_mono=False, + info_items=(("", address),), + info_title=address_title, + chunkify_info=chunkify, + ) + + # confirmation + if verb == TR.ethereum__staking_claim: + items = ((TR.send__maximum_fee, maximum_fee),) + else: + items = ( + (TR.words__amount + ":", total_amount), + (TR.send__maximum_fee, maximum_fee), + ) + await confirm_summary( + items, # items + title=title, + info_title=TR.confirm_total__title_fee, + info_items=info_items, + br_type=br_type, + br_code=br_code, + ) + + +async def confirm_solana_tx( + amount: str, + fee: str, + items: Iterable[tuple[str, str]], + amount_title: str | None = None, + fee_title: str | None = None, + br_type: str = "confirm_solana_tx", + br_code: ButtonRequestType = ButtonRequestType.SignTx, +): + amount_title = ( + amount_title if amount_title is not None else f"{TR.words__amount}:" + ) # def_arg + fee_title = fee_title or TR.words__fee # def_arg + await confirm_summary( + ((amount_title, amount), (fee_title, fee)), + info_items=items, + br_type=br_type, + br_code=br_code, + ) + + +async def confirm_joint_total(spending_amount: str, total_amount: str) -> None: + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.confirm_total( + title=TR.send__title_joint_transaction, + items=[ + (TR.send__you_are_contributing, spending_amount), + (TR.send__to_the_total_amount, total_amount), + ], + ) + ), + "confirm_joint_total", + ButtonRequestType.SignTx, + ) + ) + + +async def confirm_metadata( + br_type: str, + title: str, + content: str, + param: str | None = None, + br_code: ButtonRequestType = ButtonRequestType.SignTx, + hold: bool = False, + verb: str | None = None, +) -> None: + verb = verb or TR.buttons__continue # def_arg + await confirm_action( + br_type, + title=title.upper(), + action="", + description=content, + description_param=param, + verb=verb.upper(), + hold=hold, + br_code=br_code, + ) + + +async def confirm_replacement(title: str, txid: str) -> None: + await confirm_blob( + "confirm_replacement", + title.upper(), + txid, + TR.send__transaction_id, + TR.buttons__continue, + br_code=ButtonRequestType.SignTx, + ) + + +async def confirm_modify_output( + address: str, + sign: int, + amount_change: str, + amount_new: str, +) -> None: + address_layout = RustLayout( + trezorui2.confirm_blob( + title=TR.modify_amount__title, + data=address, + verb=TR.buttons__continue, + verb_cancel=None, + description=f"{TR.words__address}:", + extra=None, + ) + ) + modify_layout = RustLayout( + trezorui2.confirm_modify_output( + sign=sign, + amount_change=amount_change, + amount_new=amount_new, + ) + ) + + send_button_request = True + while True: + if send_button_request: + await button_request( + "modify_output", + ButtonRequestType.ConfirmOutput, + address_layout.page_count(), + ) + address_layout.request_complete_repaint() + await raise_if_not_confirmed(ctx_wait(address_layout)) + + if send_button_request: + send_button_request = False + await button_request( + "modify_output", + ButtonRequestType.ConfirmOutput, + modify_layout.page_count(), + ) + modify_layout.request_complete_repaint() + result = await ctx_wait(modify_layout) + + if result is CONFIRMED: + break + + +async def with_info( + main_layout: RustLayout, + info_layout: RustLayout, + br_type: str, + br_code: ButtonRequestType, +) -> Any: + await button_request(br_type, br_code, pages=main_layout.page_count()) + + while True: + result = await ctx_wait(main_layout) + + if result is INFO: + info_layout.request_complete_repaint() + result = await ctx_wait(info_layout) + assert result is CANCELLED + main_layout.request_complete_repaint() + continue + else: + return result + + +async def confirm_modify_fee( + title: str, + sign: int, + user_fee_change: str, + total_fee_new: str, + fee_rate_amount: str | None = None, +) -> None: + fee_layout = RustLayout( + trezorui2.confirm_modify_fee( + title=title.upper(), + 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 = RustLayout( + trezorui2.show_info_with_cancel( + title=TR.confirm_total__title_fee, + items=items, + ) + ) + await raise_if_not_confirmed( + with_info(fee_layout, info_layout, "modify_fee", ButtonRequestType.SignTx) + ) + + +async def confirm_coinjoin(max_rounds: int, max_fee_per_vbyte: str) -> None: + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.confirm_coinjoin( + max_rounds=str(max_rounds), + max_feerate=max_fee_per_vbyte, + ) + ), + "coinjoin_final", + BR_TYPE_OTHER, + ) + ) + + +# TODO cleanup @ redesign +async def confirm_sign_identity( + proto: str, identity: str, challenge_visual: str | None +) -> None: + await confirm_blob( + "sign_identity", + f"{TR.words__sign} {proto}", + identity, + challenge_visual + "\n" if challenge_visual else "", + br_code=BR_TYPE_OTHER, + ) + + +async def confirm_signverify( + message: str, + address: str, + verify: bool, + path: str | None = None, + account: str | None = None, + chunkify: bool = False, +) -> None: + if verify: + address_title = TR.sign_message__verify_address + br_type = "verify_message" + else: + address_title = TR.sign_message__confirm_address + br_type = "sign_message" + + address_layout = RustLayout( + trezorui2.confirm_address( + title=address_title, + data=address, + description="", + verb=TR.buttons__continue, + extra=None, + chunkify=chunkify, + ) + ) + + items: list[tuple[str, str]] = [] + if account is not None: + items.append((f"{TR.words__account}:", account)) + if path is not None: + items.append((TR.address_details__derivation_path, path)) + items.append( + ( + TR.sign_message__message_size, + TR.sign_message__bytes_template.format(len(message)), + ) + ) + + info_layout = RustLayout( + trezorui2.show_info_with_cancel( + title=TR.words__title_information, + items=items, + horizontal=True, + ) + ) + + message_layout = RustLayout( + trezorui2.confirm_blob( + title=TR.sign_message__confirm_message, + description=None, + data=message, + extra=None, + hold=not verify, + verb=TR.buttons__confirm if verify else None, + ) + ) + + while True: + result = await with_info( + address_layout, info_layout, br_type, br_code=BR_TYPE_OTHER + ) + if result is not CONFIRMED: + result = await ctx_wait( + RustLayout(trezorui2.show_mismatch(title=TR.addr_mismatch__mismatch)) + ) + assert result in (CONFIRMED, CANCELLED) + # Right button aborts action, left goes back to showing address. + if result is CONFIRMED: + raise ActionCancelled + else: + address_layout.request_complete_repaint() + continue + + message_layout.request_complete_repaint() + result = await interact(message_layout, br_type, BR_TYPE_OTHER) + if result is CONFIRMED: + break + + address_layout.request_complete_repaint() + + +async def show_error_popup( + title: str, + description: str, + subtitle: str | None = None, + description_param: str = "", + *, + button: str = "", + timeout_ms: int = 0, +) -> None: + if not button and not timeout_ms: + raise ValueError("Either button or timeout_ms must be set") + + if subtitle: + title += f"\n{subtitle}" + await RustLayout( + trezorui2.show_error( + title=title, + description=description.format(description_param), + button=button, + time_ms=timeout_ms, + allow_cancel=False, + ) + ) + + +def request_passphrase_on_host() -> None: + draw_simple( + trezorui2.show_simple( + title=None, + description=TR.passphrase__please_enter, + ) + ) + + +def show_wait_text(message: str) -> None: + draw_simple(trezorui2.show_wait_text(message)) + + +async def request_passphrase_on_device(max_len: int) -> str: + result = await interact( + RustLayout( + trezorui2.request_passphrase( + prompt=TR.passphrase__title_enter, max_len=max_len + ) + ), + "passphrase_device", + ButtonRequestType.PassphraseEntry, + ) + if result is CANCELLED: + raise ActionCancelled("Passphrase entry cancelled") + + assert isinstance(result, str) + return result + + +async def request_pin_on_device( + prompt: str, + attempts_remaining: int | None, + allow_cancel: bool, + wrong_pin: bool = False, +) -> str: + from trezor.wire import PinCancelled + + if attempts_remaining is None: + subprompt = "" + elif attempts_remaining == 1: + subprompt = TR.pin__last_attempt + else: + subprompt = f"{attempts_remaining} {TR.pin__tries_left}" + + result = await interact( + RustLayout( + trezorui2.request_pin( + prompt=prompt, + subprompt=subprompt, + allow_cancel=allow_cancel, + wrong_pin=wrong_pin, + ) + ), + "pin_device", + ButtonRequestType.PinEntry, + ) + if result is CANCELLED: + raise PinCancelled + assert isinstance(result, str) + return result + + +async def confirm_reenter_pin( + is_wipe_code: bool = False, +) -> None: + """Not supported for TT.""" + pass + + +async def pin_mismatch_popup( + is_wipe_code: bool = False, +) -> None: + await button_request("pin_mismatch", code=BR_TYPE_OTHER) + title = TR.wipe_code__wipe_code_mismatch if is_wipe_code else TR.pin__pin_mismatch + description = TR.wipe_code__mismatch if is_wipe_code else TR.pin__mismatch + return await show_error_popup( + title, + description, + button=TR.buttons__try_again, + ) + + +async def wipe_code_same_as_pin_popup() -> None: + await button_request("wipe_code_same_as_pin", code=BR_TYPE_OTHER) + return await show_error_popup( + TR.wipe_code__invalid, + TR.wipe_code__diff_from_pin, + button=TR.buttons__try_again, + ) + + +async def confirm_set_new_pin( + br_type: str, + title: str, + description: str, + information: str, + br_code: ButtonRequestType = BR_TYPE_OTHER, +) -> None: + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.confirm_emphasized( + title=title.upper(), + items=( + (True, description + "\n\n"), + information, + ), + verb=TR.buttons__turn_on, + ) + ), + br_type, + br_code, + ) + ) + + +async def confirm_firmware_update(description: str, fingerprint: str) -> None: + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.confirm_firmware_update( + description=description, fingerprint=fingerprint + ) + ), + "firmware_update", + BR_TYPE_OTHER, + ) + ) diff --git a/core/src/trezor/ui/layouts/mercury/fido.py b/core/src/trezor/ui/layouts/mercury/fido.py new file mode 100644 index 000000000..9dc42c152 --- /dev/null +++ b/core/src/trezor/ui/layouts/mercury/fido.py @@ -0,0 +1,89 @@ +from typing import TYPE_CHECKING + +import trezorui2 +from trezor.enums import ButtonRequestType + +from ..common import interact +from . import RustLayout + +if TYPE_CHECKING: + from trezor.loop import AwaitableTask + + +if __debug__: + from trezor import io, ui + + from ... import Result + + class _RustFidoLayoutImpl(RustLayout): + def create_tasks(self) -> tuple[AwaitableTask, ...]: + return ( + self.handle_timers(), + self.handle_input_and_rendering(), + self.handle_swipe(), + self.handle_debug_confirm(), + ) + + async def handle_debug_confirm(self) -> None: + from apps.debug import result_signal + + _event_id, result = await result_signal() + if result is not trezorui2.CONFIRMED: + raise Result(result) + + for event, x, y in ( + (io.TOUCH_START, 220, 220), + (io.TOUCH_END, 220, 220), + ): + msg = self.layout.touch_event(event, x, y) + self.layout.paint() + ui.refresh() + if msg is not None: + raise Result(msg) + + _RustFidoLayout = _RustFidoLayoutImpl + +else: + _RustFidoLayout = RustLayout + + +async def confirm_fido( + header: str, + app_name: str, + icon_name: str | None, + accounts: list[str | None], +) -> int: + """Webauthn confirmation for one or more credentials.""" + confirm = _RustFidoLayout( + trezorui2.confirm_fido( + title=header.upper(), + app_name=app_name, + icon_name=icon_name, + accounts=accounts, + ) + ) + result = await interact(confirm, "confirm_fido", ButtonRequestType.Other) + + # The Rust side returns either an int or `CANCELLED`. We detect the int situation + # and assume cancellation otherwise. + if isinstance(result, int): + return result + + # Late import won't get executed on the happy path. + from trezor.wire import ActionCancelled + + raise ActionCancelled + + +async def confirm_fido_reset() -> bool: + from trezor import TR + + confirm = RustLayout( + trezorui2.confirm_action( + title=TR.fido__title_reset, + action=TR.fido__erase_credentials, + description=TR.words__really_wanna, + reverse=True, + ) + ) + return (await confirm) is trezorui2.CONFIRMED diff --git a/core/src/trezor/ui/layouts/mercury/homescreen.py b/core/src/trezor/ui/layouts/mercury/homescreen.py new file mode 100644 index 000000000..c59abf1f1 --- /dev/null +++ b/core/src/trezor/ui/layouts/mercury/homescreen.py @@ -0,0 +1,143 @@ +from typing import TYPE_CHECKING + +import storage.cache as storage_cache +import trezorui2 +from trezor import TR, ui + +from . import RustLayout + +if TYPE_CHECKING: + from typing import Any, Tuple + + from trezor import loop + + +class HomescreenBase(RustLayout): + RENDER_INDICATOR: object | None = None + + def __init__(self, layout: Any) -> None: + super().__init__(layout=layout) + + def _paint(self) -> None: + self.layout.paint() + ui.refresh() + + def _first_paint(self) -> None: + if storage_cache.homescreen_shown is not self.RENDER_INDICATOR: + super()._first_paint() + storage_cache.homescreen_shown = self.RENDER_INDICATOR + else: + self._paint() + + if __debug__: + # In __debug__ mode, ignore {confirm,swipe,input}_signal. + def create_tasks(self) -> tuple[loop.AwaitableTask, ...]: + return ( + self.handle_timers(), + self.handle_input_and_rendering(), + self.handle_click_signal(), # so we can receive debug events + ) + + +class Homescreen(HomescreenBase): + RENDER_INDICATOR = storage_cache.HOMESCREEN_ON + + def __init__( + self, + label: str | None, + notification: str | None, + notification_is_error: bool, + hold_to_lock: bool, + ) -> None: + level = 1 + if notification is not None: + if notification == TR.homescreen__title_coinjoin_authorized: + level = 3 + elif notification == TR.homescreen__title_experimental_mode: + level = 2 + elif notification_is_error: + level = 0 + + skip = storage_cache.homescreen_shown is self.RENDER_INDICATOR + super().__init__( + layout=trezorui2.show_homescreen( + label=label, + notification=notification, + notification_level=level, + hold=hold_to_lock, + skip_first_paint=skip, + ), + ) + + async def usb_checker_task(self) -> None: + from trezor import io, loop + + usbcheck = loop.wait(io.USB_CHECK) + while True: + is_connected = await usbcheck + self.layout.usb_event(is_connected) + self.layout.paint() + ui.refresh() + + def create_tasks(self) -> Tuple[loop.AwaitableTask, ...]: + return super().create_tasks() + (self.usb_checker_task(),) + + +class Lockscreen(HomescreenBase): + RENDER_INDICATOR = storage_cache.LOCKSCREEN_ON + BACKLIGHT_LEVEL = ui.style.BACKLIGHT_LOW + + def __init__( + self, + label: str | None, + bootscreen: bool = False, + coinjoin_authorized: bool = False, + ) -> None: + self.bootscreen = bootscreen + if bootscreen: + self.BACKLIGHT_LEVEL = ui.style.BACKLIGHT_NORMAL + + skip = ( + not bootscreen and storage_cache.homescreen_shown is self.RENDER_INDICATOR + ) + super().__init__( + layout=trezorui2.show_lockscreen( + label=label, + bootscreen=bootscreen, + skip_first_paint=skip, + coinjoin_authorized=coinjoin_authorized, + ), + ) + + async def __iter__(self) -> Any: + result = await super().__iter__() + if self.bootscreen: + self.request_complete_repaint() + return result + + +class Busyscreen(HomescreenBase): + RENDER_INDICATOR = storage_cache.BUSYSCREEN_ON + + def __init__(self, delay_ms: int) -> None: + from trezor import TR + + skip = storage_cache.homescreen_shown is self.RENDER_INDICATOR + super().__init__( + layout=trezorui2.show_progress_coinjoin( + title=TR.coinjoin__waiting_for_others, + indeterminate=True, + time_ms=delay_ms, + skip_first_paint=skip, + ) + ) + + async def __iter__(self) -> Any: + from apps.base import set_homescreen + + # Handle timeout. + result = await super().__iter__() + assert result == trezorui2.CANCELLED + storage_cache.delete(storage_cache.APP_COMMON_BUSY_DEADLINE_MS) + set_homescreen() + return result diff --git a/core/src/trezor/ui/layouts/mercury/progress.py b/core/src/trezor/ui/layouts/mercury/progress.py new file mode 100644 index 000000000..5997d247f --- /dev/null +++ b/core/src/trezor/ui/layouts/mercury/progress.py @@ -0,0 +1,72 @@ +from typing import TYPE_CHECKING + +import trezorui2 +from trezor import TR, ui + +if TYPE_CHECKING: + from typing import Any + + from ..common import ProgressLayout + + +class RustProgress: + def __init__( + self, + layout: Any, + ): + self.layout = layout + ui.backlight_fade(ui.style.BACKLIGHT_DIM) + self.layout.attach_timer_fn(self.set_timer) + self.layout.paint() + ui.refresh() + ui.backlight_fade(ui.style.BACKLIGHT_NORMAL) + + def set_timer(self, token: int, deadline: int) -> None: + raise RuntimeError # progress layouts should not set timers + + def report(self, value: int, description: str | None = None): + msg = self.layout.progress_event(value, description or "") + assert msg is None + self.layout.paint() + ui.refresh() + + +def progress( + message: str | None = None, + description: str | None = None, + indeterminate: bool = False, +) -> ProgressLayout: + message = message or TR.progress__please_wait # def_arg + return RustProgress( + layout=trezorui2.show_progress( + title=message.upper(), + indeterminate=indeterminate, + description=description or "", + ) + ) + + +def bitcoin_progress(message: str) -> ProgressLayout: + return progress(message) + + +def coinjoin_progress(message: str) -> ProgressLayout: + return RustProgress( + layout=trezorui2.show_progress_coinjoin(title=message, indeterminate=False) + ) + + +def pin_progress(message: str, description: str) -> ProgressLayout: + return progress(message, description=description) + + +def monero_keyimage_sync_progress() -> ProgressLayout: + return progress("", TR.progress__syncing) + + +def monero_live_refresh_progress() -> ProgressLayout: + return progress("", TR.progress__refreshing, indeterminate=True) + + +def monero_transaction_progress_inner() -> ProgressLayout: + return progress("", TR.progress__signing_transaction) diff --git a/core/src/trezor/ui/layouts/mercury/recovery.py b/core/src/trezor/ui/layouts/mercury/recovery.py new file mode 100644 index 000000000..10c8f3aa5 --- /dev/null +++ b/core/src/trezor/ui/layouts/mercury/recovery.py @@ -0,0 +1,171 @@ +from typing import Callable, Iterable + +import trezorui2 +from trezor import TR +from trezor.enums import ButtonRequestType +from trezor.wire.context import wait as ctx_wait + +from ..common import interact +from . import RustLayout, raise_if_not_confirmed + +CONFIRMED = trezorui2.CONFIRMED # global_import_cache +INFO = trezorui2.INFO # global_import_cache + + +async def _is_confirmed_info( + dialog: RustLayout, + info_func: Callable, +) -> bool: + while True: + result = await ctx_wait(dialog) + + if result is trezorui2.INFO: + await info_func() + dialog.request_complete_repaint() + else: + return result is CONFIRMED + + +async def request_word_count(dry_run: bool) -> int: + selector = RustLayout(trezorui2.select_word_count(dry_run=dry_run)) + count = await interact(selector, "word_count", ButtonRequestType.MnemonicWordCount) + return int(count) + + +async def request_word( + word_index: int, word_count: int, is_slip39: bool, prefill_word: str = "" +) -> str: + prompt = TR.recovery__type_word_x_of_y_template.format(word_index + 1, word_count) + can_go_back = word_index > 0 + if is_slip39: + keyboard = RustLayout( + trezorui2.request_slip39( + prompt=prompt, prefill_word=prefill_word, can_go_back=can_go_back + ) + ) + else: + keyboard = RustLayout( + trezorui2.request_bip39( + prompt=prompt, prefill_word=prefill_word, can_go_back=can_go_back + ) + ) + + word: str = await ctx_wait(keyboard) + return word + + +async def show_remaining_shares( + groups: Iterable[tuple[int, tuple[str, ...]]], # remaining + list 3 words + shares_remaining: list[int], + group_threshold: int, +) -> None: + from trezor import strings + from trezor.crypto.slip39 import MAX_SHARE_COUNT + + pages: list[tuple[str, str]] = [] + for remaining, group in groups: + if 0 < remaining < MAX_SHARE_COUNT: + title = strings.format_plural( + TR.recovery__x_more_items_starting_template_plural, + remaining, + TR.plurals__x_shares_needed, + ) + words = "\n".join(group) + pages.append((title, words)) + elif ( + remaining == MAX_SHARE_COUNT and shares_remaining.count(0) < group_threshold + ): + groups_remaining = group_threshold - shares_remaining.count(0) + title = strings.format_plural( + TR.recovery__x_more_items_starting_template_plural, + groups_remaining, + TR.plurals__x_groups_needed, + ) + words = "\n".join(group) + pages.append((title, words)) + + await raise_if_not_confirmed( + interact( + RustLayout(trezorui2.show_remaining_shares(pages=pages)), + "show_shares", + ButtonRequestType.Other, + ) + ) + + +async def show_group_share_success(share_index: int, group_index: int) -> None: + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.show_group_share_success( + lines=[ + TR.recovery__you_have_entered, + TR.recovery__share_num_template.format(share_index + 1), + TR.words__from, + TR.recovery__group_num_template.format(group_index + 1), + ], + ) + ), + "share_success", + ButtonRequestType.Other, + ) + ) + + +async def continue_recovery( + button_label: str, + text: str, + subtext: str | None, + info_func: Callable | None, + dry_run: bool, + show_info: bool = False, # unused on TT +) -> bool: + from ..common import button_request + + if show_info: + # Show this just one-time + description = TR.recovery__only_first_n_letters + else: + description = subtext or "" + + homepage = RustLayout( + trezorui2.confirm_recovery( + title=text, + description=description, + button=button_label.upper(), + info_button=info_func is not None, + dry_run=dry_run, + ) + ) + + await button_request("recovery", ButtonRequestType.RecoveryHomepage) + + if info_func is not None: + return await _is_confirmed_info(homepage, info_func) + else: + result = await ctx_wait(homepage) + return result is CONFIRMED + + +async def show_recovery_warning( + br_type: str, + content: str, + subheader: str | None = None, + button: str | None = None, + br_code: ButtonRequestType = ButtonRequestType.Warning, +) -> None: + button = button or TR.buttons__try_again # def_arg + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.show_warning( + title=content, + description=subheader or "", + button=button.upper(), + allow_cancel=False, + ) + ), + br_type, + br_code, + ) + ) diff --git a/core/src/trezor/ui/layouts/mercury/reset.py b/core/src/trezor/ui/layouts/mercury/reset.py new file mode 100644 index 000000000..0dd51dc96 --- /dev/null +++ b/core/src/trezor/ui/layouts/mercury/reset.py @@ -0,0 +1,369 @@ +from typing import TYPE_CHECKING + +import trezorui2 +from trezor import TR +from trezor.enums import ButtonRequestType +from trezor.wire import ActionCancelled +from trezor.wire.context import wait as ctx_wait + +from ..common import interact +from . import RustLayout, raise_if_not_confirmed + +if TYPE_CHECKING: + from typing import Callable, Sequence + + from trezor.enums import BackupType + + +CONFIRMED = trezorui2.CONFIRMED # global_import_cache + + +def _split_share_into_pages(share_words: Sequence[str], per_page: int = 4) -> list[str]: + pages: list[str] = [] + current = "" + fill = 2 + + for i, word in enumerate(share_words): + if i % per_page == 0: + if i != 0: + pages.append(current) + current = "" + + # Align numbers to the right. + lastnum = i + per_page + 1 + fill = 1 if lastnum < 10 else 2 + else: + current += "\n" + current += f"{i + 1:>{fill}}. {word}" + + if current: + pages.append(current) + + return pages + + +async def show_share_words( + share_words: Sequence[str], + share_index: int | None = None, + group_index: int | None = None, +) -> None: + if share_index is None: + title = TR.reset__recovery_seed_title + elif group_index is None: + title = TR.reset__recovery_share_title_template.format(share_index + 1) + else: + title = TR.reset__group_share_title_template.format( + group_index + 1, share_index + 1 + ) + + pages = _split_share_into_pages(share_words) + + result = await interact( + RustLayout( + trezorui2.show_share_words( + title=title, + pages=pages, + ), + ), + "backup_words", + ButtonRequestType.ResetDevice, + ) + if result != CONFIRMED: + raise ActionCancelled + + +async def select_word( + words: Sequence[str], + share_index: int | None, + checked_index: int, + count: int, + group_index: int | None = None, +) -> str: + if share_index is None: + title: str = TR.reset__check_seed_title + elif group_index is None: + title = TR.reset__check_share_title_template.format(share_index + 1) + else: + title = TR.reset__check_group_share_title_template.format( + group_index + 1, share_index + 1 + ) + + # It may happen (with a very low probability) + # that there will be less than three unique words to choose from. + # In that case, duplicating the last word to make it three. + words = list(words) + while len(words) < 3: + words.append(words[-1]) + + result = await ctx_wait( + RustLayout( + trezorui2.select_word( + title=title, + description=TR.reset__select_word_x_of_y_template.format( + checked_index + 1, count + ), + words=(words[0], words[1], words[2]), + ) + ) + ) + if __debug__ and isinstance(result, str): + return result + assert isinstance(result, int) and 0 <= result <= 2 + return words[result] + + +async def slip39_show_checklist(step: int, backup_type: BackupType) -> None: + from trezor.enums import BackupType + + assert backup_type in (BackupType.Slip39_Basic, BackupType.Slip39_Advanced) + + items = ( + ( + TR.reset__slip39_checklist_set_num_shares, + TR.reset__slip39_checklist_set_threshold, + TR.reset__slip39_checklist_write_down_recovery, + ) + if backup_type == BackupType.Slip39_Basic + else ( + TR.reset__slip39_checklist_set_num_groups, + TR.reset__slip39_checklist_set_num_shares, + TR.reset__slip39_checklist_set_sizes_longer, + ) + ) + + result = await interact( + RustLayout( + trezorui2.show_checklist( + title=TR.reset__slip39_checklist_title, + button=TR.buttons__continue, + active=step, + items=items, + ) + ), + "slip39_checklist", + ButtonRequestType.ResetDevice, + ) + if result != CONFIRMED: + raise ActionCancelled + + +async def _prompt_number( + title: str, + description: Callable[[int], str], + info: Callable[[int], str], + count: int, + min_count: int, + max_count: int, + br_name: str, +) -> int: + num_input = RustLayout( + trezorui2.request_number( + title=title.upper(), + description=description, + count=count, + min_count=min_count, + max_count=max_count, + ) + ) + + while True: + result = await interact( + num_input, + br_name, + ButtonRequestType.ResetDevice, + ) + if __debug__: + if not isinstance(result, tuple): + # DebugLink currently can't send number of shares and it doesn't + # change the counter either so just use the initial value. + result = (result, count) + status, value = result + + if status == CONFIRMED: + assert isinstance(value, int) + return value + + await ctx_wait( + RustLayout( + trezorui2.show_simple( + title=None, + description=info(value), + button=TR.buttons__ok_i_understand, + ) + ) + ) + num_input.request_complete_repaint() + + +async def slip39_prompt_threshold( + num_of_shares: int, group_id: int | None = None +) -> int: + count = num_of_shares // 2 + 1 + # min value of share threshold is 2 unless the number of shares is 1 + # number of shares 1 is possible in advanced slip39 + min_count = min(2, num_of_shares) + max_count = num_of_shares + + def description(count: int) -> str: + if group_id is None: + if count == 1: + return TR.reset__you_need_one_share + elif count == max_count: + return TR.reset__need_all_share_template.format(count) + else: + return TR.reset__need_any_share_template.format(count) + else: + return TR.reset__num_shares_for_group_template.format(group_id + 1) + + def info(count: int) -> str: + # TODO: this is madness... + text = TR.reset__the_threshold_sets_the_number_of_shares + if group_id is None: + text += TR.reset__needed_to_recover_your_wallet + text += TR.reset__set_it_to_count_template.format(count) + if num_of_shares == 1: + text += TR.reset__one_share + elif num_of_shares == count: + text += TR.reset__all_x_of_y_template.format(count, num_of_shares) + else: + text += TR.reset__any_x_of_y_template.format(count, num_of_shares) + text += "." + else: + text += TR.reset__needed_to_form_a_group + text += TR.reset__set_it_to_count_template.format(count) + if num_of_shares == 1: + text += TR.reset__one_share + " " + elif num_of_shares == count: + text += TR.reset__all_x_of_y_template.format(count, num_of_shares) + else: + text += TR.reset__any_x_of_y_template.format(count, num_of_shares) + text += " " + TR.reset__to_form_group_template.format(group_id + 1) + return text + + return await _prompt_number( + TR.reset__title_set_threshold, + description, + info, + count, + min_count, + max_count, + "slip39_threshold", + ) + + +async def slip39_prompt_number_of_shares(group_id: int | None = None) -> int: + count = 5 + min_count = 1 + max_count = 16 + + def description(i: int): + if group_id is None: + if i == 1: + return TR.reset__only_one_share_will_be_created + else: + return TR.reset__num_of_share_holders_template.format(i) + else: + return TR.reset__total_number_of_shares_in_group_template.format( + group_id + 1 + ) + + if group_id is None: + info = TR.reset__num_of_shares_basic_info + else: + info = TR.reset__num_of_shares_advanced_info_template.format(group_id + 1) + + return await _prompt_number( + TR.reset__title_set_number_of_shares, + description, + lambda i: info, + count, + min_count, + max_count, + "slip39_shares", + ) + + +async def slip39_advanced_prompt_number_of_groups() -> int: + count = 5 + min_count = 2 + max_count = 16 + description = TR.reset__group_description + info = TR.reset__group_info + + return await _prompt_number( + TR.reset__title_set_number_of_groups, + lambda i: description, + lambda i: info, + count, + min_count, + max_count, + "slip39_groups", + ) + + +async def slip39_advanced_prompt_group_threshold(num_of_groups: int) -> int: + count = num_of_groups // 2 + 1 + min_count = 1 + max_count = num_of_groups + description = TR.reset__required_number_of_groups + info = TR.reset__advanced_group_threshold_info + + return await _prompt_number( + TR.reset__title_set_group_threshold, + lambda i: description, + lambda i: info, + count, + min_count, + max_count, + "slip39_group_threshold", + ) + + +async def show_warning_backup(slip39: bool) -> None: + result = await interact( + RustLayout( + trezorui2.show_info( + title=TR.reset__never_make_digital_copy, + button=TR.buttons__ok_i_understand, + allow_cancel=False, + ) + ), + "backup_warning", + ButtonRequestType.ResetDevice, + ) + if result != CONFIRMED: + raise ActionCancelled + + +async def show_success_backup() -> None: + from . import show_success + + await show_success( + "success_backup", + TR.reset__use_your_backup, + TR.reset__your_backup_is_done, + ) + + +async def show_reset_warning( + br_type: str, + content: str, + subheader: str | None = None, + button: str | None = None, + br_code: ButtonRequestType = ButtonRequestType.Warning, +) -> None: + button = button or TR.buttons__try_again # def_arg + await raise_if_not_confirmed( + interact( + RustLayout( + trezorui2.show_warning( + title=subheader or "", + description=content, + button=button.upper(), + allow_cancel=False, + ) + ), + br_type, + br_code, + ) + )