diff --git a/core/embed/rust/src/ui/display/font.rs b/core/embed/rust/src/ui/display/font.rs index 311e55a05..1f751756d 100644 --- a/core/embed/rust/src/ui/display/font.rs +++ b/core/embed/rust/src/ui/display/font.rs @@ -307,6 +307,16 @@ impl Font { text.len() // it fits in its entirety } + + pub fn visible_text_height_ex(&self, text: &str) -> (i16, i16) { + let (mut ascent, mut descent) = (0, 0); + for c in text.chars() { + let glyph = self.get_glyph(c); + ascent = ascent.max(glyph.bearing_y); + descent = descent.max(glyph.height - glyph.bearing_y); + } + (ascent, descent) + } } pub trait GlyphMetrics { diff --git a/core/embed/rust/src/ui/model_mercury/component/footer.rs b/core/embed/rust/src/ui/model_mercury/component/footer.rs new file mode 100644 index 000000000..d99491c4d --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/footer.rs @@ -0,0 +1,128 @@ +use crate::{ + strutil::TString, + ui::{ + component::{text::TextStyle, Component, Event, EventCtx, Never}, + constant::WIDTH, + geometry::{Alignment, Offset, Rect}, + model_mercury::theme, + shape::{Renderer, Text}, + }, +}; + +/// Component showing a task instruction (e.g. "Swipe up") and optionally task +/// description (e.g. "Confirm transaction") to a user. The component +/// is typically placed at the bottom of the screen. The height of the provided +/// area must be 18px (only instruction) or 37px (both description and +/// instruction). The content and style of both description and instruction is +/// configurable separatedly. +pub struct Footer<'a> { + area: Rect, + text_instruction: TString<'a>, + text_description: Option>, + style_instruction: &'static TextStyle, + style_description: &'static TextStyle, +} + +impl<'a> Footer<'a> { + /// height of the component with only instruction [px] + pub const HEIGHT_SIMPLE: i16 = 18; + /// height for component with both description and instruction [px] + pub const HEIGHT_DEFAULT: i16 = 37; + + pub fn new>>(instruction: T) -> Self { + Self { + area: Rect::zero(), + text_instruction: instruction.into(), + text_description: None, + style_instruction: &theme::TEXT_SUB, + style_description: &theme::TEXT_SUB, + } + } + + pub fn with_description>>(self, description: T) -> Self { + Self { + text_description: Some(description.into()), + ..self + } + } + + pub fn update_instruction>>(&mut self, ctx: &mut EventCtx, s: T) { + self.text_instruction = s.into(); + ctx.request_paint(); + } + + pub fn update_description>>(&mut self, ctx: &mut EventCtx, s: T) { + self.text_description = Some(s.into()); + ctx.request_paint(); + } + + pub fn update_instruction_style(&mut self, ctx: &mut EventCtx, style: &'static TextStyle) { + self.style_instruction = style; + ctx.request_paint(); + } + + pub fn update_description_style(&mut self, ctx: &mut EventCtx, style: &'static TextStyle) { + self.style_description = style; + ctx.request_paint(); + } +} + +impl<'a> Component for Footer<'a> { + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + let h = bounds.height(); + assert!(h == Footer::HEIGHT_SIMPLE || h == Footer::HEIGHT_DEFAULT); + self.area = bounds; + bounds + } + + fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option { + None + } + + fn paint(&mut self) { + // TODO: remove when ui-t3t1 done + todo!() + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + // show description only if there is space for it + if self.area.height() == Footer::HEIGHT_DEFAULT { + if let Some(description) = self.text_description { + let area_description = self.area.split_top(Footer::HEIGHT_SIMPLE).0; + let text_description_font_descent = self + .style_description + .text_font + .visible_text_height_ex("Ay") + .1; + let text_description_baseline = + area_description.bottom_center() - Offset::y(text_description_font_descent); + + description.map(|t| { + Text::new(text_description_baseline, t) + .with_font(self.style_description.text_font) + .with_fg(self.style_description.text_color) + .with_align(Alignment::Center) + .render(target); + }); + } + } + + let area_instruction = self.area.split_bottom(Footer::HEIGHT_SIMPLE).1; + let text_instruction_font_descent = self + .style_instruction + .text_font + .visible_text_height_ex("Ay") + .1; + let text_instruction_baseline = + area_instruction.bottom_center() - Offset::y(text_instruction_font_descent); + self.text_instruction.map(|t| { + Text::new(text_instruction_baseline, t) + .with_font(self.style_instruction.text_font) + .with_fg(self.style_instruction.text_color) + .with_align(Alignment::Center) + .render(target); + }); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/mod.rs b/core/embed/rust/src/ui/model_mercury/component/mod.rs index 598c01ce5..c9a95b3ba 100644 --- a/core/embed/rust/src/ui/model_mercury/component/mod.rs +++ b/core/embed/rust/src/ui/model_mercury/component/mod.rs @@ -4,6 +4,7 @@ mod button; mod coinjoin_progress; mod dialog; mod fido; +mod footer; mod vertical_menu; #[rustfmt::skip] mod fido_icons; @@ -18,6 +19,17 @@ pub use button::{ }; pub use error::ErrorScreen; pub use frame::{Frame, FrameMsg}; +#[cfg(feature = "micropython")] +pub use homescreen::{check_homescreen_format, Homescreen, HomescreenMsg, Lockscreen}; +pub use footer::Footer; +pub use keyboard::{ + bip39::Bip39Input, + mnemonic::{MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg}, + passphrase::{PassphraseKeyboard, PassphraseKeyboardMsg}, + pin::{PinKeyboard, PinKeyboardMsg}, + slip39::Slip39Input, + word_count::{SelectWordCount, SelectWordCountMsg}, +}; pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet}; pub use result::{ResultFooter, ResultScreen, ResultStyle}; pub use scroll::ScrollBar; diff --git a/core/embed/rust/src/ui/model_mercury/component/share_words.rs b/core/embed/rust/src/ui/model_mercury/component/share_words.rs new file mode 100644 index 000000000..5d3cfb9dc --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/component/share_words.rs @@ -0,0 +1,153 @@ +use super::theme; +use crate::{ + strutil::TString, + ui::{ + component::{Component, Event, EventCtx, PageMsg, Paginate}, + constant::SPACING, + geometry::{Alignment, Alignment2D, Insets, Offset, Rect}, + model_mercury::component::{Footer, Swipe, SwipeDirection}, + shape, + shape::Renderer, + }, +}; +use heapless::{String, Vec}; + +const MAX_WORDS: usize = 33; // super-shamir has 33 words, all other have less + +/// Component showing mnemonic/share words during backup procedure. Model T3T1 +/// contains one word per screen. A user is instructed to swipe up/down to see +/// next/previous word. +pub struct ShareWords<'a> { + area: Rect, + share_words: Vec, MAX_WORDS>, + page_index: usize, + /// Area reserved for a shown word from mnemonic/share + area_word: Rect, + /// TODO: review when swipe concept done for T3T1 + swipe: Swipe, + /// Footer component for instructions and word counting + footer: Footer<'static>, +} + +impl<'a> ShareWords<'a> { + const AREA_WORD_HEIGHT: i16 = 91; + + pub fn new(share_words: Vec, MAX_WORDS>) -> Self { + Self { + area: Rect::zero(), + share_words, + page_index: 0, + area_word: Rect::zero(), + swipe: Swipe::new().up().down(), + footer: Footer::new("Swipe up"), + } + } + + fn is_final_page(&self) -> bool { + self.page_index == self.share_words.len() - 1 + } +} + +impl<'a> Component for ShareWords<'a> { + type Msg = PageMsg<()>; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + let used_area = bounds + .inset(Insets::sides(SPACING)) + .inset(Insets::bottom(SPACING)); + + self.area_word = Rect::snap( + used_area.center(), + Offset::new(used_area.width(), ShareWords::AREA_WORD_HEIGHT), + Alignment2D::CENTER, + ); + + self.footer + .place(used_area.split_bottom(Footer::HEIGHT_SIMPLE).1); + + self.swipe.place(bounds); // Swipe possible on the whole screen area + self.area + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + ctx.set_page_count(self.share_words.len()); + let swipe = self.swipe.event(ctx, event); + match swipe { + Some(SwipeDirection::Up) => { + if self.is_final_page() { + return Some(PageMsg::Confirmed); + } + self.change_page(self.page_index + 1); + ctx.request_paint(); + } + Some(SwipeDirection::Down) => { + self.change_page(self.page_index.saturating_sub(1)); + ctx.request_paint(); + } + _ => (), + } + None + } + + fn paint(&mut self) { + // TODO: remove when ui-t3t1 done + } + + fn render<'s>(&'s self, target: &mut impl Renderer<'s>) { + // corner highlights + let (_, top_right_shape, bot_left_shape, bot_right_shape) = + shape::CornerHighlight::from_rect(self.area_word, theme::GREY_DARK, theme::BG); + top_right_shape.render(target); + bot_left_shape.render(target); + bot_right_shape.render(target); + + // the ordinal number of the current word + let ordinal_val = self.page_index as u8 + 1; + let ordinal_pos = self.area_word.top_left() + + Offset::y(theme::TEXT_SUB.text_font.visible_text_height("1")); + let ordinal = build_string!(3, inttostr!(ordinal_val), "."); + shape::Text::new(ordinal_pos, &ordinal) + .with_font(theme::TEXT_SUB.text_font) + .with_fg(theme::GREY) + .render(target); + + // the share word + let word = self.share_words[self.page_index]; + let word_baseline = self.area_word.center() + + Offset::y(theme::TEXT_SUPER.text_font.visible_text_height("A") / 2); + word.map(|w| { + shape::Text::new(word_baseline, w) + .with_font(theme::TEXT_SUPER.text_font) + .with_align(Alignment::Center) + .render(target); + }); + + // footer with instructions + self.footer.render(target); + } + + #[cfg(feature = "ui_bounds")] + fn bounds(&self, _sink: &mut dyn FnMut(Rect)) {} +} + +impl<'a> Paginate for ShareWords<'a> { + fn page_count(&mut self) -> usize { + self.share_words.len() + } + + fn change_page(&mut self, active_page: usize) { + self.page_index = active_page; + } +} + +#[cfg(feature = "ui_debug")] +impl<'a> crate::trace::Trace for ShareWords<'a> { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("ShareWords"); + let word = &self.share_words[self.page_index]; + let content = + word.map(|w| build_string!(50, inttostr!(self.page_index as u8 + 1), ". ", w, "\n")); + t.string("screen_content", content.as_str().into()); + } +} diff --git a/core/embed/rust/src/ui/shape/text.rs b/core/embed/rust/src/ui/shape/text.rs index cc83da8b2..d4feb9492 100644 --- a/core/embed/rust/src/ui/shape/text.rs +++ b/core/embed/rust/src/ui/shape/text.rs @@ -120,15 +120,3 @@ impl<'a, 's> ShapeClone<'s> for Text<'a> { Some(clone.uninit.init(Text { text, ..self })) } } - -impl Font { - fn visible_text_height_ex(&self, text: &str) -> (i16, i16) { - let (mut ascent, mut descent) = (0, 0); - for c in text.chars() { - let glyph = self.get_glyph(c); - ascent = ascent.max(glyph.bearing_y); - descent = descent.max(glyph.height - glyph.bearing_y); - } - (ascent, descent) - } -}