1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2024-12-23 14:58:09 +00:00

feat(core/ui): add PageCounter to T3T1 Footer

PageCounter sub-component is used within Footer to indicate progress in
screens, rendered e.g. as "1 / 20" for the first word in wallet backup.
This commit is contained in:
obrusvit 2024-06-13 23:07:01 +02:00 committed by matejcik
parent aaa2ece3ba
commit c73115df7b
6 changed files with 226 additions and 71 deletions

View File

@ -0,0 +1 @@
[T3T1] added word counter during wallet creation

View File

@ -2,9 +2,9 @@ use crate::{
strutil::TString,
ui::{
component::{text::TextStyle, Component, Event, EventCtx, Never, SwipeDirection},
display::Color,
display::{Color, Font},
event::SwipeEvent,
geometry::{Alignment, Offset, Rect},
geometry::{Alignment, Alignment2D, Offset, Point, Rect},
lerp::Lerp,
model_mercury::theme,
shape,
@ -12,38 +12,46 @@ use crate::{
},
};
/// Component showing a task instruction (e.g. "Swipe up") and optionally task
/// description (e.g. "Confirm transaction") to a user. A host of this component
/// is responsible of providing the exact area considering also the spacing. The
/// height must be 18px (only instruction) or 37px (both description and
/// instruction). The content and style of both description and instruction is
/// configurable separatedly.
use heapless::String;
/// Component showing a task instruction, e.g. "Swipe up", and an optional
/// content consisting of one of these:
/// - a task description e.g. "Confirm transaction", or
/// - a page counter e.g. "1 / 3", meaning the first screen of three total.
/// A host of this component is responsible of providing the exact area
/// considering also the spacing. The height must be 18px (only instruction) or
/// 37px (instruction and description/position).
#[derive(Clone)]
pub struct Footer<'a> {
area: Rect,
text_instruction: TString<'a>,
text_description: Option<TString<'a>>,
style_instruction: &'static TextStyle,
style_description: &'static TextStyle,
instruction: TString<'a>,
content: Option<FooterContent<'a>>,
swipe_allow_up: bool,
swipe_allow_down: bool,
progress: i16,
dir: SwipeDirection,
}
#[derive(Clone)]
enum FooterContent<'a> {
Description(TString<'a>),
PageCounter(PageCounter),
}
impl<'a> Footer<'a> {
/// height of the component with only instruction [px]
pub const HEIGHT_SIMPLE: i16 = 18;
/// height of the component with both description and instruction [px]
/// height of the component with instruction and additional content [px]
pub const HEIGHT_DEFAULT: i16 = 37;
const STYLE_INSTRUCTION: &'static TextStyle = &theme::TEXT_SUB_GREY;
const STYLE_DESCRIPTION: &'static TextStyle = &theme::TEXT_SUB_GREY_LIGHT;
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_GREY,
style_description: &theme::TEXT_SUB_GREY_LIGHT,
instruction: instruction.into(),
content: None,
swipe_allow_down: false,
swipe_allow_up: false,
progress: 0,
@ -53,33 +61,49 @@ impl<'a> Footer<'a> {
pub fn with_description<T: Into<TString<'a>>>(self, description: T) -> Self {
Self {
text_description: Some(description.into()),
content: Some(FooterContent::Description(description.into())),
..self
}
}
pub fn with_page_counter(self, max_pages: u8) -> Self {
Self {
content: Some(FooterContent::PageCounter(PageCounter::new(max_pages))),
..self
}
}
pub fn update_instruction<T: Into<TString<'a>>>(&mut self, ctx: &mut EventCtx, s: T) {
self.text_instruction = s.into();
self.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();
if let Some(ref mut content) = self.content {
if let Some(text) = content.as_description_mut() {
*text = s.into();
ctx.request_paint();
} else {
#[cfg(feature = "ui_debug")]
panic!("footer does not have description")
}
}
}
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();
pub fn update_page_counter(&mut self, ctx: &mut EventCtx, n: u8) {
if let Some(ref mut content) = self.content {
if let Some(counter) = content.as_page_counter_mut() {
counter.update_current_page(n);
ctx.request_paint();
} else {
#[cfg(feature = "ui_debug")]
panic!("footer does not have counter")
}
}
}
pub fn height(&self) -> i16 {
if self.text_description.is_some() {
if self.content.is_some() {
Footer::HEIGHT_DEFAULT
} else {
Footer::HEIGHT_SIMPLE
@ -106,8 +130,15 @@ impl<'a> Component for Footer<'a> {
fn place(&mut self, bounds: Rect) -> Rect {
let h = bounds.height();
assert!(h == Footer::HEIGHT_SIMPLE || h == Footer::HEIGHT_DEFAULT);
if let Some(content) = &mut self.content {
if let Some(counter) = content.as_page_counter_mut() {
if h == Footer::HEIGHT_DEFAULT {
counter.place(bounds.split_top(Footer::HEIGHT_SIMPLE).0);
}
}
}
self.area = bounds;
bounds
self.area
}
fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
@ -137,8 +168,7 @@ impl<'a> Component for Footer<'a> {
}
fn paint(&mut self) {
// TODO: remove when ui-t3t1 done
todo!()
todo!("remove when ui-t3t1 done")
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
@ -162,40 +192,45 @@ impl<'a> Component for Footer<'a> {
};
target.with_origin(offset, &|target| {
// show description only if there is space for it
// show description/counter 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);
if let Some(content) = &self.content {
match content {
FooterContent::Description(text) => {
let area_description = self.area.split_top(Footer::HEIGHT_SIMPLE).0;
let text_description_font_descent = Footer::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);
});
text.map(|t| {
Text::new(text_description_baseline, t)
.with_font(Footer::STYLE_DESCRIPTION.text_font)
.with_fg(Footer::STYLE_DESCRIPTION.text_color)
.with_align(Alignment::Center)
.render(target);
});
}
FooterContent::PageCounter(counter) => {
counter.render(target);
}
}
}
}
let area_instruction = self.area.split_bottom(Footer::HEIGHT_SIMPLE).1;
let text_instruction_font_descent = self
.style_instruction
let text_instruction_font_descent = Footer::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| {
self.instruction.map(|t| {
Text::new(text_instruction_baseline, t)
.with_font(self.style_instruction.text_font)
.with_fg(self.style_instruction.text_color)
.with_font(Footer::STYLE_INSTRUCTION.text_font)
.with_fg(Footer::STYLE_INSTRUCTION.text_color)
.with_align(Alignment::Center)
.render(target);
});
@ -218,9 +253,120 @@ impl<'a> Component for Footer<'a> {
impl crate::trace::Trace for Footer<'_> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Footer");
if let Some(description) = self.text_description {
t.string("description", description);
if let Some(content) = &self.content {
match content {
FooterContent::Description(text) => t.string("description", *text),
FooterContent::PageCounter(counter) => counter.trace(t),
}
}
t.string("instruction", self.text_instruction);
t.string("instruction", self.instruction);
}
}
impl<'a> FooterContent<'a> {
pub fn as_description_mut(&mut self) -> Option<&mut TString<'a>> {
match self {
FooterContent::Description(ref mut text) => Some(text),
_ => None,
}
}
pub fn as_page_counter_mut(&mut self) -> Option<&mut PageCounter> {
match self {
FooterContent::PageCounter(ref mut counter) => Some(counter),
_ => None,
}
}
}
/// Helper component used within Footer instead of description for page count
/// indication, rendered e.g. as: '1 / 20'.
#[derive(Clone)]
struct PageCounter {
area: Rect,
page_curr: u8,
page_max: u8,
font: Font,
}
impl PageCounter {
fn new(page_max: u8) -> Self {
Self {
area: Rect::zero(),
page_curr: 1,
page_max,
font: Font::SUB,
}
}
fn update_current_page(&mut self, new_value: u8) {
self.page_curr = new_value;
}
}
impl Component for PageCounter {
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<Self::Msg> {
None
}
fn paint(&mut self) {
todo!("remove when ui-t3t1 done")
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
let color = if self.page_curr == self.page_max {
theme::GREEN_LIGHT
} else {
theme::GREY_LIGHT
};
let string_curr = uformat!("{}", self.page_curr);
let string_max = uformat!("{}", self.page_max);
// center the whole counter "x / yz"
let offset_x = Offset::x(4); // spacing between foreslash and numbers
let width_num_curr = self.font.text_width(&string_curr);
let width_foreslash = theme::ICON_FORESLASH.toif.width();
let width_num_max = self.font.text_width(&string_max);
let width_total = width_num_curr + width_foreslash + width_num_max + 2 * offset_x.x;
let center_x = self.area.center().x;
let counter_y = self.font.vert_center(self.area.y0, self.area.y1, "0");
let counter_start_x = center_x - width_total / 2;
let counter_end_x = center_x + width_total / 2;
let base_num_curr = Point::new(counter_start_x, counter_y);
let base_foreslash = Point::new(counter_start_x + width_num_curr + offset_x.x, counter_y);
let base_num_max = Point::new(counter_end_x, counter_y);
Text::new(base_num_curr, &string_curr)
.with_align(Alignment::Start)
.with_fg(color)
.with_font(self.font)
.render(target);
shape::ToifImage::new(base_foreslash, theme::ICON_FORESLASH.toif)
.with_align(Alignment2D::BOTTOM_LEFT)
.with_fg(color)
.render(target);
Text::new(base_num_max, &string_max)
.with_align(Alignment::End)
.with_fg(color)
.with_font(self.font)
.render(target);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for PageCounter {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("PageCounter");
t.int("page current", self.page_curr.into());
t.int("page max", self.page_max.into());
}
}

View File

@ -153,6 +153,12 @@ where
self
}
#[inline(never)]
pub fn with_footer_counter(mut self, instruction: TString<'static>, max_value: u8) -> Self {
self.footer = Some(Footer::new(instruction).with_page_counter(max_value));
self
}
pub fn with_danger(self) -> Self {
self.button_styled(theme::button_danger())
.title_styled(theme::label_title_danger())
@ -195,6 +201,12 @@ where
res
}
pub fn update_footer_counter(&mut self, ctx: &mut EventCtx, new_val: u8) {
if let Some(footer) = &mut self.footer {
footer.update_page_counter(ctx, new_val);
}
}
#[inline(never)]
pub fn with_swipe(mut self, dir: SwipeDirection, settings: SwipeSettings) -> Self {
self.footer = self.footer.map(|f| match dir {

View File

@ -11,7 +11,7 @@ use crate::{
},
event::SwipeEvent,
geometry::{Alignment, Alignment2D, Insets, Offset, Rect},
model_mercury::component::{Footer, Frame, FrameMsg},
model_mercury::component::{Frame, FrameMsg},
shape::{self, Renderer},
util,
},
@ -46,13 +46,15 @@ impl<'a> ShareWords<'a> {
} else {
None
};
let n_words = share_words.len();
Self {
subtitle,
frame: Frame::left_aligned(title, ShareWordsInner::new(share_words))
.with_swipe(SwipeDirection::Up, SwipeSettings::default())
.with_swipe(SwipeDirection::Down, SwipeSettings::default())
.with_vertical_pages()
.with_subtitle(subtitle),
.with_subtitle(subtitle)
.with_footer_counter(TR::instructions__swipe_up.into(), n_words as u8),
repeated_indices,
}
}
@ -81,7 +83,7 @@ impl<'a> Component for ShareWords<'a> {
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let page_index = self.frame.inner().page_index as u8;
if let Some(repeated_indices) = &self.repeated_indices {
if repeated_indices.contains(&(page_index as usize)) {
if repeated_indices.contains(&page_index) {
let updated_subtitle = TString::from_translation(TR::reset__the_word_is_repeated);
self.frame
.update_subtitle(ctx, updated_subtitle, Some(theme::TEXT_SUB_GREEN_LIME));
@ -133,8 +135,6 @@ struct ShareWordsInner<'a> {
area_word: Rect,
/// `Some` when transition animation is in progress
animation: Option<Animation<f32>>,
/// Footer component for instructions and word counting
footer: Footer<'static>,
progress: i16,
}
@ -148,7 +148,6 @@ impl<'a> ShareWordsInner<'a> {
next_index: 0,
area_word: Rect::zero(),
animation: None,
footer: Footer::new(TR::instructions__swipe_up),
progress: 0,
}
}
@ -287,9 +286,6 @@ impl<'a> Component for ShareWordsInner<'a> {
self.render_word(self.page_index, target);
})
};
// footer with instructions
self.footer.render(target);
}
#[cfg(feature = "ui_bounds")]

View File

@ -57,6 +57,9 @@ pub const QR_SIDE_MAX: u32 = 140;
// UI icons (white color).
// 12x12
include_icon!(ICON_FORESLASH, "model_mercury/res/foreslash12.toif");
// 20x20
include_icon!(
ICON_BULLET_CHECKMARK,
@ -141,9 +144,6 @@ include_icon!(ICON_UP, "model_tt/res/caret-up24.toif");
include_icon!(ICON_DOWN, "model_tt/res/caret-down24.toif");
include_icon!(ICON_CLICK, "model_tt/res/finger24.toif");
include_icon!(ICON_CORNER_CANCEL, "model_tt/res/x32.toif");
include_icon!(ICON_CORNER_INFO, "model_tt/res/info32.toif");
// Homescreen notifications.
include_icon!(ICON_WARN, "model_tt/res/warning16.toif");
include_icon!(ICON_WARNING40, "model_tt/res/warning40.toif");

View File

@ -27,7 +27,7 @@ pub struct Text<'a> {
impl<'a> Text<'a> {
/// Creates a `shape::Text` structure with a specified
/// text (`str`) and the top-left corner (`pos`).
/// text (`str`) and the bottom-left corner (`pos`).
pub fn new(pos: Point, text: &'a str) -> Self {
Self {
pos,