1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2024-11-22 23:48:12 +00:00

feat(core): T3T1 Footer component

This commit is contained in:
obrusvit 2024-03-25 10:26:27 +01:00 committed by Martin Milata
parent 80462282dc
commit befaf87f2d
5 changed files with 303 additions and 12 deletions

View File

@ -317,6 +317,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 {

View File

@ -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<TString<'a>>,
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<T: Into<TString<'a>>>(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<T: Into<TString<'a>>>(self, description: T) -> Self {
Self {
text_description: Some(description.into()),
..self
}
}
pub fn update_instruction<T: Into<TString<'a>>>(&mut self, ctx: &mut EventCtx, s: T) {
self.text_instruction = s.into();
ctx.request_paint();
}
pub fn update_description<T: Into<TString<'a>>>(&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<Self::Msg> {
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);
});
}
}

View File

@ -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;

View File

@ -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<TString<'a>, 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<TString<'a>, 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<Self::Msg> {
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());
}
}

View File

@ -128,15 +128,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)
}
}