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:
parent
aaa2ece3ba
commit
c73115df7b
1
core/.changelog.d/3917.added
Normal file
1
core/.changelog.d/3917.added
Normal file
@ -0,0 +1 @@
|
||||
[T3T1] added word counter during wallet creation
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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")]
|
||||
|
@ -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");
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user