feat(core): T3T1 ShareWords component

cepetr/ui-t3t1/apple-fix
obrusvit 2 months ago committed by Vít Obrusník
parent 10ad7fb3b0
commit e5522dab84

@ -1,5 +1,6 @@
use super::theme;
use crate::ui::{
constant::SPACING,
component::{
base::ComponentExt, label::Label, text::TextStyle, Child, Component, Event, EventCtx,
},
@ -10,7 +11,6 @@ use crate::ui::{
};
const TITLE_HEIGHT: i16 = 42;
const TITLE_SPACE: i16 = 2;
pub struct Frame<T, U> {
border: Insets,
@ -126,8 +126,9 @@ where
fn place(&mut self, bounds: Rect) -> Rect {
let (mut header_area, content_area) = bounds.split_top(TITLE_HEIGHT);
let content_area = content_area.inset(Insets::top(TITLE_SPACE));
let content_area = content_area.inset(Insets::top(SPACING));
header_area = header_area.inset(Insets::sides(SPACING));
if let Some(b) = &mut self.button {
let (rest, button_area) = header_area.split_right(TITLE_HEIGHT);
header_area = rest;

@ -2,7 +2,6 @@ use crate::{
strutil::TString,
ui::{
component::{text::TextStyle, Component, Event, EventCtx, Never},
constant::WIDTH,
geometry::{Alignment, Offset, Rect},
model_mercury::theme,
shape::{Renderer, Text},
@ -10,9 +9,9 @@ use crate::{
};
/// 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
/// 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.
pub struct Instructions<'a> {
@ -24,9 +23,9 @@ pub struct Instructions<'a> {
}
impl<'a> Instructions<'a> {
/// height for component with only instruction [px]
/// height of the component with only instruction [px]
pub const HEIGHT_SIMPLE: i16 = 18;
/// height for component with both description and instruction [px]
/// height of the component with both description and instruction [px]
pub const HEIGHT_DEFAULT: i16 = 37;
pub fn new<T: Into<TString<'a>>>(instruction: T) -> Self {
@ -73,7 +72,6 @@ impl<'a> Component for Instructions<'a> {
fn place(&mut self, bounds: Rect) -> Rect {
let h = bounds.height();
assert!(h == Instructions::HEIGHT_SIMPLE || h == Instructions::HEIGHT_DEFAULT);
assert!(bounds.width() == WIDTH);
self.area = bounds;
bounds
}

@ -23,6 +23,7 @@ mod page;
mod progress;
mod result;
mod scroll;
mod share_words;
mod simple_page;
mod swipe;
mod welcome_screen;
@ -58,6 +59,7 @@ pub use page::ButtonPage;
pub use progress::Progress;
pub use result::{ResultFooter, ResultScreen, ResultStyle};
pub use scroll::ScrollBar;
pub use share_words::ShareWords;
pub use simple_page::SimplePage;
pub use swipe::{Swipe, SwipeDirection};
pub use vertical_menu::{VerticalMenu, VerticalMenuChoiceMsg};

@ -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::{Instructions, 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
instructions: Instructions<'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(),
instructions: Instructions::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.instructions
.place(used_area.split_bottom(Instructions::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.instructions.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());
}
}

@ -20,3 +20,8 @@ pub const fn screen() -> Rect {
Rect::from_top_left_and_size(Point::zero(), SIZE)
}
pub const SCREEN: Rect = screen();
/// Spacing between components (e.g. header and main content) and offsets from
/// the side of the screen. Generally applied everywhere except the top side of
/// the header. [px]
pub const SPACING: i16 = 2;

@ -1,4 +1,5 @@
use core::{cmp::Ordering, convert::TryInto};
use heapless::Vec;
use crate::{
error::Error,
@ -52,8 +53,8 @@ use super::{
FidoMsg, Frame, FrameMsg, Homescreen, HomescreenMsg, IconDialog, Lockscreen, MnemonicInput,
MnemonicKeyboard, MnemonicKeyboardMsg, NumberInputDialog, NumberInputDialogMsg,
PassphraseKeyboard, PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg, Progress,
SelectWordCount, SelectWordCountMsg, SelectWordMsg, SimplePage, Slip39Input, VerticalMenu,
VerticalMenuChoiceMsg,
SelectWordCount, SelectWordCountMsg, SelectWordMsg, ShareWords, SimplePage, Slip39Input,
VerticalMenu, VerticalMenuChoiceMsg,
},
theme,
};
@ -206,6 +207,15 @@ where
}
}
impl ComponentMsgObj for ShareWords<'_> {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
PageMsg::Confirmed => Ok(CONFIRMED.as_obj()),
_ => Err(Error::TypeError),
}
}
}
impl<T> ComponentMsgObj for VerticalMenu<T>
where
T: AsRef<str>,
@ -1350,20 +1360,12 @@ extern "C" fn new_show_tx_context_menu(n_args: usize, args: *const Obj, kwargs:
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 share_words_obj: Obj = kwargs.get(Qstr::MP_QSTR_pages)?;
let share_words_vec: Vec<TString, 33> = util::iter_into_vec(share_words_obj)?;
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(
title,
ButtonPage::<_, StrBuffer>::new(paragraphs.into_paragraphs(), theme::BG)
.with_hold()?
.without_cancel(),
))?;
let share_words = ShareWords::new(share_words_vec);
let frame_with_share_words = Frame::left_aligned(title, share_words).with_info_button();
let obj = LayoutObj::new(frame_with_share_words)?;
Ok(obj.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
@ -2081,7 +2083,7 @@ pub static mp_module_trezorui2: Module = obj_module! {
/// title: str,
/// pages: Iterable[str],
/// ) -> LayoutObj[UiResult]:
/// """Show mnemonic for backup. Expects the words pre-divided into individual pages."""
/// """Show mnemonic for backup."""
Qstr::MP_QSTR_show_share_words => obj_fn_kw!(0, new_show_share_words).as_obj(),
/// def request_number(

@ -96,7 +96,7 @@ impl Shape<'_> for CornerHighlight {
)
}
fn draw(&mut self, canvas: &mut dyn Canvas, cache: &DrawingCache<'_>) {
fn draw(&mut self, canvas: &mut dyn Canvas, _cache: &DrawingCache<'_>) {
let align: Alignment2D = self.corner.into();
// base circle
@ -150,7 +150,7 @@ impl Shape<'_> for CornerHighlight {
});
}
fn cleanup(&mut self, cache: &super::DrawingCache<'_>) {}
fn cleanup(&mut self, _cache: &super::DrawingCache<'_>) {}
}
impl<'s> ShapeClone<'s> for CornerHighlight {

@ -401,7 +401,7 @@ def show_share_words(
title: str,
pages: Iterable[str],
) -> LayoutObj[UiResult]:
"""Show mnemonic for backup. Expects the words pre-divided into individual pages."""
"""Show mnemonic for backup."""
# rust/src/ui/model_mercury/layout.rs

@ -18,30 +18,6 @@ if TYPE_CHECKING:
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,
@ -56,13 +32,11 @@ async def show_share_words(
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,
pages=share_words,
),
),
"backup_words",
@ -80,11 +54,11 @@ async def select_word(
group_index: int | None = None,
) -> str:
if share_index is None:
title: str = TR.reset__check_seed_title
description: str = TR.reset__check_seed_title
elif group_index is None:
title = TR.reset__check_share_title_template.format(share_index + 1)
description: str = TR.reset__check_share_title_template.format(share_index + 1)
else:
title = TR.reset__check_group_share_title_template.format(
description: str = TR.reset__check_group_share_title_template.format(
group_index + 1, share_index + 1
)
@ -98,10 +72,10 @@ async def select_word(
result = await ctx_wait(
RustLayout(
trezorui2.select_word(
title=title,
description=TR.reset__select_word_x_of_y_template.format(
title=TR.reset__select_word_x_of_y_template.format(
checked_index + 1, count
),
description=description,
words=(words[0], words[1], words[2]),
)
)

@ -174,24 +174,9 @@ def click_through(
def read_and_confirm_mnemonic(
debug: "DebugLink", choose_wrong: bool = False
) -> Generator[None, "ButtonRequest", Optional[str]]:
# TODO: these are very similar, reuse some code
if debug.model is models.T2T1:
mnemonic = yield from read_and_confirm_mnemonic_tt(debug, choose_wrong)
elif debug.model is models.T2B1:
mnemonic = yield from read_and_confirm_mnemonic_tr(debug, choose_wrong)
elif debug.model is models.T3T1:
mnemonic = yield from read_and_confirm_mnemonic_tt(debug, choose_wrong)
else:
raise ValueError(f"Unknown model: {debug.model}")
return mnemonic
def read_and_confirm_mnemonic_tt(
debug: "DebugLink", choose_wrong: bool = False
) -> Generator[None, "ButtonRequest", Optional[str]]:
"""Read a given number of mnemonic words from Trezor T screen and correctly
answer confirmation questions. Return the full mnemonic.
"""Read a given number of mnemonic words from the screen and answer
confirmation questions.
Return the full mnemonic or None if `choose_wrong` is True.
For use in an input flow function.
Example:
@ -201,6 +186,23 @@ def read_and_confirm_mnemonic_tt(
mnemonic = yield from read_and_confirm_mnemonic(client.debug)
"""
if debug.model is models.T2T1:
mnemonic = yield from read_mnemonic_from_screen_tt(debug)
elif debug.model is models.T2B1:
mnemonic = yield from read_mnemonic_from_screen_tr(debug)
elif debug.model is models.T3T1:
mnemonic = yield from read_mnemonic_from_screen_mercury(debug)
else:
raise ValueError(f"Unknown model: {debug.model}")
if not check_share(debug, mnemonic, choose_wrong):
return None
return " ".join(mnemonic)
def read_mnemonic_from_screen_tt(
debug: "DebugLink",
) -> Generator[None, "ButtonRequest", list[str]]:
mnemonic: list[str] = []
br = yield
assert br.pages is not None
@ -215,27 +217,12 @@ def read_and_confirm_mnemonic_tt(
debug.swipe_up()
debug.press_yes()
# check share
for _ in range(3):
# Word position is the first number in the text
word_pos_match = re.search(r"\d+", debug.wait_layout().text_content())
assert word_pos_match is not None
word_pos = int(word_pos_match.group(0))
index = word_pos - 1
if choose_wrong:
debug.input(mnemonic[(index + 1) % len(mnemonic)])
return None
else:
debug.input(mnemonic[index])
return " ".join(mnemonic)
return mnemonic
def read_and_confirm_mnemonic_tr(
debug: "DebugLink", choose_wrong: bool = False
) -> Generator[None, "ButtonRequest", Optional[str]]:
def read_mnemonic_from_screen_tr(
debug: "DebugLink",
) -> Generator[None, "ButtonRequest", list[str]]:
mnemonic: list[str] = []
yield # write down all 12 words in order
debug.press_yes()
@ -250,20 +237,47 @@ def read_and_confirm_mnemonic_tr(
yield # Select correct words...
debug.press_right()
return mnemonic
# check share
def read_mnemonic_from_screen_mercury(
debug: "DebugLink",
) -> Generator[None, "ButtonRequest", list[str]]:
mnemonic: list[str] = []
br = yield
assert br.pages is not None
debug.wait_layout()
for i in range(br.pages):
words = debug.wait_layout().seed_words()
mnemonic.extend(words)
debug.swipe_up()
return mnemonic
def check_share(
debug: "DebugLink", mnemonic: list[str], choose_wrong: bool = False
) -> bool:
"""
Given the mnemonic word list, proceed with the backup check:
three rounds of `Select word X of Y` choices.
"""
for _ in range(3):
word_pos_match = re.search(r"\d+", debug.wait_layout().title())
# Word position is the first number in the text
word_pos_match = re.search(r"\d+", debug.wait_layout().text_content())
assert word_pos_match is not None
word_pos = int(word_pos_match.group(0))
index = word_pos - 1
if choose_wrong:
debug.input(mnemonic[(index + 1) % len(mnemonic)])
return None
return False
else:
debug.input(mnemonic[index])
return " ".join(mnemonic)
return True
def click_info_button_tt(debug: "DebugLink"):

Loading…
Cancel
Save