feat(core): init T3T1 UI layouts

Start with copy of components and layouts from T2T1.
obrusvit/ui-t3t1-reset-device-apple-hotfix
Martin Milata 2 months ago committed by obrusvit
parent d5f5aeeb77
commit 6da7832104

@ -0,0 +1,222 @@
use heapless::Vec;
use crate::{
error::Error,
micropython::buffer::StrBuffer,
strutil::StringType,
translations::TR,
ui::{
component::{
text::paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt},
Component, Event, EventCtx, Paginate, Qr,
},
geometry::Rect,
shape::Renderer,
},
};
use super::{theme, Frame, FrameMsg};
const MAX_XPUBS: usize = 16;
pub struct AddressDetails<T> {
qr_code: Frame<Qr, T>,
details: Frame<Paragraphs<ParagraphVecShort<StrBuffer>>, T>,
xpub_view: Frame<Paragraphs<Paragraph<T>>, T>,
xpubs: Vec<(T, T), MAX_XPUBS>,
xpub_page_count: Vec<u8, MAX_XPUBS>,
current_page: usize,
}
impl<T> AddressDetails<T>
where
T: StringType,
{
pub fn new(
qr_title: T,
qr_address: T,
case_sensitive: bool,
details_title: T,
account: Option<StrBuffer>,
path: Option<StrBuffer>,
) -> Result<Self, Error>
where
T: From<&'static str>,
{
let mut para = ParagraphVecShort::new();
if let Some(a) = account {
para.add(Paragraph::new(
&theme::TEXT_NORMAL,
TR::words__account_colon.try_into()?,
));
para.add(Paragraph::new(&theme::TEXT_MONO, a));
}
if let Some(p) = path {
para.add(Paragraph::new(
&theme::TEXT_NORMAL,
TR::address_details__derivation_path.try_into()?,
));
para.add(Paragraph::new(&theme::TEXT_MONO, p));
}
let result = Self {
qr_code: Frame::left_aligned(
theme::label_title(),
qr_title,
Qr::new(qr_address, case_sensitive)?.with_border(7),
)
.with_cancel_button()
.with_border(theme::borders_horizontal_scroll()),
details: Frame::left_aligned(
theme::label_title(),
details_title,
para.into_paragraphs(),
)
.with_cancel_button()
.with_border(theme::borders_horizontal_scroll()),
xpub_view: Frame::left_aligned(
theme::label_title(),
" \n ".into(),
Paragraph::new(&theme::TEXT_MONO, "".into()).into_paragraphs(),
)
.with_cancel_button()
.with_border(theme::borders_horizontal_scroll()),
xpubs: Vec::new(),
xpub_page_count: Vec::new(),
current_page: 0,
};
Ok(result)
}
pub fn add_xpub(&mut self, title: T, xpub: T) -> Result<(), Error> {
self.xpubs
.push((title, xpub))
.map_err(|_| Error::OutOfRange)
}
fn switch_xpub(&mut self, i: usize, page: usize) -> usize
where
T: Clone,
{
// Context is needed for updating child so that it can request repaint. In this
// case the parent component that handles paging always requests complete
// repaint after page change so we can use a dummy context here.
let mut dummy_ctx = EventCtx::new();
self.xpub_view
.update_title(&mut dummy_ctx, self.xpubs[i].0.clone());
self.xpub_view.update_content(&mut dummy_ctx, |p| {
p.inner_mut().update(self.xpubs[i].1.clone());
let npages = p.page_count();
p.change_page(page);
npages
})
}
fn lookup(&self, scrollbar_page: usize) -> (usize, usize) {
let mut xpub_index = 0;
let mut xpub_page = scrollbar_page;
for page_count in self.xpub_page_count.iter().map(|pc| {
let upc: usize = (*pc).into();
upc
}) {
if page_count <= xpub_page {
xpub_page -= page_count;
xpub_index += 1;
} else {
break;
}
}
(xpub_index, xpub_page)
}
}
impl<T> Paginate for AddressDetails<T>
where
T: StringType + Clone,
{
fn page_count(&mut self) -> usize {
let total_xpub_pages: u8 = self.xpub_page_count.iter().copied().sum();
2usize.saturating_add(total_xpub_pages.into())
}
fn change_page(&mut self, to_page: usize) {
self.current_page = to_page;
if to_page > 1 {
let i = to_page - 2;
let (xpub_index, xpub_page) = self.lookup(i);
self.switch_xpub(xpub_index, xpub_page);
}
}
}
impl<T> Component for AddressDetails<T>
where
T: StringType + Clone,
{
type Msg = ();
fn place(&mut self, bounds: Rect) -> Rect {
self.qr_code.place(bounds);
self.details.place(bounds);
self.xpub_view.place(bounds);
self.xpub_page_count.clear();
for i in 0..self.xpubs.len() {
let npages = self.switch_xpub(i, 0) as u8;
unwrap!(self.xpub_page_count.push(npages));
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let msg = match self.current_page {
0 => self.qr_code.event(ctx, event),
1 => self.details.event(ctx, event),
_ => self.xpub_view.event(ctx, event),
};
match msg {
Some(FrameMsg::Button(_)) => Some(()),
_ => None,
}
}
fn paint(&mut self) {
match self.current_page {
0 => self.qr_code.paint(),
1 => self.details.paint(),
_ => self.xpub_view.paint(),
}
}
fn render(&mut self, target: &mut impl Renderer) {
match self.current_page {
0 => self.qr_code.render(target),
1 => self.details.render(target),
_ => self.xpub_view.render(target),
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
match self.current_page {
0 => self.qr_code.bounds(sink),
1 => self.details.bounds(sink),
_ => self.xpub_view.bounds(sink),
}
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for AddressDetails<T>
where
T: StringType,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("AddressDetails");
match self.current_page {
0 => t.child("qr_code", &self.qr_code),
1 => t.child("details", &self.details),
_ => t.child("xpub_view", &self.xpub_view),
}
}
}

@ -0,0 +1,183 @@
use core::mem;
use crate::{
error::Error,
maybe_trace::MaybeTrace,
micropython::buffer::StrBuffer,
translations::TR,
ui::{
canvas::algo::PI4,
component::{
base::Never, painter, Child, Component, ComponentExt, Empty, Event, EventCtx, Label,
Split,
},
constant,
display::loader::{loader_circular_uncompress, LoaderDimensions},
geometry::{Insets, Offset, Rect},
shape,
shape::Renderer,
util::animation_disabled,
},
};
use super::{theme, Frame};
const RECTANGLE_HEIGHT: i16 = 56;
const LABEL_TOP: i16 = 135;
const LOADER_OUTER: i16 = 39;
const LOADER_INNER: i16 = 28;
const LOADER_OFFSET: i16 = -34;
const LOADER_SPEED: u16 = 5;
pub struct CoinJoinProgress<T, U> {
value: u16,
indeterminate: bool,
content: Child<Frame<Split<Empty, U>, StrBuffer>>,
// Label is not a child since circular loader paints large black rectangle which overlaps it.
// To work around this, draw label every time loader is drawn.
label: Label<T>,
}
impl<T, U> CoinJoinProgress<T, U>
where
T: AsRef<str>,
{
pub fn new(
text: T,
indeterminate: bool,
) -> Result<CoinJoinProgress<T, impl Component<Msg = Never> + MaybeTrace>, Error>
where
T: AsRef<str>,
{
let style = theme::label_coinjoin_progress();
let label = Label::centered(
TryInto::<StrBuffer>::try_into(TR::coinjoin__title_do_not_disconnect)?,
style,
)
.vertically_centered();
let bg = painter::rect_painter(style.background_color, theme::BG);
let inner = (bg, label);
CoinJoinProgress::with_background(text, inner, indeterminate)
}
}
impl<T, U> CoinJoinProgress<T, U>
where
T: AsRef<str>,
U: Component<Msg = Never>,
{
pub fn with_background(text: T, inner: U, indeterminate: bool) -> Result<Self, Error> {
Ok(Self {
value: 0,
indeterminate,
content: Frame::centered(
theme::label_title(),
TR::coinjoin__title_progress.try_into()?,
Split::bottom(RECTANGLE_HEIGHT, 0, Empty, inner),
)
.into_child(),
label: Label::centered(text, theme::TEXT_NORMAL),
})
}
}
impl<T, U> Component for CoinJoinProgress<T, U>
where
T: AsRef<str>,
U: Component<Msg = Never>,
{
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.content.place(bounds);
let label_bounds = bounds.inset(Insets::top(LABEL_TOP));
self.label.place(label_bounds);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.content.event(ctx, event);
self.label.event(ctx, event);
match event {
_ if animation_disabled() => {
return None;
}
Event::Attach if self.indeterminate => {
ctx.request_anim_frame();
}
Event::Timer(EventCtx::ANIM_FRAME_TIMER) => {
self.value = (self.value + LOADER_SPEED) % 1000;
ctx.request_anim_frame();
ctx.request_paint();
}
Event::Progress(new_value, _new_description) => {
if mem::replace(&mut self.value, new_value) != new_value {
ctx.request_paint();
}
}
_ => {}
}
None
}
fn paint(&mut self) {
self.content.paint();
loader_circular_uncompress(
LoaderDimensions::new(LOADER_OUTER, LOADER_INNER),
LOADER_OFFSET,
theme::FG,
theme::BG,
self.value,
self.indeterminate,
None,
);
self.label.paint();
}
fn render(&mut self, target: &mut impl Renderer) {
self.content.render(target);
let center = constant::screen().center() + Offset::y(LOADER_OFFSET);
let active_color = theme::FG;
let background_color = theme::BG;
let inactive_color = background_color.blend(active_color, 85);
let start = (self.value - 100) % 1000;
let end = (self.value + 100) % 1000;
let start = ((start as i32 * 8 * PI4 as i32) / 1000) as i16;
let end = ((end as i32 * 8 * PI4 as i32) / 1000) as i16;
shape::Circle::new(center, LOADER_OUTER)
.with_bg(inactive_color)
.render(target);
shape::Circle::new(center, LOADER_OUTER)
.with_bg(active_color)
.with_start_angle(start)
.with_end_angle(end)
.render(target);
shape::Circle::new(center, LOADER_INNER + 2)
.with_bg(active_color)
.render(target);
shape::Circle::new(center, LOADER_INNER)
.with_bg(background_color)
.render(target);
self.label.render(target);
}
}
#[cfg(feature = "ui_debug")]
impl<T, U> crate::trace::Trace for CoinJoinProgress<T, U>
where
T: AsRef<str>,
U: Component + crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("CoinJoinProgress");
t.child("label", &self.label);
t.child("content", &self.content);
}
}

@ -0,0 +1,233 @@
use crate::{
strutil::StringType,
ui::{
component::{
image::BlendedImage,
text::{
paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt},
TextStyle,
},
Child, Component, Event, EventCtx, Never,
},
geometry::{Insets, LinearPlacement, Rect},
shape::Renderer,
},
};
use super::theme;
pub enum DialogMsg<T, U> {
Content(T),
Controls(U),
}
pub struct Dialog<T, U> {
content: Child<T>,
controls: Child<U>,
}
impl<T, U> Dialog<T, U>
where
T: Component,
U: Component,
{
pub fn new(content: T, controls: U) -> Self {
Self {
content: Child::new(content),
controls: Child::new(controls),
}
}
pub fn inner(&self) -> &T {
self.content.inner()
}
}
impl<T, U> Component for Dialog<T, U>
where
T: Component,
U: Component,
{
type Msg = DialogMsg<T::Msg, U::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
let controls_area = self.controls.place(bounds);
let content_area = bounds
.inset(Insets::bottom(controls_area.height()))
.inset(Insets::bottom(theme::BUTTON_SPACING))
.inset(Insets::left(theme::CONTENT_BORDER));
self.content.place(content_area);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.content
.event(ctx, event)
.map(Self::Msg::Content)
.or_else(|| self.controls.event(ctx, event).map(Self::Msg::Controls))
}
fn paint(&mut self) {
self.content.paint();
self.controls.paint();
}
fn render(&mut self, target: &mut impl Renderer) {
self.content.render(target);
self.controls.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.content.bounds(sink);
self.controls.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T, U> crate::trace::Trace for Dialog<T, U>
where
T: crate::trace::Trace,
U: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Dialog");
t.child("content", &self.content);
t.child("controls", &self.controls);
}
}
pub struct IconDialog<T, U> {
image: Child<BlendedImage>,
paragraphs: Paragraphs<ParagraphVecShort<T>>,
controls: Child<U>,
}
impl<T, U> IconDialog<T, U>
where
T: StringType,
U: Component,
{
pub fn new(icon: BlendedImage, title: T, controls: U) -> Self {
Self {
image: Child::new(icon),
paragraphs: Paragraphs::new(ParagraphVecShort::from_iter([Paragraph::new(
&theme::TEXT_DEMIBOLD,
title,
)
.centered()]))
.with_placement(
LinearPlacement::vertical()
.align_at_center()
.with_spacing(Self::VALUE_SPACE),
),
controls: Child::new(controls),
}
}
pub fn with_paragraph(mut self, para: Paragraph<T>) -> Self {
if !para.content().as_ref().is_empty() {
self.paragraphs.inner_mut().add(para);
}
self
}
pub fn with_text(self, style: &'static TextStyle, text: T) -> Self {
self.with_paragraph(Paragraph::new(style, text).centered())
}
pub fn with_description(self, description: T) -> Self {
self.with_text(&theme::TEXT_NORMAL_OFF_WHITE, description)
}
pub fn with_value(self, value: T) -> Self {
self.with_text(&theme::TEXT_MONO, value)
}
pub fn new_shares(lines: [T; 4], controls: U) -> Self {
let [l0, l1, l2, l3] = lines;
Self {
image: Child::new(BlendedImage::new(
theme::IMAGE_BG_CIRCLE,
theme::IMAGE_FG_SUCCESS,
theme::SUCCESS_COLOR,
theme::FG,
theme::BG,
)),
paragraphs: ParagraphVecShort::from_iter([
Paragraph::new(&theme::TEXT_NORMAL_OFF_WHITE, l0).centered(),
Paragraph::new(&theme::TEXT_DEMIBOLD, l1).centered(),
Paragraph::new(&theme::TEXT_NORMAL_OFF_WHITE, l2).centered(),
Paragraph::new(&theme::TEXT_DEMIBOLD, l3).centered(),
])
.into_paragraphs()
.with_placement(LinearPlacement::vertical().align_at_center()),
controls: Child::new(controls),
}
}
pub const ICON_AREA_PADDING: i16 = 2;
pub const ICON_AREA_HEIGHT: i16 = 60;
pub const VALUE_SPACE: i16 = 5;
}
impl<T, U> Component for IconDialog<T, U>
where
T: StringType,
U: Component,
{
type Msg = DialogMsg<Never, U::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
let bounds = bounds
.inset(theme::borders())
.inset(Insets::top(Self::ICON_AREA_PADDING));
let controls_area = self.controls.place(bounds);
let content_area = bounds.inset(Insets::bottom(controls_area.height()));
let (image_area, content_area) = content_area.split_top(Self::ICON_AREA_HEIGHT);
self.image.place(image_area);
self.paragraphs.place(content_area);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.paragraphs.event(ctx, event);
self.controls.event(ctx, event).map(Self::Msg::Controls)
}
fn paint(&mut self) {
self.image.paint();
self.paragraphs.paint();
self.controls.paint();
}
fn render(&mut self, target: &mut impl Renderer) {
self.image.render(target);
self.paragraphs.render(target);
self.controls.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.image.bounds(sink);
self.paragraphs.bounds(sink);
self.controls.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T, U> crate::trace::Trace for IconDialog<T, U>
where
T: StringType,
U: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("IconDialog");
t.child("image", &self.image);
t.child("content", &self.paragraphs);
t.child("controls", &self.controls);
}
}

@ -0,0 +1,246 @@
use crate::ui::{
component::{image::Image, Child, Component, Event, EventCtx, Label},
display,
geometry::{Insets, Rect},
model_mercury::component::{
fido_icons::get_fido_icon_data,
swipe::{Swipe, SwipeDirection},
theme, ScrollBar,
},
shape,
shape::Renderer,
};
use super::CancelConfirmMsg;
const ICON_HEIGHT: i16 = 70;
const SCROLLBAR_INSET_TOP: i16 = 5;
const SCROLLBAR_HEIGHT: i16 = 10;
const APP_NAME_PADDING: i16 = 12;
const APP_NAME_HEIGHT: i16 = 30;
pub enum FidoMsg {
Confirmed(usize),
Cancelled,
}
pub struct FidoConfirm<F: Fn(usize) -> T, T, U> {
page_swipe: Swipe,
app_name: Label<T>,
account_name: Label<T>,
icon: Child<Image>,
/// Function/closure that will return appropriate page on demand.
get_account: F,
scrollbar: ScrollBar,
fade: bool,
controls: U,
}
impl<F, T, U> FidoConfirm<F, T, U>
where
F: Fn(usize) -> T,
T: AsRef<str> + From<&'static str>,
U: Component<Msg = CancelConfirmMsg>,
{
pub fn new(
app_name: T,
get_account: F,
page_count: usize,
icon_name: Option<T>,
controls: U,
) -> Self {
let icon_data = get_fido_icon_data(icon_name.as_ref());
// Preparing scrollbar and setting its page-count.
let mut scrollbar = ScrollBar::horizontal();
scrollbar.set_count_and_active_page(page_count, 0);
// Preparing swipe component and setting possible initial
// swipe directions according to number of pages.
let mut page_swipe = Swipe::horizontal();
page_swipe.allow_right = scrollbar.has_previous_page();
page_swipe.allow_left = scrollbar.has_next_page();
Self {
app_name: Label::centered(app_name, theme::TEXT_DEMIBOLD),
account_name: Label::centered("".into(), theme::TEXT_DEMIBOLD),
page_swipe,
icon: Child::new(Image::new(icon_data)),
get_account,
scrollbar,
fade: false,
controls,
}
}
fn on_page_swipe(&mut self, ctx: &mut EventCtx, swipe: SwipeDirection) {
// Change the page number.
match swipe {
SwipeDirection::Left if self.scrollbar.has_next_page() => {
self.scrollbar.go_to_next_page();
}
SwipeDirection::Right if self.scrollbar.has_previous_page() => {
self.scrollbar.go_to_previous_page();
}
_ => {} // page did not change
};
// Disable swipes on the boundaries. Not allowing carousel effect.
self.page_swipe.allow_right = self.scrollbar.has_previous_page();
self.page_swipe.allow_left = self.scrollbar.has_next_page();
// Redraw the page.
ctx.request_paint();
// Reset backlight to normal level on next paint.
self.fade = true;
}
fn active_page(&self) -> usize {
self.scrollbar.active_page
}
}
impl<F, T, U> Component for FidoConfirm<F, T, U>
where
F: Fn(usize) -> T,
T: AsRef<str> + From<&'static str>,
U: Component<Msg = CancelConfirmMsg>,
{
type Msg = FidoMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.page_swipe.place(bounds);
// Place the control buttons.
let controls_area = self.controls.place(bounds);
// Get the image and content areas.
let content_area = bounds.inset(Insets::bottom(controls_area.height()));
let (image_area, content_area) = content_area.split_top(ICON_HEIGHT);
// In case of showing a scrollbar, getting its area and placing it.
let remaining_area = if self.scrollbar.page_count > 1 {
let (scrollbar_area, remaining_area) = content_area
.inset(Insets::top(SCROLLBAR_INSET_TOP))
.split_top(SCROLLBAR_HEIGHT);
self.scrollbar.place(scrollbar_area);
remaining_area
} else {
content_area
};
// Place the icon image.
self.icon.place(image_area);
// Place the text labels.
let (app_name_area, account_name_area) = remaining_area
.inset(Insets::top(APP_NAME_PADDING))
.split_top(APP_NAME_HEIGHT);
self.app_name.place(app_name_area);
self.account_name.place(account_name_area);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(swipe) = self.page_swipe.event(ctx, event) {
// Swipe encountered, update the page.
self.on_page_swipe(ctx, swipe);
}
if let Some(msg) = self.controls.event(ctx, event) {
// Some button was clicked, send results.
match msg {
CancelConfirmMsg::Confirmed => return Some(FidoMsg::Confirmed(self.active_page())),
CancelConfirmMsg::Cancelled => return Some(FidoMsg::Cancelled),
}
}
None
}
fn paint(&mut self) {
self.icon.paint();
self.controls.paint();
self.app_name.paint();
if self.scrollbar.page_count > 1 {
self.scrollbar.paint();
}
let current_account = (self.get_account)(self.active_page());
// Erasing the old text content before writing the new one.
let account_name_area = self.account_name.area();
let real_area = account_name_area
.with_height(account_name_area.height() + self.account_name.font().text_baseline() + 1);
display::rect_fill(real_area, theme::BG);
// Account name is optional.
// Showing it only if it differs from app name.
// (Dummy requests usually have some text as both app_name and account_name.)
if !current_account.as_ref().is_empty()
&& current_account.as_ref() != self.app_name.text().as_ref()
{
self.account_name.set_text(current_account);
self.account_name.paint();
}
if self.fade {
self.fade = false;
// Note that this is blocking and takes some time.
display::fade_backlight(theme::BACKLIGHT_NORMAL);
}
}
fn render(&mut self, target: &mut impl Renderer) {
self.icon.render(target);
self.controls.render(target);
self.app_name.render(target);
if self.scrollbar.page_count > 1 {
self.scrollbar.render(target);
}
let current_account = (self.get_account)(self.active_page());
// Erasing the old text content before writing the new one.
let account_name_area = self.account_name.area();
let real_area = account_name_area
.with_height(account_name_area.height() + self.account_name.font().text_baseline() + 1);
shape::Bar::new(real_area).with_bg(theme::BG).render(target);
// Account name is optional.
// Showing it only if it differs from app name.
// (Dummy requests usually have some text as both app_name and account_name.)
if !current_account.as_ref().is_empty()
&& current_account.as_ref() != self.app_name.text().as_ref()
{
self.account_name.set_text(current_account);
self.account_name.render(target);
}
if self.fade {
self.fade = false;
// Note that this is blocking and takes some time.
display::fade_backlight(theme::BACKLIGHT_NORMAL);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.icon.bounds(sink);
self.app_name.bounds(sink);
self.account_name.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<F, T, U> crate::trace::Trace for FidoConfirm<F, T, U>
where
F: Fn(usize) -> T,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("FidoConfirm");
}
}

@ -0,0 +1,80 @@
//! generated from webauthn_icons.rs.mako
//! (by running `make templates` in `core`)
//! do not edit manually!
const ICON_APPLE: &[u8] = include_res!("model_mercury/res/fido/icon_apple.toif");
const ICON_AWS: &[u8] = include_res!("model_mercury/res/fido/icon_aws.toif");
const ICON_BINANCE: &[u8] = include_res!("model_mercury/res/fido/icon_binance.toif");
const ICON_BITBUCKET: &[u8] = include_res!("model_mercury/res/fido/icon_bitbucket.toif");
const ICON_BITFINEX: &[u8] = include_res!("model_mercury/res/fido/icon_bitfinex.toif");
const ICON_BITWARDEN: &[u8] = include_res!("model_mercury/res/fido/icon_bitwarden.toif");
const ICON_CLOUDFLARE: &[u8] = include_res!("model_mercury/res/fido/icon_cloudflare.toif");
const ICON_COINBASE: &[u8] = include_res!("model_mercury/res/fido/icon_coinbase.toif");
const ICON_DASHLANE: &[u8] = include_res!("model_mercury/res/fido/icon_dashlane.toif");
const ICON_DROPBOX: &[u8] = include_res!("model_mercury/res/fido/icon_dropbox.toif");
const ICON_DUO: &[u8] = include_res!("model_mercury/res/fido/icon_duo.toif");
const ICON_FACEBOOK: &[u8] = include_res!("model_mercury/res/fido/icon_facebook.toif");
const ICON_FASTMAIL: &[u8] = include_res!("model_mercury/res/fido/icon_fastmail.toif");
const ICON_FEDORA: &[u8] = include_res!("model_mercury/res/fido/icon_fedora.toif");
const ICON_GANDI: &[u8] = include_res!("model_mercury/res/fido/icon_gandi.toif");
const ICON_GEMINI: &[u8] = include_res!("model_mercury/res/fido/icon_gemini.toif");
const ICON_GITHUB: &[u8] = include_res!("model_mercury/res/fido/icon_github.toif");
const ICON_GITLAB: &[u8] = include_res!("model_mercury/res/fido/icon_gitlab.toif");
const ICON_GOOGLE: &[u8] = include_res!("model_mercury/res/fido/icon_google.toif");
const ICON_INVITY: &[u8] = include_res!("model_mercury/res/fido/icon_invity.toif");
const ICON_KEEPER: &[u8] = include_res!("model_mercury/res/fido/icon_keeper.toif");
const ICON_KRAKEN: &[u8] = include_res!("model_mercury/res/fido/icon_kraken.toif");
const ICON_LOGIN_GOV: &[u8] = include_res!("model_mercury/res/fido/icon_login.gov.toif");
const ICON_MICROSOFT: &[u8] = include_res!("model_mercury/res/fido/icon_microsoft.toif");
const ICON_MOJEID: &[u8] = include_res!("model_mercury/res/fido/icon_mojeid.toif");
const ICON_NAMECHEAP: &[u8] = include_res!("model_mercury/res/fido/icon_namecheap.toif");
const ICON_PROTON: &[u8] = include_res!("model_mercury/res/fido/icon_proton.toif");
const ICON_SLUSHPOOL: &[u8] = include_res!("model_mercury/res/fido/icon_slushpool.toif");
const ICON_STRIPE: &[u8] = include_res!("model_mercury/res/fido/icon_stripe.toif");
const ICON_TUTANOTA: &[u8] = include_res!("model_mercury/res/fido/icon_tutanota.toif");
/// Default icon when app does not have its own
const ICON_WEBAUTHN: &[u8] = include_res!("model_mercury/res/fido/icon_webauthn.toif");
/// Translates icon name into its data.
/// Returns default `ICON_WEBAUTHN` when the icon is not found or name not
/// supplied.
pub fn get_fido_icon_data<T: AsRef<str>>(icon_name: Option<T>) -> &'static [u8] {
if let Some(icon_name) = icon_name {
match icon_name.as_ref() {
"apple" => ICON_APPLE,
"aws" => ICON_AWS,
"binance" => ICON_BINANCE,
"bitbucket" => ICON_BITBUCKET,
"bitfinex" => ICON_BITFINEX,
"bitwarden" => ICON_BITWARDEN,
"cloudflare" => ICON_CLOUDFLARE,
"coinbase" => ICON_COINBASE,
"dashlane" => ICON_DASHLANE,
"dropbox" => ICON_DROPBOX,
"duo" => ICON_DUO,
"facebook" => ICON_FACEBOOK,
"fastmail" => ICON_FASTMAIL,
"fedora" => ICON_FEDORA,
"gandi" => ICON_GANDI,
"gemini" => ICON_GEMINI,
"github" => ICON_GITHUB,
"gitlab" => ICON_GITLAB,
"google" => ICON_GOOGLE,
"invity" => ICON_INVITY,
"keeper" => ICON_KEEPER,
"kraken" => ICON_KRAKEN,
"login.gov" => ICON_LOGIN_GOV,
"microsoft" => ICON_MICROSOFT,
"mojeid" => ICON_MOJEID,
"namecheap" => ICON_NAMECHEAP,
"proton" => ICON_PROTON,
"slushpool" => ICON_SLUSHPOOL,
"stripe" => ICON_STRIPE,
"tutanota" => ICON_TUTANOTA,
_ => ICON_WEBAUTHN,
}
} else {
ICON_WEBAUTHN
}
}

@ -0,0 +1,35 @@
//! generated from webauthn_icons.rs.mako
//! (by running `make templates` in `core`)
//! do not edit manually!
<%
icons: list[tuple[str, str]] = []
for app in fido:
if app.icon is not None:
# Variable names cannot have a dot in themselves
icon_name = app.key
var_name = icon_name.replace(".", "_").upper()
icons.append((icon_name, var_name))
%>\
% for icon_name, var_name in icons:
const ICON_${var_name}: &[u8] = include_res!("model_mercury/res/fido/icon_${icon_name}.toif");
% endfor
/// Default icon when app does not have its own
const ICON_WEBAUTHN: &[u8] = include_res!("model_mercury/res/fido/icon_webauthn.toif");
/// Translates icon name into its data.
/// Returns default `ICON_WEBAUTHN` when the icon is not found or name not
/// supplied.
pub fn get_fido_icon_data<T: AsRef<str>>(icon_name: Option<T>) -> &'static [u8] {
if let Some(icon_name) = icon_name {
match icon_name.as_ref() {
% for icon_name, var_name in icons:
"${icon_name}" => ICON_${var_name},
% endfor
_ => ICON_WEBAUTHN,
}
} else {
ICON_WEBAUTHN
}
}

@ -0,0 +1,598 @@
mod render;
use crate::{
micropython::gc::Gc,
strutil::TString,
time::{Duration, Instant},
translations::TR,
trezorhal::usb::usb_configured,
ui::{
component::{Component, Event, EventCtx, Pad, TimerToken},
display::{self, tjpgd::jpeg_info, toif::Icon, Color, Font},
event::{TouchEvent, USBEvent},
geometry::{Alignment, Alignment2D, Insets, Offset, Point, Rect},
layout::util::get_user_custom_image,
model_mercury::{constant, theme::IMAGE_HOMESCREEN},
shape::{self, Renderer},
},
};
use crate::{
trezorhal::{buffers::BufferJpegWork, uzlib::UZLIB_WINDOW_SIZE},
ui::{
constant::HEIGHT,
display::{
tjpgd::{jpeg_test, BufferInput},
toif::{Toif, ToifFormat},
},
model_mercury::component::homescreen::render::{
HomescreenJpeg, HomescreenToif, HOMESCREEN_TOIF_SIZE,
},
},
};
use render::{
homescreen, homescreen_blurred, HomescreenNotification, HomescreenText,
HOMESCREEN_IMAGE_HEIGHT, HOMESCREEN_IMAGE_WIDTH,
};
use super::{theme, Loader, LoaderMsg};
const AREA: Rect = constant::screen();
const TOP_CENTER: Point = AREA.top_center();
const LABEL_Y: i16 = HEIGHT - 18;
const LOCKED_Y: i16 = HEIGHT / 2 - 13;
const TAP_Y: i16 = HEIGHT / 2 + 14;
const HOLD_Y: i16 = 200;
const COINJOIN_Y: i16 = 30;
const LOADER_OFFSET: Offset = Offset::y(-10);
const LOADER_DELAY: Duration = Duration::from_millis(500);
const LOADER_DURATION: Duration = Duration::from_millis(2000);
pub struct Homescreen {
label: TString<'static>,
notification: Option<(TString<'static>, u8)>,
custom_image: Option<Gc<[u8]>>,
hold_to_lock: bool,
loader: Loader,
pad: Pad,
paint_notification_only: bool,
delay: Option<TimerToken>,
}
pub enum HomescreenMsg {
Dismissed,
}
impl Homescreen {
pub fn new(
label: TString<'static>,
notification: Option<(TString<'static>, u8)>,
hold_to_lock: bool,
) -> Self {
Self {
label,
notification,
custom_image: get_user_custom_image().ok(),
hold_to_lock,
loader: Loader::with_lock_icon().with_durations(LOADER_DURATION, LOADER_DURATION / 3),
pad: Pad::with_background(theme::BG),
paint_notification_only: false,
delay: None,
}
}
fn level_to_style(level: u8) -> (Color, Icon) {
match level {
3 => (theme::YELLOW, theme::ICON_COINJOIN),
2 => (theme::VIOLET, theme::ICON_MAGIC),
1 => (theme::YELLOW, theme::ICON_WARN),
_ => (theme::RED, theme::ICON_WARN),
}
}
fn get_notification(&self) -> Option<HomescreenNotification> {
if !usb_configured() {
let (color, icon) = Self::level_to_style(0);
Some(HomescreenNotification {
text: TR::homescreen__title_no_usb_connection.into(),
icon,
color,
})
} else if let Some((notification, level)) = self.notification {
let (color, icon) = Self::level_to_style(level);
Some(HomescreenNotification {
text: notification,
icon,
color,
})
} else {
None
}
}
fn paint_loader(&mut self) {
TR::progress__locking_device.map_translated(|t| {
display::text_center(
TOP_CENTER + Offset::y(HOLD_Y),
t,
Font::NORMAL,
theme::FG,
theme::BG,
)
});
self.loader.paint()
}
fn render_loader(&mut self, target: &mut impl Renderer) {
TR::progress__locking_device.map_translated(|t| {
shape::Text::new(TOP_CENTER + Offset::y(HOLD_Y), t)
.with_align(Alignment::Center)
.with_font(Font::NORMAL)
.with_fg(theme::FG);
});
self.loader.render(target)
}
pub fn set_paint_notification(&mut self) {
self.paint_notification_only = true;
}
fn event_usb(&mut self, ctx: &mut EventCtx, event: Event) {
if let Event::USB(USBEvent::Connected(_)) = event {
self.paint_notification_only = true;
ctx.request_paint();
}
}
fn event_hold(&mut self, ctx: &mut EventCtx, event: Event) -> bool {
match event {
Event::Touch(TouchEvent::TouchStart(_)) => {
if self.loader.is_animating() {
self.loader.start_growing(ctx, Instant::now());
} else {
self.delay = Some(ctx.request_timer(LOADER_DELAY));
}
}
Event::Touch(TouchEvent::TouchEnd(_)) => {
self.delay = None;
let now = Instant::now();
if self.loader.is_completely_grown(now) {
return true;
}
if self.loader.is_animating() {
self.loader.start_shrinking(ctx, now);
}
}
Event::Timer(token) if Some(token) == self.delay => {
self.delay = None;
self.pad.clear();
self.paint_notification_only = false;
self.loader.start_growing(ctx, Instant::now());
}
_ => {}
}
match self.loader.event(ctx, event) {
Some(LoaderMsg::GrownCompletely) => {
// Wait for TouchEnd before returning.
}
Some(LoaderMsg::ShrunkCompletely) => {
self.loader.reset();
self.pad.clear();
self.paint_notification_only = false;
ctx.request_paint()
}
None => {}
}
false
}
}
impl Component for Homescreen {
type Msg = HomescreenMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.pad.place(AREA);
self.loader.place(AREA.translate(LOADER_OFFSET));
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
Self::event_usb(self, ctx, event);
if self.hold_to_lock {
Self::event_hold(self, ctx, event).then_some(HomescreenMsg::Dismissed)
} else {
None
}
}
fn paint(&mut self) {
self.pad.paint();
if self.loader.is_animating() || self.loader.is_completely_grown(Instant::now()) {
self.paint_loader();
} else {
let mut label_style = theme::TEXT_DEMIBOLD;
label_style.text_color = theme::FG;
let text = HomescreenText {
text: self.label,
style: label_style,
offset: Offset::y(LABEL_Y),
icon: None,
};
let notification = self.get_notification();
let mut show_default = true;
if let Some(ref data) = self.custom_image {
if is_image_jpeg(data.as_ref()) {
let input = BufferInput(data.as_ref());
let mut pool = BufferJpegWork::get_cleared();
let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice());
homescreen(
&mut hs_img,
&[text],
notification,
self.paint_notification_only,
);
show_default = false;
} else if is_image_toif(data.as_ref()) {
let input = unwrap!(Toif::new(data.as_ref()));
let mut window = [0; UZLIB_WINDOW_SIZE];
let mut hs_img =
HomescreenToif::new(input.decompression_context(Some(&mut window)));
homescreen(
&mut hs_img,
&[text],
notification,
self.paint_notification_only,
);
show_default = false;
}
}
if show_default {
let input = BufferInput(IMAGE_HOMESCREEN);
let mut pool = BufferJpegWork::get_cleared();
let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice());
homescreen(
&mut hs_img,
&[text],
notification,
self.paint_notification_only,
);
}
}
}
fn render(&mut self, target: &mut impl Renderer) {
self.pad.render(target);
if self.loader.is_animating() || self.loader.is_completely_grown(Instant::now()) {
self.render_loader(target);
} else {
let img_data = match self.custom_image {
Some(ref img) => IMAGE_HOMESCREEN, //img.as_ref(), !@# solve lifetime problem
None => IMAGE_HOMESCREEN,
};
if is_image_jpeg(img_data) {
shape::JpegImage::new(self.pad.area.center(), img_data)
.with_align(Alignment2D::CENTER)
.render(target);
} else if is_image_toif(img_data) {
shape::ToifImage::new(self.pad.area.center(), unwrap!(Toif::new(img_data)))
.with_align(Alignment2D::CENTER)
.render(target);
}
self.label.map(|t| {
let style = theme::TEXT_DEMIBOLD;
let pos = Point::new(self.pad.area.center().x, LABEL_Y);
shape::Text::new(pos, t)
.with_align(Alignment::Center)
.with_font(style.text_font)
.with_fg(theme::FG)
.render(target);
});
if let Some(notif) = self.get_notification() {
const NOTIFICATION_HEIGHT: i16 = 36;
const NOTIFICATION_BORDER: i16 = 6;
const TEXT_ICON_SPACE: i16 = 8;
let banner = self
.pad
.area
.inset(Insets::sides(NOTIFICATION_BORDER))
.with_height(NOTIFICATION_HEIGHT)
.translate(Offset::y(NOTIFICATION_BORDER));
shape::Bar::new(banner)
.with_radius(2)
.with_bg(notif.color)
.render(target);
notif.text.map(|t| {
let style = theme::TEXT_BOLD;
let icon_width = notif.icon.toif.width() + TEXT_ICON_SPACE;
let text_pos = Point::new(
style
.text_font
.horz_center(banner.x0 + icon_width, banner.x1, t),
style.text_font.vert_center(banner.y0, banner.y1, "A"),
);
shape::Text::new(text_pos, t)
.with_font(style.text_font)
.with_fg(style.text_color)
.render(target);
let icon_pos = Point::new(text_pos.x - icon_width, banner.center().y);
shape::ToifImage::new(icon_pos, notif.icon.toif)
.with_fg(style.text_color)
.with_align(Alignment2D::CENTER_LEFT)
.render(target);
});
}
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.loader.bounds(sink);
sink(self.pad.area);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Homescreen {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Homescreen");
t.string("label", self.label);
}
}
pub struct Lockscreen {
label: TString<'static>,
custom_image: Option<Gc<[u8]>>,
bootscreen: bool,
coinjoin_authorized: bool,
}
impl Lockscreen {
pub fn new(label: TString<'static>, bootscreen: bool, coinjoin_authorized: bool) -> Self {
Lockscreen {
label,
custom_image: get_user_custom_image().ok(),
bootscreen,
coinjoin_authorized,
}
}
}
impl Component for Lockscreen {
type Msg = HomescreenMsg;
fn place(&mut self, bounds: Rect) -> Rect {
bounds
}
fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Event::Touch(TouchEvent::TouchEnd(_)) = event {
return Some(HomescreenMsg::Dismissed);
}
None
}
fn paint(&mut self) {
let (locked, tap) = if self.bootscreen {
(
TR::lockscreen__title_not_connected,
TR::lockscreen__tap_to_connect,
)
} else {
(TR::lockscreen__title_locked, TR::lockscreen__tap_to_unlock)
};
let mut label_style = theme::TEXT_DEMIBOLD;
label_style.text_color = theme::GREY_LIGHT;
let mut texts: &[HomescreenText] = &[
HomescreenText {
text: "".into(),
style: theme::TEXT_NORMAL,
offset: Offset::new(2, COINJOIN_Y),
icon: Some(theme::ICON_COINJOIN),
},
HomescreenText {
text: locked.into(),
style: theme::TEXT_BOLD,
offset: Offset::y(LOCKED_Y),
icon: Some(theme::ICON_LOCK),
},
HomescreenText {
text: tap.into(),
style: theme::TEXT_NORMAL,
offset: Offset::y(TAP_Y),
icon: None,
},
HomescreenText {
text: self.label,
style: label_style,
offset: Offset::y(LABEL_Y),
icon: None,
},
];
if !self.coinjoin_authorized {
texts = &texts[1..];
}
let mut show_default = true;
if let Some(ref data) = self.custom_image {
if is_image_jpeg(data.as_ref()) {
let input = BufferInput(data.as_ref());
let mut pool = BufferJpegWork::get_cleared();
let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice());
homescreen_blurred(&mut hs_img, texts);
show_default = false;
} else if is_image_toif(data.as_ref()) {
let input = unwrap!(Toif::new(data.as_ref()));
let mut window = [0; UZLIB_WINDOW_SIZE];
let mut hs_img =
HomescreenToif::new(input.decompression_context(Some(&mut window)));
homescreen_blurred(&mut hs_img, texts);
show_default = false;
}
}
if show_default {
let input = BufferInput(IMAGE_HOMESCREEN);
let mut pool = BufferJpegWork::get_cleared();
let mut hs_img = HomescreenJpeg::new(input, pool.buffer.as_mut_slice());
homescreen_blurred(&mut hs_img, texts);
}
}
fn render(&mut self, target: &mut impl Renderer) {
let img_data = match self.custom_image {
Some(ref img) => IMAGE_HOMESCREEN, //img.as_ref(), !@# solve lifetime problem
None => IMAGE_HOMESCREEN,
};
let center = constant::screen().center();
if is_image_jpeg(img_data) {
shape::JpegImage::new(center, img_data)
.with_align(Alignment2D::CENTER)
.with_blur(4)
.with_dim(130)
.render(target);
} else if is_image_toif(img_data) {
shape::ToifImage::new(center, unwrap!(Toif::new(img_data)))
.with_align(Alignment2D::CENTER)
//.with_blur(5)
.render(target);
}
let (locked, tap) = if self.bootscreen {
(
TR::lockscreen__title_not_connected,
TR::lockscreen__tap_to_connect,
)
} else {
(TR::lockscreen__title_locked, TR::lockscreen__tap_to_unlock)
};
let mut label_style = theme::TEXT_DEMIBOLD;
label_style.text_color = theme::GREY_LIGHT;
let mut texts: &[HomescreenText] = &[
HomescreenText {
text: "".into(),
style: theme::TEXT_NORMAL,
offset: Offset::new(2, COINJOIN_Y),
icon: Some(theme::ICON_COINJOIN),
},
HomescreenText {
text: locked.into(),
style: theme::TEXT_BOLD,
offset: Offset::y(LOCKED_Y),
icon: Some(theme::ICON_LOCK),
},
HomescreenText {
text: tap.into(),
style: theme::TEXT_NORMAL,
offset: Offset::y(TAP_Y),
icon: None,
},
HomescreenText {
text: self.label,
style: label_style,
offset: Offset::y(LABEL_Y),
icon: None,
},
];
if !self.coinjoin_authorized {
texts = &texts[1..];
}
for item in texts.iter() {
item.text.map(|t| {
const TEXT_ICON_SPACE: i16 = 2;
let icon_width = match item.icon {
Some(icon) => icon.toif.width() + TEXT_ICON_SPACE,
None => 0,
};
let area = constant::screen();
let text_pos = Point::new(
item.style
.text_font
.horz_center(area.x0 + icon_width, area.x1, t),
0,
) + item.offset;
shape::Text::new(text_pos, t)
.with_font(item.style.text_font)
.with_fg(item.style.text_color)
.render(target);
if let Some(icon) = item.icon {
let icon_pos = Point::new(text_pos.x - icon_width, text_pos.y);
shape::ToifImage::new(icon_pos, icon.toif)
.with_align(Alignment2D::BOTTOM_LEFT)
.with_fg(item.style.text_color)
.render(target);
}
});
}
}
}
pub fn check_homescreen_format(buffer: &[u8]) -> bool {
#[cfg(not(feature = "new_rendering"))]
let result = is_image_jpeg(buffer) && jpeg_test(buffer);
#[cfg(feature = "new_rendering")]
let result = is_image_jpeg(buffer); // !@# TODO: test like if `new_rendering` is off
result
}
fn is_image_jpeg(buffer: &[u8]) -> bool {
let jpeg = jpeg_info(buffer);
if let Some((size, mcu_height)) = jpeg {
if size.x == HOMESCREEN_IMAGE_WIDTH && size.y == HOMESCREEN_IMAGE_HEIGHT && mcu_height <= 16
{
return true;
}
}
false
}
fn is_image_toif(buffer: &[u8]) -> bool {
let toif = Toif::new(buffer);
if let Ok(toif) = toif {
if toif.size().x == HOMESCREEN_TOIF_SIZE
&& toif.size().y == HOMESCREEN_TOIF_SIZE
&& toif.format() == ToifFormat::FullColorBE
{
return true;
}
}
false
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Lockscreen {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Lockscreen");
}
}

@ -0,0 +1,768 @@
use crate::{
strutil::TString,
trezorhal::{
buffers::{
BufferBlurring, BufferBlurringTotals, BufferJpeg, BufferLine16bpp, BufferLine4bpp,
BufferText,
},
display,
dma2d::{dma2d_setup_4bpp_over_16bpp, dma2d_start_blend, dma2d_wait_for_transfer},
uzlib::UzlibContext,
},
ui::{
component::text::TextStyle,
constant::{screen, HEIGHT, WIDTH},
display::{
position_buffer, rect_fill_rounded_buffer, set_window,
tjpgd::{BufferInput, BufferOutput, JDEC},
Color, Icon,
},
geometry::{Offset, Point, Rect},
model_mercury::theme,
util::icon_text_center,
},
};
#[derive(Clone, Copy)]
pub struct HomescreenText<'a> {
pub text: TString<'a>,
pub style: TextStyle,
pub offset: Offset,
pub icon: Option<Icon>,
}
#[derive(Clone, Copy)]
pub struct HomescreenNotification {
pub text: TString<'static>,
pub icon: Icon,
pub color: Color,
}
#[derive(Clone, Copy)]
struct HomescreenTextInfo {
pub text_area: Rect,
pub text_width: i16,
pub text_color: Color,
pub icon_area: Option<Rect>,
}
pub const HOMESCREEN_IMAGE_WIDTH: i16 = WIDTH;
pub const HOMESCREEN_IMAGE_HEIGHT: i16 = HEIGHT;
pub const HOMESCREEN_TOIF_SIZE: i16 = 144;
pub const HOMESCREEN_TOIF_Y_OFFSET: i16 = 27;
pub const HOMESCREEN_TOIF_X_OFFSET: usize =
((WIDTH.saturating_sub(HOMESCREEN_TOIF_SIZE)) / 2) as usize;
const HOMESCREEN_MAX_ICON_SIZE: i16 = 20;
const NOTIFICATION_HEIGHT: i16 = 36;
const NOTIFICATION_BORDER: i16 = 6;
const TEXT_ICON_SPACE: i16 = 2;
const HOMESCREEN_DIM_HEIGHT: i16 = 35;
const HOMESCREEN_DIM_START: i16 = HOMESCREEN_IMAGE_HEIGHT - 42;
const HOMESCREEN_DIM: f32 = 0.65;
const HOMESCREEN_DIM_BORDER: i16 = theme::BUTTON_SPACING;
const LOCKSCREEN_DIM: f32 = 0.55;
const LOCKSCREEN_DIM_BG: f32 = 0.0;
const LOCKSCREEN_DIM_ALL: bool = true;
const BLUR_SIZE: usize = 9;
const BLUR_DIV: u32 =
((65536_f32 * (1_f32 - LOCKSCREEN_DIM_BG)) as u32) / ((BLUR_SIZE * BLUR_SIZE) as u32);
const DECOMP_LINES: usize = BLUR_SIZE + 1;
const BLUR_RADIUS: i16 = (BLUR_SIZE / 2) as i16;
const COLORS: usize = 3;
const RED_IDX: usize = 0;
const GREEN_IDX: usize = 1;
const BLUE_IDX: usize = 2;
pub trait HomescreenDecompressor {
fn get_height(&self) -> i16;
fn decompress(&mut self);
fn get_data(&mut self) -> &mut BufferJpeg;
}
pub struct HomescreenJpeg<'i> {
pub output: BufferOutput,
pub input: BufferInput<'i>,
pub jdec: Option<JDEC<'i>>,
}
impl<'i> HomescreenJpeg<'i> {
pub fn new(mut input: BufferInput<'i>, pool: &'i mut [u8]) -> Self {
Self {
output: BufferOutput::new(WIDTH, 16),
jdec: JDEC::new(&mut input, pool).ok(),
input,
}
}
}
impl<'i> HomescreenDecompressor for HomescreenJpeg<'i> {
fn get_height(&self) -> i16 {
if let Some(dec) = self.jdec.as_ref() {
return dec.mcu_height();
}
1
}
fn decompress(&mut self) {
self.jdec
.as_mut()
.map(|dec| dec.decomp(&mut self.input, &mut self.output));
}
fn get_data(&mut self) -> &mut BufferJpeg {
self.output.buffer()
}
}
pub struct HomescreenToif<'i> {
pub output: BufferOutput,
pub decomp_context: UzlibContext<'i>,
line: i16,
}
impl<'i> HomescreenToif<'i> {
pub fn new(context: UzlibContext<'i>) -> Self {
Self {
output: BufferOutput::new(WIDTH, 16),
decomp_context: context,
line: 0,
}
}
}
impl<'i> HomescreenDecompressor for HomescreenToif<'i> {
fn get_height(&self) -> i16 {
1
}
fn decompress(&mut self) {
// SAFETY: Aligning to u8 slice is safe, because the original slice is aligned
// to 16 bits, therefore there are also no residuals (prefix/suffix).
// The data in the slices are integers, so these are valid for both u16
// and u8.
if self.line >= HOMESCREEN_TOIF_Y_OFFSET
&& self.line < HOMESCREEN_TOIF_Y_OFFSET + HOMESCREEN_TOIF_SIZE
{
let (_, workbuf, _) = unsafe { self.output.buffer().buffer.align_to_mut::<u8>() };
let result = self.decomp_context.uncompress(
&mut workbuf[2 * HOMESCREEN_TOIF_X_OFFSET
..2 * HOMESCREEN_TOIF_X_OFFSET + 2 * HOMESCREEN_TOIF_SIZE as usize],
);
if result.is_err() {
self.output.buffer().buffer.fill(0);
} else {
for i in 0..HOMESCREEN_TOIF_SIZE as usize {
workbuf.swap(
2 * HOMESCREEN_TOIF_X_OFFSET + 2 * i,
2 * HOMESCREEN_TOIF_X_OFFSET + 2 * i + 1,
);
}
}
} else {
self.output.buffer().buffer.fill(0);
}
self.line += 1;
}
fn get_data(&mut self) -> &mut BufferJpeg {
self.output.buffer()
}
}
fn homescreen_get_fg_text(
y_tmp: i16,
text_info: HomescreenTextInfo,
text_buffer: &BufferText,
fg_buffer: &mut BufferLine4bpp,
) -> bool {
if y_tmp >= text_info.text_area.y0 && y_tmp < text_info.text_area.y1 {
let y_pos = y_tmp - text_info.text_area.y0;
position_buffer(
&mut fg_buffer.buffer,
&text_buffer.buffer[(y_pos * WIDTH / 2) as usize..((y_pos + 1) * WIDTH / 2) as usize],
4,
text_info.text_area.x0,
text_info.text_width,
);
}
y_tmp == (text_info.text_area.y1 - 1)
}
fn homescreen_get_fg_icon(
y_tmp: i16,
text_info: HomescreenTextInfo,
icon_data: &[u8],
fg_buffer: &mut BufferLine4bpp,
) {
if let Some(icon_area) = text_info.icon_area {
let icon_size = icon_area.size();
if y_tmp >= icon_area.y0 && y_tmp < icon_area.y1 {
let y_pos = y_tmp - icon_area.y0;
position_buffer(
&mut fg_buffer.buffer,
&icon_data
[(y_pos * icon_size.x / 2) as usize..((y_pos + 1) * icon_size.x / 2) as usize],
4,
icon_area.x0,
icon_size.x,
);
}
}
}
fn homescreen_position_text(
text: &HomescreenText,
buffer: &mut BufferText,
icon_buffer: &mut [u8],
) -> HomescreenTextInfo {
let text_width = text
.text
.map(|t| display::text_width(t, text.style.text_font.into()));
let font_max_height = display::text_max_height(text.style.text_font.into());
let font_baseline = display::text_baseline(text.style.text_font.into());
let text_width_clamped = text_width.clamp(0, screen().width());
let icon_size = if let Some(icon) = text.icon {
let size = icon.toif.size();
assert!(size.x <= HOMESCREEN_MAX_ICON_SIZE);
assert!(size.y <= HOMESCREEN_MAX_ICON_SIZE);
icon.toif.uncompress(icon_buffer);
size
} else {
Offset::zero()
};
let text_top = screen().y0 + text.offset.y - font_max_height + font_baseline;
let text_bottom = screen().y0 + text.offset.y + font_baseline;
let total_width = text_width_clamped + icon_size.x + TEXT_ICON_SPACE;
let icon_left = screen().center().x + text.offset.x - total_width / 2;
let text_left = icon_left + icon_size.x + TEXT_ICON_SPACE;
let text_right = screen().center().x + text.offset.x + total_width / 2;
let text_area = Rect::new(
Point::new(text_left, text_top),
Point::new(text_right, text_bottom),
);
let icon_area = if text.icon.is_some() {
Some(Rect::from_top_left_and_size(
Point::new(icon_left, text_bottom - icon_size.y - font_baseline),
icon_size,
))
} else {
None
};
text.text
.map(|t| display::text_into_buffer(t, text.style.text_font.into(), buffer, 0));
HomescreenTextInfo {
text_area,
text_width,
text_color: text.style.text_color,
icon_area,
}
}
#[inline(always)]
fn homescreen_dim_area(x: i16, y: i16) -> bool {
y >= HOMESCREEN_DIM_START
&& (y > HOMESCREEN_DIM_START + 1
&& y < (HOMESCREEN_DIM_START + HOMESCREEN_DIM_HEIGHT - 1)
&& x > HOMESCREEN_DIM_BORDER
&& x < WIDTH - HOMESCREEN_DIM_BORDER)
|| (y > HOMESCREEN_DIM_START
&& y < (HOMESCREEN_DIM_START + HOMESCREEN_DIM_HEIGHT)
&& x > HOMESCREEN_DIM_BORDER + 1
&& x < WIDTH - (HOMESCREEN_DIM_BORDER + 1))
|| ((HOMESCREEN_DIM_START..=(HOMESCREEN_DIM_START + HOMESCREEN_DIM_HEIGHT)).contains(&y)
&& x > HOMESCREEN_DIM_BORDER + 2
&& x < WIDTH - (HOMESCREEN_DIM_BORDER + 2))
}
fn homescreen_line_blurred(
icon_data: &[u8],
text_buffer: &mut BufferText,
fg_buffer: &mut BufferLine4bpp,
img_buffer: &mut BufferLine16bpp,
text_info: HomescreenTextInfo,
blurring: &BlurringContext,
y: i16,
) -> bool {
fg_buffer.buffer.fill(0);
for x in 0..HOMESCREEN_IMAGE_WIDTH {
let c = if LOCKSCREEN_DIM_ALL {
let x = x as usize;
let coef = (65536_f32 * LOCKSCREEN_DIM) as u32;
let r = (blurring.totals.buffer[RED_IDX][x] as u32 * BLUR_DIV) >> 16;
let g = (blurring.totals.buffer[GREEN_IDX][x] as u32 * BLUR_DIV) >> 16;
let b = (blurring.totals.buffer[BLUE_IDX][x] as u32 * BLUR_DIV) >> 16;
let r = (((coef * r) >> 8) & 0xF800) as u16;
let g = (((coef * g) >> 13) & 0x07E0) as u16;
let b = (((coef * b) >> 19) & 0x001F) as u16;
r | g | b
} else {
let x = x as usize;
let r = (((blurring.totals.buffer[RED_IDX][x] as u32 * BLUR_DIV) >> 8) & 0xF800) as u16;
let g =
(((blurring.totals.buffer[GREEN_IDX][x] as u32 * BLUR_DIV) >> 13) & 0x07E0) as u16;
let b =
(((blurring.totals.buffer[BLUE_IDX][x] as u32 * BLUR_DIV) >> 19) & 0x001F) as u16;
r | g | b
};
let j = (2 * x) as usize;
img_buffer.buffer[j + 1] = (c >> 8) as u8;
img_buffer.buffer[j] = (c & 0xFF) as u8;
}
let done = homescreen_get_fg_text(y, text_info, text_buffer, fg_buffer);
homescreen_get_fg_icon(y, text_info, icon_data, fg_buffer);
dma2d_wait_for_transfer();
dma2d_setup_4bpp_over_16bpp(text_info.text_color.into());
unsafe {
dma2d_start_blend(&fg_buffer.buffer, &img_buffer.buffer, WIDTH);
}
done
}
#[allow(clippy::too_many_arguments)]
fn homescreen_line(
icon_data: &[u8],
text_buffer: &mut BufferText,
text_info: HomescreenTextInfo,
data_buffer: &mut BufferJpeg,
fg_buffer: &mut BufferLine4bpp,
img_buffer: &mut BufferLine16bpp,
mcu_height: i16,
y: i16,
) -> bool {
let image_data = get_data(data_buffer, y, mcu_height);
fg_buffer.buffer.fill(0);
for x in 0..HOMESCREEN_IMAGE_WIDTH {
let d = image_data[x as usize];
let c = if homescreen_dim_area(x, y) {
let coef = (65536_f32 * HOMESCREEN_DIM) as u32;
let r = (d & 0xF800) >> 8;
let g = (d & 0x07E0) >> 3;
let b = (d & 0x001F) << 3;
let r = (((coef * r as u32) >> 8) & 0xF800) as u16;
let g = (((coef * g as u32) >> 13) & 0x07E0) as u16;
let b = (((coef * b as u32) >> 19) & 0x001F) as u16;
r | g | b
} else {
d
};
let j = 2 * x as usize;
img_buffer.buffer[j + 1] = (c >> 8) as u8;
img_buffer.buffer[j] = (c & 0xFF) as u8;
}
let done = homescreen_get_fg_text(y, text_info, text_buffer, fg_buffer);
homescreen_get_fg_icon(y, text_info, icon_data, fg_buffer);
dma2d_wait_for_transfer();
dma2d_setup_4bpp_over_16bpp(text_info.text_color.into());
unsafe {
dma2d_start_blend(&fg_buffer.buffer, &img_buffer.buffer, WIDTH);
}
done
}
fn homescreen_next_text(
texts: &[HomescreenText],
text_buffer: &mut BufferText,
icon_data: &mut [u8],
text_info: HomescreenTextInfo,
text_idx: usize,
) -> (HomescreenTextInfo, usize) {
let mut next_text_idx = text_idx;
let mut next_text_info = text_info;
if next_text_idx < texts.len() {
if let Some(txt) = texts.get(next_text_idx) {
text_buffer.buffer.fill(0);
next_text_info = homescreen_position_text(txt, text_buffer, icon_data);
next_text_idx += 1;
}
}
(next_text_info, next_text_idx)
}
#[inline(always)]
fn update_accs_add(data: &[u16], idx: usize, acc_r: &mut u16, acc_g: &mut u16, acc_b: &mut u16) {
let d = data[idx];
let r = (d & 0xF800) >> 8;
let g = (d & 0x07E0) >> 3;
let b = (d & 0x001F) << 3;
*acc_r += r;
*acc_g += g;
*acc_b += b;
}
#[inline(always)]
fn update_accs_sub(data: &[u16], idx: usize, acc_r: &mut u16, acc_g: &mut u16, acc_b: &mut u16) {
let d = data[idx];
let r = (d & 0xF800) >> 8;
let g = (d & 0x07E0) >> 3;
let b = (d & 0x001F) << 3;
*acc_r -= r;
*acc_g -= g;
*acc_b -= b;
}
struct BlurringContext {
mem: BufferBlurring,
pub totals: BufferBlurringTotals,
line_num: i16,
add_idx: usize,
rem_idx: usize,
}
impl BlurringContext {
pub fn new() -> Self {
Self {
mem: BufferBlurring::get_cleared(),
totals: BufferBlurringTotals::get_cleared(),
line_num: 0,
add_idx: 0,
rem_idx: 0,
}
}
fn clear(&mut self) {
let lines = &mut self.mem.buffer[0..DECOMP_LINES];
for (i, total) in self.totals.buffer.iter_mut().enumerate() {
for line in lines.iter_mut() {
line[i].fill(0);
}
total.fill(0);
}
}
// computes color averages for one line of image data
fn compute_line_avgs(&mut self, buffer: &mut BufferJpeg, mcu_height: i16) {
let lines = &mut self.mem.buffer[0..DECOMP_LINES];
let mut acc_r = 0;
let mut acc_g = 0;
let mut acc_b = 0;
let data = get_data(buffer, self.line_num, mcu_height);
for i in -BLUR_RADIUS..=BLUR_RADIUS {
let ic = i.clamp(0, HOMESCREEN_IMAGE_WIDTH - 1) as usize;
update_accs_add(data, ic, &mut acc_r, &mut acc_g, &mut acc_b);
}
for i in 0..HOMESCREEN_IMAGE_WIDTH {
lines[self.add_idx][RED_IDX][i as usize] = acc_r;
lines[self.add_idx][GREEN_IDX][i as usize] = acc_g;
lines[self.add_idx][BLUE_IDX][i as usize] = acc_b;
// clamping handles left and right edges
let ic = (i - BLUR_RADIUS).clamp(0, HOMESCREEN_IMAGE_WIDTH - 1) as usize;
let ic2 =
(i + BLUR_SIZE as i16 - BLUR_RADIUS).clamp(0, HOMESCREEN_IMAGE_WIDTH - 1) as usize;
update_accs_add(data, ic2, &mut acc_r, &mut acc_g, &mut acc_b);
update_accs_sub(data, ic, &mut acc_r, &mut acc_g, &mut acc_b);
}
self.line_num += 1;
}
// adds one line of averages to sliding total averages
fn vertical_avg_add(&mut self) {
let lines = &mut self.mem.buffer[0..DECOMP_LINES];
for i in 0..HOMESCREEN_IMAGE_WIDTH as usize {
self.totals.buffer[RED_IDX][i] += lines[self.add_idx][RED_IDX][i];
self.totals.buffer[GREEN_IDX][i] += lines[self.add_idx][GREEN_IDX][i];
self.totals.buffer[BLUE_IDX][i] += lines[self.add_idx][BLUE_IDX][i];
}
}
// adds one line and removes one line of averages to/from sliding total averages
fn vertical_avg(&mut self) {
let lines = &mut self.mem.buffer[0..DECOMP_LINES];
for i in 0..HOMESCREEN_IMAGE_WIDTH as usize {
self.totals.buffer[RED_IDX][i] +=
lines[self.add_idx][RED_IDX][i] - lines[self.rem_idx][RED_IDX][i];
self.totals.buffer[GREEN_IDX][i] +=
lines[self.add_idx][GREEN_IDX][i] - lines[self.rem_idx][GREEN_IDX][i];
self.totals.buffer[BLUE_IDX][i] +=
lines[self.add_idx][BLUE_IDX][i] - lines[self.rem_idx][BLUE_IDX][i];
}
}
fn inc_add(&mut self) {
self.add_idx += 1;
if self.add_idx >= DECOMP_LINES {
self.add_idx = 0;
}
}
fn inc_rem(&mut self) {
self.rem_idx += 1;
if self.rem_idx >= DECOMP_LINES {
self.rem_idx = 0;
}
}
fn get_line_num(&self) -> i16 {
self.line_num
}
}
#[inline(always)]
fn get_data(buffer: &mut BufferJpeg, line_num: i16, mcu_height: i16) -> &mut [u16] {
let data_start = ((line_num % mcu_height) * WIDTH) as usize;
let data_end = (((line_num % mcu_height) + 1) * WIDTH) as usize;
&mut buffer.buffer[data_start..data_end]
}
pub fn homescreen_blurred(data: &mut dyn HomescreenDecompressor, texts: &[HomescreenText]) {
let mut icon_data = [0_u8; (HOMESCREEN_MAX_ICON_SIZE * HOMESCREEN_MAX_ICON_SIZE / 2) as usize];
let mut text_buffer = BufferText::get_cleared();
let mut fg_buffer_0 = BufferLine4bpp::get_cleared();
let mut img_buffer_0 = BufferLine16bpp::get_cleared();
let mut fg_buffer_1 = BufferLine4bpp::get_cleared();
let mut img_buffer_1 = BufferLine16bpp::get_cleared();
let mut next_text_idx = 1;
let mut text_info =
homescreen_position_text(unwrap!(texts.first()), &mut text_buffer, &mut icon_data);
let mcu_height = data.get_height();
data.decompress();
set_window(screen());
let mut blurring = BlurringContext::new();
// handling top edge case: preload the edge value N+1 times
blurring.compute_line_avgs(data.get_data(), mcu_height);
for _ in 0..=BLUR_RADIUS {
blurring.vertical_avg_add();
}
blurring.inc_add();
// load enough values to be able to compute first line averages
for _ in 0..BLUR_RADIUS {
blurring.compute_line_avgs(data.get_data(), mcu_height);
blurring.vertical_avg_add();
blurring.inc_add();
if (blurring.get_line_num() % mcu_height) == 0 {
data.decompress();
}
}
for y in 0..HEIGHT {
// several lines have been already decompressed before this loop, adjust for
// that
if y < HOMESCREEN_IMAGE_HEIGHT - (BLUR_RADIUS + 1) {
blurring.compute_line_avgs(data.get_data(), mcu_height);
}
let done = if y % 2 == 0 {
homescreen_line_blurred(
&icon_data,
&mut text_buffer,
&mut fg_buffer_0,
&mut img_buffer_0,
text_info,
&blurring,
y,
)
} else {
homescreen_line_blurred(
&icon_data,
&mut text_buffer,
&mut fg_buffer_1,
&mut img_buffer_1,
text_info,
&blurring,
y,
)
};
if done {
(text_info, next_text_idx) = homescreen_next_text(
texts,
&mut text_buffer,
&mut icon_data,
text_info,
next_text_idx,
);
}
blurring.vertical_avg();
// handling bottom edge case: stop incrementing counter, adding the edge value
// for the rest of image
// the extra -1 is to indicate that this was the last decompressed line,
// in the next pass the docompression and compute_line_avgs won't happen
if y < HOMESCREEN_IMAGE_HEIGHT - (BLUR_RADIUS + 1) - 1 {
blurring.inc_add();
}
if y == HOMESCREEN_IMAGE_HEIGHT {
// reached end of image, clear avgs (display black)
blurring.clear();
}
// only start incrementing remove index when enough lines have been loaded
if y >= (BLUR_RADIUS) {
blurring.inc_rem();
}
if (blurring.get_line_num() % mcu_height) == 0 && (blurring.get_line_num() < HEIGHT) {
data.decompress();
}
}
dma2d_wait_for_transfer();
}
pub fn homescreen(
data: &mut dyn HomescreenDecompressor,
texts: &[HomescreenText],
notification: Option<HomescreenNotification>,
notification_only: bool,
) {
let mut icon_data = [0_u8; (HOMESCREEN_MAX_ICON_SIZE * HOMESCREEN_MAX_ICON_SIZE / 2) as usize];
let mut text_buffer = BufferText::get_cleared();
let mut fg_buffer_0 = BufferLine4bpp::get_cleared();
let mut img_buffer_0 = BufferLine16bpp::get_cleared();
let mut fg_buffer_1 = BufferLine4bpp::get_cleared();
let mut img_buffer_1 = BufferLine16bpp::get_cleared();
let mut next_text_idx = 0;
let mut text_info = if let Some(notification) = notification {
rect_fill_rounded_buffer(
Rect::from_top_left_and_size(
Point::new(NOTIFICATION_BORDER, 0),
Offset::new(WIDTH - NOTIFICATION_BORDER * 2, NOTIFICATION_HEIGHT),
),
2,
&mut text_buffer,
);
let area = Rect::new(
Point::new(0, NOTIFICATION_BORDER),
Point::new(WIDTH, NOTIFICATION_HEIGHT + NOTIFICATION_BORDER),
);
HomescreenTextInfo {
text_area: area,
text_width: WIDTH,
text_color: notification.color,
icon_area: None,
}
} else {
next_text_idx += 1;
homescreen_position_text(unwrap!(texts.first()), &mut text_buffer, &mut icon_data)
};
set_window(screen());
let mcu_height = data.get_height();
for y in 0..HEIGHT {
if (y % mcu_height) == 0 {
data.decompress();
}
let done = if y % 2 == 0 {
homescreen_line(
&icon_data,
&mut text_buffer,
text_info,
data.get_data(),
&mut fg_buffer_0,
&mut img_buffer_0,
mcu_height,
y,
)
} else {
homescreen_line(
&icon_data,
&mut text_buffer,
text_info,
data.get_data(),
&mut fg_buffer_1,
&mut img_buffer_1,
mcu_height,
y,
)
};
if done {
if notification.is_some() && next_text_idx == 0 {
//finished notification area, let interrupt and draw the text
let notification = unwrap!(notification);
let style = TextStyle {
background_color: notification.color,
..theme::TEXT_BOLD
};
dma2d_wait_for_transfer();
drop(fg_buffer_0);
drop(fg_buffer_1);
icon_text_center(
text_info.text_area.center(),
notification.icon,
8,
notification.text,
style,
Offset::new(1, -2),
);
fg_buffer_0 = BufferLine4bpp::get_cleared();
fg_buffer_1 = BufferLine4bpp::get_cleared();
set_window(
screen()
.split_top(NOTIFICATION_HEIGHT + NOTIFICATION_BORDER)
.1,
);
}
if notification_only && next_text_idx == 0 {
dma2d_wait_for_transfer();
return;
}
(text_info, next_text_idx) = homescreen_next_text(
texts,
&mut text_buffer,
&mut icon_data,
text_info,
next_text_idx,
);
}
}
dma2d_wait_for_transfer();
}

@ -0,0 +1,326 @@
use crate::{
trezorhal::bip39,
ui::{
component::{text::common::TextBox, Component, Event, EventCtx},
display,
geometry::{Alignment2D, Offset, Rect},
model_mercury::{
component::{
keyboard::{
common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard},
mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT},
},
Button, ButtonContent, ButtonMsg,
},
theme,
},
shape,
shape::Renderer,
},
};
use heapless::String;
const MAX_LENGTH: usize = 8;
pub struct Bip39Input {
button: Button<&'static str>,
// used only to keep track of suggestion text color
button_suggestion: Button<&'static str>,
textbox: TextBox<MAX_LENGTH>,
multi_tap: MultiTapKeyboard,
options_num: Option<usize>,
suggested_word: Option<&'static str>,
}
impl MnemonicInput for Bip39Input {
/// Return the key set. Keys are further specified as indices into this
/// array.
fn keys() -> [&'static str; MNEMONIC_KEY_COUNT] {
["abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz"]
}
/// Returns `true` if given key index can continue towards a valid mnemonic
/// word, `false` otherwise.
fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool {
// Currently pending key is always enabled.
let key_is_pending = self.multi_tap.pending_key() == Some(key);
// Keys that contain letters from the completion mask are enabled as well.
let key_matches_mask =
bip39::word_completion_mask(self.textbox.content()) & Self::key_mask(key) != 0;
key_is_pending || key_matches_mask
}
/// Key button was clicked. If this button is pending, let's cycle the
/// pending character in textbox. If not, let's just append the first
/// character.
fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize) {
let edit = self.multi_tap.click_key(ctx, key, Self::keys()[key]);
self.textbox.apply(ctx, edit);
self.complete_word_from_dictionary(ctx);
}
/// Backspace button was clicked, let's delete the last character of input
/// and clear the pending marker.
fn on_backspace_click(&mut self, ctx: &mut EventCtx) {
self.multi_tap.clear_pending_state(ctx);
self.textbox.delete_last(ctx);
self.complete_word_from_dictionary(ctx);
}
/// Backspace button was long pressed, let's delete all characters of input
/// and clear the pending marker.
fn on_backspace_long_press(&mut self, ctx: &mut EventCtx) {
self.multi_tap.clear_pending_state(ctx);
self.textbox.clear(ctx);
self.complete_word_from_dictionary(ctx);
}
fn is_empty(&self) -> bool {
self.textbox.is_empty()
}
fn mnemonic(&self) -> Option<&'static str> {
self.suggested_word
}
}
impl Component for Bip39Input {
type Msg = MnemonicInputMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.button.place(bounds);
self.button_suggestion.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.button_suggestion.event(ctx, event);
if self.multi_tap.is_timeout_event(event) {
self.on_timeout(ctx)
} else if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) {
self.on_input_click(ctx)
} else {
None
}
}
fn paint(&mut self) {
let area = self.button.area();
let style = self.button.style();
// First, paint the button background.
self.button.paint_background(style);
// Paint the entered content (the prefix of the suggested word).
let text = self.textbox.content();
let width = style.font.text_width(text);
// Content starts in the left-center point, offset by 16px to the right and 8px
// to the bottom.
let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8);
display::text_left(
text_baseline,
text,
style.font,
style.text_color,
style.button_color,
);
// Paint the rest of the suggested dictionary word.
if let Some(word) = self.suggested_word.and_then(|w| w.get(text.len()..)) {
let word_baseline = text_baseline + Offset::new(width, 0);
let style = self.button_suggestion.style();
display::text_left(
word_baseline,
word,
style.font,
style.text_color,
style.button_color,
);
}
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() {
paint_pending_marker(text_baseline, text, style.font, style.text_color);
}
// Paint the icon.
if let ButtonContent::Icon(icon) = self.button.content() {
// Icon is painted in the right-center point, of expected size 16x16 pixels, and
// 16px from the right edge.
let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0);
icon.draw(
icon_center,
Alignment2D::CENTER,
style.text_color,
style.button_color,
);
}
}
fn render(&mut self, target: &mut impl Renderer) {
let area = self.button.area();
let style = self.button.style();
// First, paint the button background.
self.button.render_background(target, style);
// Paint the entered content (the prefix of the suggested word).
let text = self.textbox.content();
let width = style.font.text_width(text);
// Content starts in the left-center point, offset by 16px to the right and 8px
// to the bottom.
let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8);
shape::Text::new(text_baseline, text)
.with_font(style.font)
.with_fg(style.text_color)
.render(target);
// Paint the rest of the suggested dictionary word.
if let Some(word) = self.suggested_word.and_then(|w| w.get(text.len()..)) {
let word_baseline = text_baseline + Offset::new(width, 0);
let style = self.button_suggestion.style();
shape::Text::new(word_baseline, word)
.with_font(style.font)
.with_fg(style.text_color)
.render(target);
}
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() {
render_pending_marker(target, text_baseline, text, style.font, style.text_color);
}
// Paint the icon.
if let ButtonContent::Icon(icon) = self.button.content() {
// Icon is painted in the right-center point, of expected size 16x16 pixels, and
// 16px from the right edge.
let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0);
shape::ToifImage::new(icon_center, icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.text_color)
.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.button.bounds(sink);
}
}
impl Bip39Input {
pub fn new() -> Self {
Self {
button: Button::empty(),
textbox: TextBox::empty(),
multi_tap: MultiTapKeyboard::new(),
options_num: None,
suggested_word: None,
button_suggestion: Button::empty(),
}
}
pub fn prefilled_word(word: &str) -> Self {
// Word may be empty string, fallback to normal input
if word.is_empty() {
return Self::new();
}
// Styling the input to reflect already filled word
Self {
button: Button::with_icon(theme::ICON_LIST_CHECK).styled(theme::button_pin_confirm()),
textbox: TextBox::new(String::from(word)),
multi_tap: MultiTapKeyboard::new(),
options_num: bip39::options_num(word),
suggested_word: bip39::complete_word(word),
button_suggestion: Button::empty().styled(theme::button_suggestion_confirm()),
}
}
/// Compute a bitmask of all letters contained in given key text. Lowest bit
/// is 'a', second lowest 'b', etc.
fn key_mask(key: usize) -> u32 {
let mut mask = 0;
for ch in Self::keys()[key].as_bytes() {
// We assume the key text is lower-case alphabetic ASCII, making the subtraction
// and the shift panic-free.
mask |= 1 << (ch - b'a');
}
mask
}
/// Input button was clicked. If the content matches the suggested word,
/// let's confirm it, otherwise just auto-complete.
fn on_input_click(&mut self, ctx: &mut EventCtx) -> Option<MnemonicInputMsg> {
if let (Some(word), Some(num)) = (self.suggested_word, self.options_num) {
return if num == 1 && word.starts_with(self.textbox.content())
|| num > 1 && word.eq(self.textbox.content())
{
// Confirm button.
self.textbox.clear(ctx);
Some(MnemonicInputMsg::Confirmed)
} else {
// Auto-complete button.
self.textbox.replace(ctx, word);
self.complete_word_from_dictionary(ctx);
Some(MnemonicInputMsg::Completed)
};
}
None
}
/// Timeout occurred. If we can auto-complete current input, let's just
/// reset the pending marker. If not, input is invalid, let's backspace the
/// last character.
fn on_timeout(&mut self, ctx: &mut EventCtx) -> Option<MnemonicInputMsg> {
self.multi_tap.clear_pending_state(ctx);
if self.suggested_word.is_none() {
self.textbox.delete_last(ctx);
self.complete_word_from_dictionary(ctx);
}
Some(MnemonicInputMsg::TimedOut)
}
fn complete_word_from_dictionary(&mut self, ctx: &mut EventCtx) {
self.options_num = bip39::options_num(self.textbox.content());
self.suggested_word = bip39::complete_word(self.textbox.content());
// Change the style of the button depending on the completed word.
if let (Some(word), Some(num)) = (self.suggested_word, self.options_num) {
if num == 1 && word.starts_with(self.textbox.content())
|| num > 1 && word.eq(self.textbox.content())
{
// Confirm button.
self.button.enable(ctx);
self.button.set_stylesheet(ctx, theme::button_pin_confirm());
self.button
.set_content(ctx, ButtonContent::Icon(theme::ICON_LIST_CHECK));
self.button_suggestion
.set_stylesheet(ctx, theme::button_suggestion_confirm());
} else {
// Auto-complete button.
self.button.enable(ctx);
self.button
.set_stylesheet(ctx, theme::button_pin_autocomplete());
self.button
.set_content(ctx, ButtonContent::Icon(theme::ICON_CLICK));
self.button_suggestion
.set_stylesheet(ctx, theme::button_suggestion_autocomplete());
}
} else {
// Disabled button.
self.button.disable(ctx);
self.button.set_stylesheet(ctx, theme::button_pin());
self.button.set_content(ctx, ButtonContent::Text(""));
}
}
}
// DEBUG-ONLY SECTION BELOW
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Bip39Input {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Bip39Input");
t.child("textbox", &self.textbox);
}
}

@ -0,0 +1,152 @@
use crate::{
time::Duration,
ui::{
component::{text::common::TextEdit, Event, EventCtx, TimerToken},
display::{self, Color, Font},
geometry::{Offset, Point, Rect},
shape,
shape::Renderer,
},
};
/// Contains state commonly used in implementations multi-tap keyboards.
pub struct MultiTapKeyboard {
/// Configured timeout after which we cancel currently pending key.
timeout: Duration,
/// The currently pending state.
pending: Option<Pending>,
}
struct Pending {
/// Index of the pending key.
key: usize,
/// Index of the key press (how many times the `key` was pressed, minus
/// one).
press: usize,
/// Timer for clearing the pending state.
timer: TimerToken,
}
impl MultiTapKeyboard {
/// Create a new, empty, multi-tap state.
pub fn new() -> Self {
Self {
timeout: Duration::from_secs(1),
pending: None,
}
}
/// Return the index of the currently pending key, if any.
pub fn pending_key(&self) -> Option<usize> {
self.pending.as_ref().map(|p| p.key)
}
/// Return the index of the pending key press.
pub fn pending_press(&self) -> Option<usize> {
self.pending.as_ref().map(|p| p.press)
}
/// Return the token for the currently pending timer.
pub fn pending_timer(&self) -> Option<TimerToken> {
self.pending.as_ref().map(|p| p.timer)
}
/// Returns `true` if `event` is an `Event::Timer` for the currently pending
/// timer.
pub fn is_timeout_event(&self, event: Event) -> bool {
matches!((event, self.pending_timer()), (Event::Timer(t), Some(pt)) if pt == t)
}
/// Reset to the empty state. Takes `EventCtx` to request a paint pass (to
/// either hide or show any pending marker our caller might want to draw
/// later).
pub fn clear_pending_state(&mut self, ctx: &mut EventCtx) {
if self.pending.is_some() {
self.pending = None;
ctx.request_paint();
}
}
/// Register a click to a key. `MultiTapKeyboard` itself does not have any
/// concept of the key set, so both the key index and the key content is
/// taken here. Returns a text editing operation the caller should apply on
/// the output buffer. Takes `EventCtx` to request a timeout for cancelling
/// the pending state. Caller is required to handle the timer event and
/// call `Self::clear_pending_state` when the timer hits.
pub fn click_key(&mut self, ctx: &mut EventCtx, key: usize, key_text: &str) -> TextEdit {
let (is_pending, press) = match &self.pending {
Some(pending) if pending.key == key => {
// This key is pending. Cycle the last inserted character through the
// key content.
(true, pending.press.wrapping_add(1))
}
_ => {
// This key is not pending. Append the first character in the key.
(false, 0)
}
};
// If the key has more then one character, we need to set it as pending, so we
// can cycle through on the repeated clicks. We also request a timer so we can
// reset the pending state after a deadline.
//
// Note: It might seem that we should make sure to `request_paint` in case we
// progress into a pending state (to display the pending marker), but such
// transition only happens as a result of an append op, so the painting should
// be requested by handling the `TextEdit`.
self.pending = if key_text.len() > 1 {
Some(Pending {
key,
press,
timer: ctx.request_timer(self.timeout),
})
} else {
None
};
assert!(!key_text.is_empty());
// Now we can be sure that a looped iterator will return a value
let ch = unwrap!(key_text.chars().cycle().nth(press));
if is_pending {
TextEdit::ReplaceLast(ch)
} else {
TextEdit::Append(ch)
}
}
}
/// Create a visible "underscoring" of the last letter of a text.
pub fn paint_pending_marker(text_baseline: Point, text: &str, font: Font, color: Color) {
// Measure the width of the last character of input.
if let Some(last) = text.chars().last() {
let width = font.text_width(text);
let last_width = font.char_width(last);
// Draw the marker 2px under the start of the baseline of the last character.
let marker_origin = text_baseline + Offset::new(width - last_width, 2);
// Draw the marker 1px longer than the last character, and 3px thick.
let marker_rect =
Rect::from_top_left_and_size(marker_origin, Offset::new(last_width + 1, 3));
display::rect_fill(marker_rect, color);
}
}
/// Create a visible "underscoring" of the last letter of a text.
pub fn render_pending_marker(
target: &mut impl Renderer,
text_baseline: Point,
text: &str,
font: Font,
color: Color,
) {
// Measure the width of the last character of input.
if let Some(last) = text.chars().last() {
let width = font.text_width(text);
let last_width = font.char_width(last);
// Draw the marker 2px under the start of the baseline of the last character.
let marker_origin = text_baseline + Offset::new(width - last_width, 2);
// Draw the marker 1px longer than the last character, and 3px thick.
let marker_rect =
Rect::from_top_left_and_size(marker_origin, Offset::new(last_width + 1, 3));
shape::Bar::new(marker_rect).with_bg(color).render(target);
}
}

@ -0,0 +1,234 @@
use crate::ui::{
component::{maybe::paint_overlapping, Child, Component, Event, EventCtx, Label, Maybe},
geometry::{Alignment2D, Grid, Offset, Rect},
model_mercury::{
component::{Button, ButtonMsg, Swipe, SwipeDirection},
theme,
},
shape::Renderer,
};
pub const MNEMONIC_KEY_COUNT: usize = 9;
pub enum MnemonicKeyboardMsg {
Confirmed,
Previous,
}
pub struct MnemonicKeyboard<T, U> {
/// Initial prompt, displayed on empty input.
prompt: Child<Maybe<Label<U>>>,
/// Backspace button.
back: Child<Maybe<Button<&'static str>>>,
/// Input area, acting as the auto-complete and confirm button.
input: Child<Maybe<T>>,
/// Key buttons.
keys: [Child<Button<&'static str>>; MNEMONIC_KEY_COUNT],
/// Swipe controller - allowing for going to the previous word.
swipe: Swipe,
/// Whether going back is allowed (is not on the very first word).
can_go_back: bool,
}
impl<T, U> MnemonicKeyboard<T, U>
where
T: MnemonicInput,
U: AsRef<str>,
{
pub fn new(input: T, prompt: U, can_go_back: bool) -> Self {
// Input might be already pre-filled
let prompt_visible = input.is_empty();
Self {
prompt: Child::new(Maybe::new(
theme::BG,
Label::centered(prompt, theme::label_keyboard_prompt()),
prompt_visible,
)),
back: Child::new(Maybe::new(
theme::BG,
Button::with_icon_blend(
theme::IMAGE_BG_BACK_BTN_TALL,
theme::ICON_BACK,
Offset::new(30, 17),
)
.styled(theme::button_reset())
.with_long_press(theme::ERASE_HOLD_DURATION),
!prompt_visible,
)),
input: Child::new(Maybe::new(theme::BG, input, !prompt_visible)),
keys: T::keys()
.map(|t| Button::with_text(t).styled(theme::button_pin()))
.map(Child::new),
swipe: Swipe::new().right(),
can_go_back,
}
}
fn on_input_change(&mut self, ctx: &mut EventCtx) {
self.toggle_key_buttons(ctx);
self.toggle_prompt_or_input(ctx);
}
/// Either enable or disable the key buttons, depending on the dictionary
/// completion mask and the pending key.
fn toggle_key_buttons(&mut self, ctx: &mut EventCtx) {
for (key, btn) in self.keys.iter_mut().enumerate() {
let enabled = self
.input
.inner()
.inner()
.can_key_press_lead_to_a_valid_word(key);
btn.mutate(ctx, |ctx, b| b.enable_if(ctx, enabled));
}
}
/// After edit operations, we need to either show or hide the prompt, the
/// input, and the back button.
fn toggle_prompt_or_input(&mut self, ctx: &mut EventCtx) {
let prompt_visible = self.input.inner().inner().is_empty();
self.prompt
.mutate(ctx, |ctx, p| p.show_if(ctx, prompt_visible));
self.input
.mutate(ctx, |ctx, i| i.show_if(ctx, !prompt_visible));
self.back
.mutate(ctx, |ctx, b| b.show_if(ctx, !prompt_visible));
}
pub fn mnemonic(&self) -> Option<&'static str> {
self.input.inner().inner().mnemonic()
}
}
impl<T, U> Component for MnemonicKeyboard<T, U>
where
T: MnemonicInput,
U: AsRef<str>,
{
type Msg = MnemonicKeyboardMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let (_, bounds) = bounds
.inset(theme::borders())
.split_bottom(4 * theme::MNEMONIC_BUTTON_HEIGHT + 3 * theme::KEYBOARD_SPACING);
let grid = Grid::new(bounds, 4, 3).with_spacing(theme::KEYBOARD_SPACING);
let back_area = grid.row_col(0, 0);
let input_area = grid.row_col(0, 1).union(grid.row_col(0, 3));
let prompt_center = grid.row_col(0, 0).union(grid.row_col(0, 3)).center();
let prompt_size = self.prompt.inner().inner().max_size();
let prompt_area = Rect::snap(prompt_center, prompt_size, Alignment2D::CENTER);
self.swipe.place(bounds);
self.prompt.place(prompt_area);
self.back.place(back_area);
self.input.place(input_area);
for (key, btn) in self.keys.iter_mut().enumerate() {
btn.place(grid.cell(key + grid.cols)); // Start in the second row.
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
// Swipe will cause going back to the previous word when allowed.
if self.can_go_back {
if let Some(SwipeDirection::Right) = self.swipe.event(ctx, event) {
return Some(MnemonicKeyboardMsg::Previous);
}
}
match self.input.event(ctx, event) {
Some(MnemonicInputMsg::Confirmed) => {
// Confirmed, bubble up.
return Some(MnemonicKeyboardMsg::Confirmed);
}
Some(_) => {
// Either a timeout or a completion.
self.on_input_change(ctx);
return None;
}
_ => {}
}
match self.back.event(ctx, event) {
Some(ButtonMsg::Clicked) => {
self.input
.mutate(ctx, |ctx, i| i.inner_mut().on_backspace_click(ctx));
self.on_input_change(ctx);
return None;
}
Some(ButtonMsg::LongPressed) => {
self.input
.mutate(ctx, |ctx, i| i.inner_mut().on_backspace_long_press(ctx));
self.on_input_change(ctx);
return None;
}
_ => {}
}
for (key, btn) in self.keys.iter_mut().enumerate() {
if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) {
self.input
.mutate(ctx, |ctx, i| i.inner_mut().on_key_click(ctx, key));
self.on_input_change(ctx);
return None;
}
}
None
}
fn paint(&mut self) {
paint_overlapping(&mut [&mut self.prompt, &mut self.input, &mut self.back]);
for btn in &mut self.keys {
btn.paint();
}
}
fn render(&mut self, target: &mut impl Renderer) {
self.prompt.render(target);
self.input.render(target);
self.back.render(target);
for btn in &mut self.keys {
btn.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.prompt.bounds(sink);
self.input.bounds(sink);
self.back.bounds(sink);
for btn in &self.keys {
btn.bounds(sink)
}
}
}
pub trait MnemonicInput: Component<Msg = MnemonicInputMsg> {
fn keys() -> [&'static str; MNEMONIC_KEY_COUNT];
fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool;
fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize);
fn on_backspace_click(&mut self, ctx: &mut EventCtx);
fn on_backspace_long_press(&mut self, ctx: &mut EventCtx);
fn is_empty(&self) -> bool;
fn mnemonic(&self) -> Option<&'static str>;
}
pub enum MnemonicInputMsg {
Confirmed,
Completed,
TimedOut,
}
#[cfg(feature = "ui_debug")]
impl<T, U> crate::trace::Trace for MnemonicKeyboard<T, U>
where
T: MnemonicInput + crate::trace::Trace,
U: AsRef<str>,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("MnemonicKeyboard");
t.child("prompt", &self.prompt);
t.child("input", &self.input);
}
}

@ -0,0 +1,8 @@
pub mod bip39;
pub mod mnemonic;
pub mod passphrase;
pub mod pin;
pub mod slip39;
pub mod word_count;
mod common;

@ -0,0 +1,441 @@
use crate::ui::{
component::{
base::ComponentExt, text::common::TextBox, Child, Component, Event, EventCtx, Never,
},
display,
geometry::{Grid, Offset, Rect},
model_mercury::component::{
button::{Button, ButtonContent, ButtonMsg},
keyboard::common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard},
swipe::{Swipe, SwipeDirection},
theme, ScrollBar,
},
shape,
shape::Renderer,
util::long_line_content_with_ellipsis,
};
pub enum PassphraseKeyboardMsg {
Confirmed,
Cancelled,
}
pub struct PassphraseKeyboard {
page_swipe: Swipe,
input: Child<Input>,
back: Child<Button<&'static str>>,
confirm: Child<Button<&'static str>>,
keys: [Child<Button<&'static str>>; KEY_COUNT],
scrollbar: ScrollBar,
fade: bool,
}
const STARTING_PAGE: usize = 1;
const PAGE_COUNT: usize = 4;
const KEY_COUNT: usize = 10;
#[rustfmt::skip]
const KEYBOARD: [[&str; KEY_COUNT]; PAGE_COUNT] = [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
[" ", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz", "*#"],
[" ", "ABC", "DEF", "GHI", "JKL", "MNO", "PQRS", "TUV", "WXYZ", "*#"],
["_<>", ".:@", "/|\\", "!()", "+%&", "-[]", "?{}", ",'`", ";\"~", "$^="],
];
const MAX_LENGTH: usize = 50;
const INPUT_AREA_HEIGHT: i16 = ScrollBar::DOT_SIZE + 9;
impl PassphraseKeyboard {
pub fn new() -> Self {
Self {
page_swipe: Swipe::horizontal(),
input: Input::new().into_child(),
confirm: Button::with_icon(theme::ICON_CONFIRM)
.styled(theme::button_confirm())
.into_child(),
back: Button::with_icon_blend(
theme::IMAGE_BG_BACK_BTN,
theme::ICON_BACK,
Offset::new(30, 12),
)
.styled(theme::button_reset())
.initially_enabled(false)
.with_long_press(theme::ERASE_HOLD_DURATION)
.into_child(),
keys: KEYBOARD[STARTING_PAGE].map(|text| {
Child::new(Button::new(Self::key_content(text)).styled(theme::button_pin()))
}),
scrollbar: ScrollBar::horizontal(),
fade: false,
}
}
fn key_text(content: &ButtonContent<&'static str>) -> &'static str {
match content {
ButtonContent::Text(text) => text,
ButtonContent::Icon(_) => " ",
ButtonContent::IconAndText(_) => " ",
ButtonContent::Empty => "",
ButtonContent::IconBlend(_, _, _) => "",
}
}
fn key_content(text: &'static str) -> ButtonContent<&'static str> {
match text {
" " => ButtonContent::Icon(theme::ICON_SPACE),
t => ButtonContent::Text(t),
}
}
fn on_page_swipe(&mut self, ctx: &mut EventCtx, swipe: SwipeDirection) {
// Change the page number.
let key_page = self.scrollbar.active_page;
let key_page = match swipe {
SwipeDirection::Left => (key_page as isize + 1) as usize % PAGE_COUNT,
SwipeDirection::Right => (key_page as isize - 1) as usize % PAGE_COUNT,
_ => key_page,
};
self.scrollbar.go_to(key_page);
// Clear the pending state.
self.input
.mutate(ctx, |ctx, i| i.multi_tap.clear_pending_state(ctx));
// Update buttons.
self.replace_button_content(ctx, key_page);
// Reset backlight to normal level on next paint.
self.fade = true;
// So that swipe does not visually enable the input buttons when max length
// reached
self.update_input_btns_state(ctx);
}
fn replace_button_content(&mut self, ctx: &mut EventCtx, page: usize) {
for (i, btn) in self.keys.iter_mut().enumerate() {
let text = KEYBOARD[page][i];
let content = Self::key_content(text);
btn.mutate(ctx, |ctx, b| b.set_content(ctx, content));
btn.request_complete_repaint(ctx);
}
}
/// Possibly changing the buttons' state after change of the input.
fn after_edit(&mut self, ctx: &mut EventCtx) {
self.update_back_btn_state(ctx);
self.update_input_btns_state(ctx);
}
/// When the input is empty, disable the back button.
fn update_back_btn_state(&mut self, ctx: &mut EventCtx) {
if self.input.inner().textbox.is_empty() {
self.back.mutate(ctx, |ctx, b| b.disable(ctx));
} else {
self.back.mutate(ctx, |ctx, b| b.enable(ctx));
}
}
/// When the input has reached max length, disable all the input buttons.
fn update_input_btns_state(&mut self, ctx: &mut EventCtx) {
let active_states = self.get_buttons_active_states();
for (key, btn) in self.keys.iter_mut().enumerate() {
btn.mutate(ctx, |ctx, b| {
if active_states[key] {
b.enable(ctx);
} else {
b.disable(ctx);
}
});
}
}
/// Precomputing the active states not to overlap borrows in
/// `self.keys.iter_mut` loop.
fn get_buttons_active_states(&self) -> [bool; KEY_COUNT] {
let mut active_states: [bool; KEY_COUNT] = [false; KEY_COUNT];
for (key, state) in active_states.iter_mut().enumerate() {
*state = self.is_button_active(key);
}
active_states
}
/// We should disable the input when the passphrase has reached maximum
/// length and we are not cycling through the characters.
fn is_button_active(&self, key: usize) -> bool {
let textbox_not_full = !self.input.inner().textbox.is_full();
let key_is_pending = {
if let Some(pending) = self.input.inner().multi_tap.pending_key() {
pending == key
} else {
false
}
};
textbox_not_full || key_is_pending
}
pub fn passphrase(&self) -> &str {
self.input.inner().textbox.content()
}
}
impl Component for PassphraseKeyboard {
type Msg = PassphraseKeyboardMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let bounds = bounds.inset(theme::borders());
let (input_area, key_grid_area) =
bounds.split_bottom(4 * theme::PIN_BUTTON_HEIGHT + 3 * theme::BUTTON_SPACING);
let (input_area, scroll_area) = input_area.split_bottom(INPUT_AREA_HEIGHT);
let (scroll_area, _) = scroll_area.split_top(ScrollBar::DOT_SIZE);
let key_grid = Grid::new(key_grid_area, 4, 3).with_spacing(theme::BUTTON_SPACING);
let confirm_btn_area = key_grid.cell(11);
let back_btn_area = key_grid.cell(9);
self.page_swipe.place(bounds);
self.input.place(input_area);
self.confirm.place(confirm_btn_area);
self.back.place(back_btn_area);
self.scrollbar.place(scroll_area);
self.scrollbar
.set_count_and_active_page(PAGE_COUNT, STARTING_PAGE);
// Place all the character buttons.
for (key, btn) in &mut self.keys.iter_mut().enumerate() {
// Assign the keys in each page to buttons on a 5x3 grid, starting
// from the second row.
let area = key_grid.cell(if key < 9 {
// The grid has 3 columns, and we skip the first row.
key
} else {
// For the last key (the "0" position) we skip one cell.
key + 1
});
btn.place(area);
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if self.input.inner().multi_tap.is_timeout_event(event) {
self.input
.mutate(ctx, |ctx, i| i.multi_tap.clear_pending_state(ctx));
return None;
}
if let Some(swipe) = self.page_swipe.event(ctx, event) {
// We have detected a horizontal swipe. Change the keyboard page.
self.on_page_swipe(ctx, swipe);
return None;
}
if let Some(ButtonMsg::Clicked) = self.confirm.event(ctx, event) {
// Confirm button was clicked, we're done.
return Some(PassphraseKeyboardMsg::Confirmed);
}
match self.back.event(ctx, event) {
Some(ButtonMsg::Clicked) => {
// Backspace button was clicked. If we have any content in the textbox, let's
// delete the last character. Otherwise cancel.
return if self.input.inner().textbox.is_empty() {
Some(PassphraseKeyboardMsg::Cancelled)
} else {
self.input.mutate(ctx, |ctx, i| {
i.multi_tap.clear_pending_state(ctx);
i.textbox.delete_last(ctx);
});
self.after_edit(ctx);
None
};
}
Some(ButtonMsg::LongPressed) => {
self.input.mutate(ctx, |ctx, i| {
i.multi_tap.clear_pending_state(ctx);
i.textbox.clear(ctx);
});
self.after_edit(ctx);
return None;
}
_ => {}
}
// Process key button events in case we did not reach maximum passphrase length.
// (All input buttons should be disallowed in that case, this is just a safety
// measure.)
// Also we need to allow for cycling through the last character.
let active_states = self.get_buttons_active_states();
for (key, btn) in self.keys.iter_mut().enumerate() {
if !active_states[key] {
// Button is not active
continue;
}
if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) {
// Key button was clicked. If this button is pending, let's cycle the pending
// character in textbox. If not, let's just append the first character.
let text = Self::key_text(btn.inner().content());
self.input.mutate(ctx, |ctx, i| {
let edit = i.multi_tap.click_key(ctx, key, text);
i.textbox.apply(ctx, edit);
});
self.after_edit(ctx);
return None;
}
}
None
}
fn paint(&mut self) {
self.input.paint();
self.scrollbar.paint();
self.confirm.paint();
self.back.paint();
for btn in &mut self.keys {
btn.paint();
}
if self.fade {
self.fade = false;
// Note that this is blocking and takes some time.
display::fade_backlight(theme::BACKLIGHT_NORMAL);
}
}
fn render(&mut self, target: &mut impl Renderer) {
self.input.render(target);
self.scrollbar.render(target);
self.confirm.render(target);
self.back.render(target);
for btn in &mut self.keys {
btn.render(target);
}
if self.fade {
self.fade = false;
// Note that this is blocking and takes some time.
display::fade_backlight(theme::BACKLIGHT_NORMAL);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.input.bounds(sink);
self.scrollbar.bounds(sink);
self.confirm.bounds(sink);
self.back.bounds(sink);
for btn in &self.keys {
btn.bounds(sink)
}
}
}
struct Input {
area: Rect,
textbox: TextBox<MAX_LENGTH>,
multi_tap: MultiTapKeyboard,
}
impl Input {
fn new() -> Self {
Self {
area: Rect::zero(),
textbox: TextBox::empty(),
multi_tap: MultiTapKeyboard::new(),
}
}
}
impl Component for Input {
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) {
let style = theme::label_keyboard();
let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height())
- Offset::y(style.text_font.text_baseline());
let text = self.textbox.content();
// Preparing the new text to be displayed.
// Possible optimization is to redraw the background only when pending character
// is replaced, or only draw rectangle over the pending character and
// marker.
display::rect_fill(self.area, theme::BG);
// Find out how much text can fit into the textbox.
// Accounting for the pending marker, which draws itself one pixel longer than
// the last character
let available_area_width = self.area.width() - 1;
let text_to_display =
long_line_content_with_ellipsis(text, "...", style.text_font, available_area_width);
display::text_left(
text_baseline,
&text_to_display,
style.text_font,
style.text_color,
style.background_color,
);
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() {
paint_pending_marker(
text_baseline,
&text_to_display,
style.text_font,
style.text_color,
);
}
}
fn render(&mut self, target: &mut impl Renderer) {
let style = theme::label_keyboard();
let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height())
- Offset::y(style.text_font.text_baseline());
let text = self.textbox.content();
shape::Bar::new(self.area).with_bg(theme::BG).render(target);
// Find out how much text can fit into the textbox.
// Accounting for the pending marker, which draws itself one pixel longer than
// the last character
let available_area_width = self.area.width() - 1;
let text_to_display =
long_line_content_with_ellipsis(text, "...", style.text_font, available_area_width);
shape::Text::new(text_baseline, &text_to_display)
.with_font(style.text_font)
.with_fg(style.text_color)
.render(target);
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() {
render_pending_marker(
target,
text_baseline,
&text_to_display,
style.text_font,
style.text_color,
);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area)
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for PassphraseKeyboard {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("PassphraseKeyboard");
t.string("passphrase", self.passphrase().into());
}
}

@ -0,0 +1,572 @@
use core::mem;
use heapless::String;
use crate::{
time::Duration,
trezorhal::random,
ui::{
component::{
base::ComponentExt, text::TextStyle, Child, Component, Event, EventCtx, Label, Maybe,
Never, Pad, TimerToken,
},
display::{self, Font},
event::TouchEvent,
geometry::{Alignment, Alignment2D, Grid, Insets, Offset, Rect},
model_mercury::component::{
button::{Button, ButtonContent, ButtonMsg, ButtonMsg::Clicked},
theme,
},
shape,
shape::Renderer,
},
};
pub enum PinKeyboardMsg {
Confirmed,
Cancelled,
}
const MAX_LENGTH: usize = 50;
const MAX_VISIBLE_DOTS: usize = 14;
const MAX_VISIBLE_DIGITS: usize = 16;
const DIGIT_COUNT: usize = 10; // 0..10
const HEADER_PADDING_SIDE: i16 = 5;
const HEADER_PADDING_BOTTOM: i16 = 12;
const HEADER_PADDING: Insets = Insets::new(
theme::borders().top,
HEADER_PADDING_SIDE,
HEADER_PADDING_BOTTOM,
HEADER_PADDING_SIDE,
);
pub struct PinKeyboard<T> {
allow_cancel: bool,
major_prompt: Child<Label<T>>,
minor_prompt: Child<Label<T>>,
major_warning: Option<Child<Label<T>>>,
textbox: Child<PinDots>,
textbox_pad: Pad,
erase_btn: Child<Maybe<Button<&'static str>>>,
cancel_btn: Child<Maybe<Button<&'static str>>>,
confirm_btn: Child<Button<&'static str>>,
digit_btns: [Child<Button<&'static str>>; DIGIT_COUNT],
warning_timer: Option<TimerToken>,
}
impl<T> PinKeyboard<T>
where
T: AsRef<str>,
{
// Label position fine-tuning.
const MAJOR_OFF: Offset = Offset::y(11);
const MINOR_OFF: Offset = Offset::y(11);
pub fn new(
major_prompt: T,
minor_prompt: T,
major_warning: Option<T>,
allow_cancel: bool,
) -> Self {
// Control buttons.
let erase_btn = Button::with_icon_blend(
theme::IMAGE_BG_BACK_BTN,
theme::ICON_BACK,
Offset::new(30, 12),
)
.styled(theme::button_reset())
.with_long_press(theme::ERASE_HOLD_DURATION)
.initially_enabled(false);
let erase_btn = Maybe::hidden(theme::BG, erase_btn).into_child();
let cancel_btn = Button::with_icon(theme::ICON_CANCEL).styled(theme::button_cancel());
let cancel_btn = Maybe::new(theme::BG, cancel_btn, allow_cancel).into_child();
Self {
allow_cancel,
major_prompt: Label::left_aligned(major_prompt, theme::label_keyboard()).into_child(),
minor_prompt: Label::right_aligned(minor_prompt, theme::label_keyboard_minor())
.into_child(),
major_warning: major_warning.map(|text| {
Label::left_aligned(text, theme::label_keyboard_warning()).into_child()
}),
textbox: PinDots::new(theme::label_default()).into_child(),
textbox_pad: Pad::with_background(theme::label_default().background_color),
erase_btn,
cancel_btn,
confirm_btn: Button::with_icon(theme::ICON_CONFIRM)
.styled(theme::button_confirm())
.initially_enabled(false)
.into_child(),
digit_btns: Self::generate_digit_buttons(),
warning_timer: None,
}
}
fn generate_digit_buttons() -> [Child<Button<&'static str>>; DIGIT_COUNT] {
// Generate a random sequence of digits from 0 to 9.
let mut digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
random::shuffle(&mut digits);
digits
.map(Button::with_text)
.map(|b| b.styled(theme::button_pin()))
.map(Child::new)
}
fn pin_modified(&mut self, ctx: &mut EventCtx) {
let is_full = self.textbox.inner().is_full();
let is_empty = self.textbox.inner().is_empty();
self.textbox_pad.clear();
self.textbox.request_complete_repaint(ctx);
if is_empty {
self.major_prompt.request_complete_repaint(ctx);
self.minor_prompt.request_complete_repaint(ctx);
self.major_warning.request_complete_repaint(ctx);
}
let cancel_enabled = is_empty && self.allow_cancel;
for btn in &mut self.digit_btns {
btn.mutate(ctx, |ctx, btn| btn.enable_if(ctx, !is_full));
}
self.erase_btn.mutate(ctx, |ctx, btn| {
btn.show_if(ctx, !is_empty);
btn.inner_mut().enable_if(ctx, !is_empty);
});
self.cancel_btn.mutate(ctx, |ctx, btn| {
btn.show_if(ctx, cancel_enabled);
btn.inner_mut().enable_if(ctx, is_empty);
});
self.confirm_btn
.mutate(ctx, |ctx, btn| btn.enable_if(ctx, !is_empty));
}
pub fn pin(&self) -> &str {
self.textbox.inner().pin()
}
}
impl<T> Component for PinKeyboard<T>
where
T: AsRef<str>,
{
type Msg = PinKeyboardMsg;
fn place(&mut self, bounds: Rect) -> Rect {
// Ignore the top padding for now, we need it to reliably register textbox touch
// events.
let borders_no_top = Insets {
top: 0,
..theme::borders()
};
// Prompts and PIN dots display.
let (header, keypad) = bounds
.inset(borders_no_top)
.split_bottom(4 * theme::PIN_BUTTON_HEIGHT + 3 * theme::BUTTON_SPACING);
let prompt = header.inset(HEADER_PADDING);
// the inset -3 is a workaround for long text in "re-enter wipe code"
let major_area = prompt.translate(Self::MAJOR_OFF).inset(Insets::right(-3));
let minor_area = prompt.translate(Self::MINOR_OFF);
// Control buttons.
let grid = Grid::new(keypad, 4, 3).with_spacing(theme::BUTTON_SPACING);
// Prompts and PIN dots display.
self.textbox_pad.place(header);
self.textbox.place(header);
self.major_prompt.place(major_area);
self.minor_prompt.place(minor_area);
self.major_warning.as_mut().map(|c| c.place(major_area));
// Control buttons.
let erase_cancel_area = grid.row_col(3, 0);
self.erase_btn.place(erase_cancel_area);
self.cancel_btn.place(erase_cancel_area);
self.confirm_btn.place(grid.row_col(3, 2));
// Digit buttons.
for (i, btn) in self.digit_btns.iter_mut().enumerate() {
// Assign the digits to buttons on a 4x3 grid, starting from the first row.
let area = grid.cell(if i < 9 {
i
} else {
// For the last key (the "0" position) we skip one cell.
i + 1
});
btn.place(area);
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
match event {
// Set up timer to switch off warning prompt.
Event::Attach if self.major_warning.is_some() => {
self.warning_timer = Some(ctx.request_timer(Duration::from_secs(2)));
}
// Hide warning, show major prompt.
Event::Timer(token) if Some(token) == self.warning_timer => {
self.major_warning = None;
self.textbox_pad.clear();
self.minor_prompt.request_complete_repaint(ctx);
ctx.request_paint();
}
_ => {}
}
self.textbox.event(ctx, event);
if let Some(Clicked) = self.confirm_btn.event(ctx, event) {
return Some(PinKeyboardMsg::Confirmed);
}
if let Some(Clicked) = self.cancel_btn.event(ctx, event) {
return Some(PinKeyboardMsg::Cancelled);
}
match self.erase_btn.event(ctx, event) {
Some(ButtonMsg::Clicked) => {
self.textbox.mutate(ctx, |ctx, t| t.pop(ctx));
self.pin_modified(ctx);
return None;
}
Some(ButtonMsg::LongPressed) => {
self.textbox.mutate(ctx, |ctx, t| t.clear(ctx));
self.pin_modified(ctx);
return None;
}
_ => {}
}
for btn in &mut self.digit_btns {
if let Some(Clicked) = btn.event(ctx, event) {
if let ButtonContent::Text(text) = btn.inner().content() {
self.textbox.mutate(ctx, |ctx, t| t.push(ctx, text));
self.pin_modified(ctx);
return None;
}
}
}
None
}
fn paint(&mut self) {
self.erase_btn.paint();
self.textbox_pad.paint();
if self.textbox.inner().is_empty() {
if let Some(ref mut w) = self.major_warning {
w.paint();
} else {
self.major_prompt.paint();
}
self.minor_prompt.paint();
self.cancel_btn.paint();
} else {
self.textbox.paint();
}
self.confirm_btn.paint();
for btn in &mut self.digit_btns {
btn.paint();
}
}
fn render(&mut self, target: &mut impl Renderer) {
self.erase_btn.render(target);
self.textbox_pad.render(target);
if self.textbox.inner().is_empty() {
if let Some(ref mut w) = self.major_warning {
w.render(target);
} else {
self.major_prompt.render(target);
}
self.minor_prompt.render(target);
self.cancel_btn.render(target);
} else {
self.textbox.render(target);
}
self.confirm_btn.render(target);
for btn in &mut self.digit_btns {
btn.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.major_prompt.bounds(sink);
self.minor_prompt.bounds(sink);
self.erase_btn.bounds(sink);
self.cancel_btn.bounds(sink);
self.confirm_btn.bounds(sink);
self.textbox.bounds(sink);
for b in &self.digit_btns {
b.bounds(sink)
}
}
}
struct PinDots {
area: Rect,
pad: Pad,
style: TextStyle,
digits: String<MAX_LENGTH>,
display_digits: bool,
}
impl PinDots {
const DOT: i16 = 6;
const PADDING: i16 = 6;
const TWITCH: i16 = 4;
fn new(style: TextStyle) -> Self {
Self {
area: Rect::zero(),
pad: Pad::with_background(style.background_color),
style,
digits: String::new(),
display_digits: false,
}
}
fn size(&self) -> Offset {
let ndots = self.digits.len().min(MAX_VISIBLE_DOTS);
let mut width = Self::DOT * (ndots as i16);
width += Self::PADDING * (ndots.saturating_sub(1) as i16);
Offset::new(width, Self::DOT)
}
fn is_empty(&self) -> bool {
self.digits.is_empty()
}
fn is_full(&self) -> bool {
self.digits.len() == self.digits.capacity()
}
fn clear(&mut self, ctx: &mut EventCtx) {
self.digits.clear();
ctx.request_paint()
}
fn push(&mut self, ctx: &mut EventCtx, text: &str) {
if self.digits.push_str(text).is_err() {
// `self.pin` is full and wasn't able to accept all of
// `text`. Should not happen.
};
ctx.request_paint()
}
fn pop(&mut self, ctx: &mut EventCtx) {
if self.digits.pop().is_some() {
ctx.request_paint()
}
}
fn pin(&self) -> &str {
&self.digits
}
fn paint_digits(&self, area: Rect) {
let center = area.center() + Offset::y(Font::MONO.text_height() / 2);
let right = center + Offset::x(Font::MONO.text_width("0") * (MAX_VISIBLE_DOTS as i16) / 2);
let digits = self.digits.len();
if digits <= MAX_VISIBLE_DOTS {
display::text_center(
center,
&self.digits,
Font::MONO,
self.style.text_color,
self.style.background_color,
);
} else {
let offset: usize = digits.saturating_sub(MAX_VISIBLE_DIGITS);
display::text_right(
right,
&self.digits[offset..],
Font::MONO,
self.style.text_color,
self.style.background_color,
);
}
}
fn render_digits(&self, area: Rect, target: &mut impl Renderer) {
let center = area.center() + Offset::y(Font::MONO.text_height() / 2);
let right = center + Offset::x(Font::MONO.text_width("0") * (MAX_VISIBLE_DOTS as i16) / 2);
let digits = self.digits.len();
if digits <= MAX_VISIBLE_DOTS {
shape::Text::new(center, &self.digits)
.with_align(Alignment::Center)
.with_font(Font::MONO)
.with_fg(self.style.text_color)
.render(target);
} else {
let offset: usize = digits.saturating_sub(MAX_VISIBLE_DIGITS);
shape::Text::new(right, &self.digits[offset..])
.with_align(Alignment::End)
.with_font(Font::MONO)
.with_fg(self.style.text_color)
.render(target);
}
}
fn paint_dots(&self, area: Rect) {
let mut cursor = self.size().snap(area.center(), Alignment2D::CENTER);
let digits = self.digits.len();
let dots_visible = digits.min(MAX_VISIBLE_DOTS);
let step = Self::DOT + Self::PADDING;
// Jiggle when overflowed.
if digits > dots_visible && digits % 2 == 0 {
cursor.x += Self::TWITCH
}
// Small leftmost dot.
if digits > dots_visible + 1 {
theme::DOT_SMALL.draw(
cursor - Offset::x(2 * step),
Alignment2D::TOP_LEFT,
self.style.text_color,
self.style.background_color,
);
}
// Greyed out dot.
if digits > dots_visible {
theme::DOT_ACTIVE.draw(
cursor - Offset::x(step),
Alignment2D::TOP_LEFT,
theme::GREY_LIGHT,
self.style.background_color,
);
}
// Draw a dot for each PIN digit.
for _ in 0..dots_visible {
theme::DOT_ACTIVE.draw(
cursor,
Alignment2D::TOP_LEFT,
self.style.text_color,
self.style.background_color,
);
cursor.x += step;
}
}
fn render_dots(&self, area: Rect, target: &mut impl Renderer) {
let mut cursor = self.size().snap(area.center(), Alignment2D::CENTER);
let digits = self.digits.len();
let dots_visible = digits.min(MAX_VISIBLE_DOTS);
let step = Self::DOT + Self::PADDING;
// Jiggle when overflowed.
if digits > dots_visible && digits % 2 == 0 {
cursor.x += Self::TWITCH
}
// Small leftmost dot.
if digits > dots_visible + 1 {
shape::ToifImage::new(cursor - Offset::x(2 * step), theme::DOT_SMALL.toif)
.with_align(Alignment2D::TOP_LEFT)
.with_fg(self.style.text_color)
.render(target);
}
// Greyed out dot.
if digits > dots_visible {
shape::ToifImage::new(cursor - Offset::x(step), theme::DOT_ACTIVE.toif)
.with_align(Alignment2D::TOP_LEFT)
.with_fg(theme::GREY_LIGHT)
.render(target);
}
// Draw a dot for each PIN digit.
for _ in 0..dots_visible {
shape::ToifImage::new(cursor, theme::DOT_ACTIVE.toif)
.with_align(Alignment2D::TOP_LEFT)
.with_fg(self.style.text_color)
.render(target);
cursor.x += step;
}
}
}
impl Component for PinDots {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.pad.place(bounds);
self.area = bounds;
self.area
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
match event {
Event::Touch(TouchEvent::TouchStart(pos)) => {
if self.area.contains(pos) {
self.display_digits = true;
self.pad.clear();
ctx.request_paint();
};
None
}
Event::Touch(TouchEvent::TouchEnd(_)) => {
if mem::replace(&mut self.display_digits, false) {
self.pad.clear();
ctx.request_paint();
};
None
}
_ => None,
}
}
fn paint(&mut self) {
let dot_area = self.area.inset(HEADER_PADDING);
self.pad.paint();
if self.display_digits {
self.paint_digits(dot_area)
} else {
self.paint_dots(dot_area)
}
}
fn render(&mut self, target: &mut impl Renderer) {
let dot_area = self.area.inset(HEADER_PADDING);
self.pad.render(target);
if self.display_digits {
self.render_digits(dot_area, target)
} else {
self.render_dots(dot_area, target)
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area);
sink(self.area.inset(HEADER_PADDING));
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for PinKeyboard<T>
where
T: AsRef<str>,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("PinKeyboard");
// So that debuglink knows the locations of the buttons
let mut digits_order: String<10> = String::new();
for btn in self.digit_btns.iter() {
let btn_content = btn.inner().content();
if let ButtonContent::Text(text) = btn_content {
unwrap!(digits_order.push_str(text));
}
}
t.string("digits_order", digits_order.as_str().into());
t.string("pin", self.textbox.inner().pin().into());
t.bool("display_digits", self.textbox.inner().display_digits);
}
}

@ -0,0 +1,399 @@
use core::iter;
use heapless::String;
use crate::{
trezorhal::slip39,
ui::{
component::{
text::common::{TextBox, TextEdit},
Component, Event, EventCtx,
},
display,
geometry::{Alignment2D, Offset, Rect},
model_mercury::{
component::{
keyboard::{
common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard},
mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT},
},
Button, ButtonContent, ButtonMsg,
},
theme,
},
shape,
shape::Renderer,
util::ResultExt,
},
};
const MAX_LENGTH: usize = 8;
pub struct Slip39Input {
button: Button<&'static str>,
textbox: TextBox<MAX_LENGTH>,
multi_tap: MultiTapKeyboard,
final_word: Option<&'static str>,
input_mask: Slip39Mask,
}
impl MnemonicInput for Slip39Input {
/// Return the key set. Keys are further specified as indices into this
/// array.
fn keys() -> [&'static str; MNEMONIC_KEY_COUNT] {
["ab", "cd", "ef", "ghij", "klm", "nopq", "rs", "tuv", "wxyz"]
}
/// Returns `true` if given key index can continue towards a valid mnemonic
/// word, `false` otherwise.
fn can_key_press_lead_to_a_valid_word(&self, key: usize) -> bool {
if self.input_mask.is_final() {
false
} else {
// Currently pending key is always enabled.
// Keys that mach the completion mask are enabled as well.
self.multi_tap.pending_key() == Some(key) || self.input_mask.contains_key(key)
}
}
/// Key button was clicked. If this button is pending, let's cycle the
/// pending character in textbox. If not, let's just append the first
/// character.
fn on_key_click(&mut self, ctx: &mut EventCtx, key: usize) {
let edit = self.multi_tap.click_key(ctx, key, Self::keys()[key]);
if let TextEdit::Append(_) = edit {
// This key press wasn't just a pending key rotation, so let's push the key
// digit to the buffer.
self.textbox.append(ctx, Self::key_digit(key));
} else {
// Ignore the pending char rotation. We use the pending key to paint
// the last character, but the mnemonic word computation depends
// only on the pressed key, not on the specific character inside it.
// Request paint of pending char.
ctx.request_paint();
}
self.complete_word_from_dictionary(ctx);
}
/// Backspace button was clicked, let's delete the last character of input
/// and clear the pending marker.
fn on_backspace_click(&mut self, ctx: &mut EventCtx) {
self.multi_tap.clear_pending_state(ctx);
self.textbox.delete_last(ctx);
self.complete_word_from_dictionary(ctx);
}
/// Backspace button was long pressed, let's delete all characters of input
/// and clear the pending marker.
fn on_backspace_long_press(&mut self, ctx: &mut EventCtx) {
self.multi_tap.clear_pending_state(ctx);
self.textbox.clear(ctx);
self.complete_word_from_dictionary(ctx);
}
fn is_empty(&self) -> bool {
self.textbox.is_empty()
}
fn mnemonic(&self) -> Option<&'static str> {
self.final_word
}
}
impl Component for Slip39Input {
type Msg = MnemonicInputMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.button.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if self.multi_tap.is_timeout_event(event) {
// Timeout occurred. Reset the pending key.
self.multi_tap.clear_pending_state(ctx);
return Some(MnemonicInputMsg::TimedOut);
}
if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) {
// Input button was clicked. If the whole word is totally identified, let's
// confirm it, otherwise don't do anything.
if self.input_mask.is_final() {
return Some(MnemonicInputMsg::Confirmed);
}
}
None
}
fn paint(&mut self) {
let area = self.button.area();
let style = self.button.style();
// First, paint the button background.
self.button.paint_background(style);
// Content starts in the left-center point, offset by 16px to the right and 8px
// to the bottom.
let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8);
// To simplify things, we always copy the printed string here, even if it
// wouldn't be strictly necessary.
let mut text: String<MAX_LENGTH> = String::new();
if let Some(word) = self.final_word {
// We're done with input, paint the full word.
text.push_str(word)
.assert_if_debugging_ui("Text buffer is too small");
} else {
// Paint an asterisk for each letter of input.
for ch in iter::repeat('*').take(self.textbox.content().len()) {
text.push(ch)
.assert_if_debugging_ui("Text buffer is too small");
}
// If we're in the pending state, paint the pending character at the end.
if let (Some(key), Some(press)) =
(self.multi_tap.pending_key(), self.multi_tap.pending_press())
{
assert!(!Self::keys()[key].is_empty());
// Now we can be sure that the looped iterator will return a value.
let ch = unwrap!(Self::keys()[key].chars().cycle().nth(press));
text.pop();
text.push(ch)
.assert_if_debugging_ui("Text buffer is too small");
}
}
display::text_left(
text_baseline,
text.as_str(),
style.font,
style.text_color,
style.button_color,
);
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() && self.final_word.is_none() {
paint_pending_marker(text_baseline, text.as_str(), style.font, style.text_color);
}
// Paint the icon.
if let ButtonContent::Icon(icon) = self.button.content() {
// Icon is painted in the right-center point, of expected size 16x16 pixels, and
// 16px from the right edge.
let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0);
icon.draw(
icon_center,
Alignment2D::CENTER,
style.text_color,
style.button_color,
);
}
}
fn render(&mut self, target: &mut impl Renderer) {
let area = self.button.area();
let style = self.button.style();
// First, paint the button background.
self.button.render_background(target, style);
// Content starts in the left-center point, offset by 16px to the right and 8px
// to the bottom.
let text_baseline = area.top_left().center(area.bottom_left()) + Offset::new(16, 8);
// To simplify things, we always copy the printed string here, even if it
// wouldn't be strictly necessary.
let mut text: String<MAX_LENGTH> = String::new();
if let Some(word) = self.final_word {
// We're done with input, paint the full word.
text.push_str(word)
.assert_if_debugging_ui("Text buffer is too small");
} else {
// Paint an asterisk for each letter of input.
for ch in iter::repeat('*').take(self.textbox.content().len()) {
text.push(ch)
.assert_if_debugging_ui("Text buffer is too small");
}
// If we're in the pending state, paint the pending character at the end.
if let (Some(key), Some(press)) =
(self.multi_tap.pending_key(), self.multi_tap.pending_press())
{
assert!(!Self::keys()[key].is_empty());
// Now we can be sure that the looped iterator will return a value.
let ch = unwrap!(Self::keys()[key].chars().cycle().nth(press));
text.pop();
text.push(ch)
.assert_if_debugging_ui("Text buffer is too small");
}
}
shape::Text::new(text_baseline, text.as_str())
.with_font(style.font)
.with_fg(style.text_color)
.render(target);
// Paint the pending marker.
if self.multi_tap.pending_key().is_some() && self.final_word.is_none() {
render_pending_marker(
target,
text_baseline,
text.as_str(),
style.font,
style.text_color,
);
}
// Paint the icon.
if let ButtonContent::Icon(icon) = self.button.content() {
// Icon is painted in the right-center point, of expected size 16x16 pixels, and
// 16px from the right edge.
let icon_center = area.top_right().center(area.bottom_right()) - Offset::new(16 + 8, 0);
shape::ToifImage::new(icon_center, icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.text_color)
.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.button.bounds(sink);
}
}
impl Slip39Input {
pub fn new() -> Self {
Self {
// Button has the same style the whole time
button: Button::empty().styled(theme::button_pin_confirm()),
textbox: TextBox::empty(),
multi_tap: MultiTapKeyboard::new(),
final_word: None,
input_mask: Slip39Mask::full(),
}
}
pub fn prefilled_word(word: &str) -> Self {
// Word may be empty string, fallback to normal input
if word.is_empty() {
return Self::new();
}
let (buff, input_mask, final_word) = Self::setup_from_prefilled_word(word);
Self {
// Button has the same style the whole time
button: Button::empty().styled(theme::button_pin_confirm()),
textbox: TextBox::new(buff),
multi_tap: MultiTapKeyboard::new(),
final_word,
input_mask,
}
}
fn setup_from_prefilled_word(
word: &str,
) -> (String<MAX_LENGTH>, Slip39Mask, Option<&'static str>) {
let mut buff: String<MAX_LENGTH> = String::new();
// Gradually appending encoded key digits to the buffer and checking if
// have not already formed a final word.
for ch in word.chars() {
let mut index = 0;
for (i, key) in Self::keys().iter().enumerate() {
if key.contains(ch) {
index = i;
break;
}
}
buff.push(Self::key_digit(index))
.assert_if_debugging_ui("Text buffer is too small");
let sequence: Option<u16> = buff.parse().ok();
let input_mask = sequence
.and_then(slip39::word_completion_mask)
.map(Slip39Mask)
.unwrap_or_else(Slip39Mask::full);
let final_word = if input_mask.is_final() {
sequence.and_then(slip39::button_sequence_to_word)
} else {
None
};
// As soon as we have a final word, we can stop.
if final_word.is_some() {
return (buff, input_mask, final_word);
}
}
(buff, Slip39Mask::full(), None)
}
/// Convert a key index into the key digit. This is what we push into the
/// input buffer.
///
/// # Examples
///
/// ```
/// Self::key_digit(0) == '1';
/// Self::key_digit(1) == '2';
/// ```
fn key_digit(key: usize) -> char {
let index = key + 1;
unwrap!(char::from_digit(index as u32, 10))
}
fn complete_word_from_dictionary(&mut self, ctx: &mut EventCtx) {
let sequence = self.input_sequence();
self.input_mask = sequence
.and_then(slip39::word_completion_mask)
.map(Slip39Mask)
.unwrap_or_else(Slip39Mask::full);
self.final_word = if self.input_mask.is_final() {
sequence.and_then(slip39::button_sequence_to_word)
} else {
None
};
// Change the style of the button depending on the input.
if self.final_word.is_some() {
// Confirm button.
self.button.enable(ctx);
self.button
.set_content(ctx, ButtonContent::Icon(theme::ICON_LIST_CHECK));
} else {
// Disabled button.
self.button.disable(ctx);
self.button.set_content(ctx, ButtonContent::Text(""));
}
}
fn input_sequence(&self) -> Option<u16> {
self.textbox.content().parse().ok()
}
}
struct Slip39Mask(u16);
impl Slip39Mask {
/// Return a mask with all keys allowed.
fn full() -> Self {
Self(0x1FF) // All buttons are allowed. 9-bit bitmap all set to 1.
}
/// Returns `true` if `key` can lead to a valid SLIP39 word with this mask.
fn contains_key(&self, key: usize) -> bool {
self.0 & (1 << key) != 0
}
/// Returns `true` if mask has exactly one bit set to 1, or is equal to 0.
fn is_final(&self) -> bool {
self.0.count_ones() <= 1
}
}
// DEBUG-ONLY SECTION BELOW
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for Slip39Input {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Slip39Input");
t.child("textbox", &self.textbox);
}
}

@ -0,0 +1,80 @@
use crate::ui::{
component::{Component, Event, EventCtx},
geometry::{Grid, GridCellSpan, Rect},
model_mercury::{
component::button::{Button, ButtonMsg},
theme,
},
shape::Renderer,
};
const NUMBERS: [u32; 5] = [12, 18, 20, 24, 33];
const LABELS: [&str; 5] = ["12", "18", "20", "24", "33"];
const CELLS: [(usize, usize); 5] = [(0, 0), (0, 2), (0, 4), (1, 0), (1, 2)];
pub struct SelectWordCount {
button: [Button<&'static str>; NUMBERS.len()],
}
pub enum SelectWordCountMsg {
Selected(u32),
}
impl SelectWordCount {
pub fn new() -> Self {
SelectWordCount {
button: LABELS.map(|t| Button::with_text(t).styled(theme::button_pin())),
}
}
}
impl Component for SelectWordCount {
type Msg = SelectWordCountMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let (_, bounds) = bounds.split_bottom(2 * theme::BUTTON_HEIGHT + theme::BUTTON_SPACING);
let grid = Grid::new(bounds, 2, 6).with_spacing(theme::BUTTON_SPACING);
for (btn, (x, y)) in self.button.iter_mut().zip(CELLS) {
btn.place(grid.cells(GridCellSpan {
from: (x, y),
to: (x, y + 1),
}));
}
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
for (i, btn) in self.button.iter_mut().enumerate() {
if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) {
return Some(SelectWordCountMsg::Selected(NUMBERS[i]));
}
}
None
}
fn paint(&mut self) {
for btn in self.button.iter_mut() {
btn.paint()
}
}
fn render(&mut self, target: &mut impl Renderer) {
for btn in self.button.iter_mut() {
btn.render(target)
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
for btn in self.button.iter() {
btn.bounds(sink)
}
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for SelectWordCount {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("SelectWordCount");
}
}

@ -0,0 +1,276 @@
use crate::{
error::Error,
micropython::buffer::StrBuffer,
strutil::{self, StringType},
translations::TR,
ui::{
component::{
base::ComponentExt,
paginated::Paginate,
text::paragraphs::{Paragraph, Paragraphs},
Child, Component, Event, EventCtx, Pad,
},
display::{self, Font},
geometry::{Alignment, Grid, Insets, Offset, Rect},
shape::{self, Renderer},
},
};
use super::{theme, Button, ButtonMsg};
pub enum NumberInputDialogMsg {
Selected,
InfoRequested,
}
pub struct NumberInputDialog<T, F>
where
F: Fn(u32) -> T,
{
area: Rect,
description_func: F,
input: Child<NumberInput>,
paragraphs: Child<Paragraphs<Paragraph<T>>>,
paragraphs_pad: Pad,
info_button: Child<Button<StrBuffer>>,
confirm_button: Child<Button<StrBuffer>>,
}
impl<T, F> NumberInputDialog<T, F>
where
F: Fn(u32) -> T,
T: StringType,
{
pub fn new(min: u32, max: u32, init_value: u32, description_func: F) -> Result<Self, Error> {
let text = description_func(init_value);
Ok(Self {
area: Rect::zero(),
description_func,
input: NumberInput::new(min, max, init_value).into_child(),
paragraphs: Paragraphs::new(Paragraph::new(&theme::TEXT_NORMAL, text)).into_child(),
paragraphs_pad: Pad::with_background(theme::BG),
info_button: Button::with_text(TR::buttons__info.try_into()?).into_child(),
confirm_button: Button::with_text(TR::buttons__continue.try_into()?)
.styled(theme::button_confirm())
.into_child(),
})
}
fn update_text(&mut self, ctx: &mut EventCtx, value: u32) {
let text = (self.description_func)(value);
self.paragraphs.mutate(ctx, move |ctx, para| {
para.inner_mut().update(text);
// Recompute bounding box.
para.change_page(0);
ctx.request_paint()
});
self.paragraphs_pad.clear();
ctx.request_paint();
}
pub fn value(&self) -> u32 {
self.input.inner().value
}
}
impl<T, F> Component for NumberInputDialog<T, F>
where
T: StringType,
F: Fn(u32) -> T,
{
type Msg = NumberInputDialogMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
let button_height = theme::BUTTON_HEIGHT;
let content_area = self.area.inset(Insets::top(2 * theme::BUTTON_SPACING));
let (input_area, content_area) = content_area.split_top(button_height);
let (content_area, button_area) = content_area.split_bottom(button_height);
let content_area = content_area.inset(Insets::new(
theme::BUTTON_SPACING,
0,
theme::BUTTON_SPACING,
theme::CONTENT_BORDER,
));
let grid = Grid::new(button_area, 1, 2).with_spacing(theme::KEYBOARD_SPACING);
self.input.place(input_area);
self.paragraphs.place(content_area);
self.paragraphs_pad.place(content_area);
self.info_button.place(grid.row_col(0, 0));
self.confirm_button.place(grid.row_col(0, 1));
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(NumberInputMsg::Changed(i)) = self.input.event(ctx, event) {
self.update_text(ctx, i);
}
self.paragraphs.event(ctx, event);
if let Some(ButtonMsg::Clicked) = self.info_button.event(ctx, event) {
return Some(Self::Msg::InfoRequested);
}
if let Some(ButtonMsg::Clicked) = self.confirm_button.event(ctx, event) {
return Some(Self::Msg::Selected);
};
None
}
fn paint(&mut self) {
self.input.paint();
self.paragraphs_pad.paint();
self.paragraphs.paint();
self.info_button.paint();
self.confirm_button.paint();
}
fn render(&mut self, target: &mut impl Renderer) {
self.input.render(target);
self.paragraphs_pad.render(target);
self.paragraphs.render(target);
self.info_button.render(target);
self.confirm_button.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area);
self.input.bounds(sink);
self.paragraphs.bounds(sink);
self.info_button.bounds(sink);
self.confirm_button.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T, F> crate::trace::Trace for NumberInputDialog<T, F>
where
T: StringType,
F: Fn(u32) -> T,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("NumberInputDialog");
t.child("input", &self.input);
t.child("paragraphs", &self.paragraphs);
t.child("info_button", &self.info_button);
t.child("confirm_button", &self.confirm_button);
}
}
pub enum NumberInputMsg {
Changed(u32),
}
pub struct NumberInput {
area: Rect,
dec: Child<Button<&'static str>>,
inc: Child<Button<&'static str>>,
min: u32,
max: u32,
value: u32,
}
impl NumberInput {
pub fn new(min: u32, max: u32, value: u32) -> Self {
let dec = Button::with_text("-")
.styled(theme::button_counter())
.into_child();
let inc = Button::with_text("+")
.styled(theme::button_counter())
.into_child();
let value = value.clamp(min, max);
Self {
area: Rect::zero(),
dec,
inc,
min,
max,
value,
}
}
}
impl Component for NumberInput {
type Msg = NumberInputMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let grid = Grid::new(bounds, 1, 3).with_spacing(theme::KEYBOARD_SPACING);
self.dec.place(grid.row_col(0, 0));
self.inc.place(grid.row_col(0, 2));
self.area = grid.row_col(0, 1);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let mut changed = false;
if let Some(ButtonMsg::Clicked) = self.dec.event(ctx, event) {
self.value = self.min.max(self.value.saturating_sub(1));
changed = true;
};
if let Some(ButtonMsg::Clicked) = self.inc.event(ctx, event) {
self.value = self.max.min(self.value.saturating_add(1));
changed = true;
};
if changed {
self.dec
.mutate(ctx, |ctx, btn| btn.enable_if(ctx, self.value > self.min));
self.inc
.mutate(ctx, |ctx, btn| btn.enable_if(ctx, self.value < self.max));
ctx.request_paint();
return Some(NumberInputMsg::Changed(self.value));
}
None
}
fn paint(&mut self) {
let mut buf = [0u8; 10];
if let Some(text) = strutil::format_i64(self.value as i64, &mut buf) {
let digit_font = Font::DEMIBOLD;
let y_offset = digit_font.text_height() / 2 + Button::<&str>::BASELINE_OFFSET;
display::rect_fill(self.area, theme::BG);
display::text_center(
self.area.center() + Offset::y(y_offset),
text,
digit_font,
theme::FG,
theme::BG,
);
}
self.dec.paint();
self.inc.paint();
}
fn render(&mut self, target: &mut impl Renderer) {
let mut buf = [0u8; 10];
if let Some(text) = strutil::format_i64(self.value as i64, &mut buf) {
let digit_font = Font::DEMIBOLD;
let y_offset = digit_font.text_height() / 2 + Button::<&str>::BASELINE_OFFSET;
shape::Bar::new(self.area).with_bg(theme::BG).render(target);
shape::Text::new(self.area.center() + Offset::y(y_offset), text)
.with_align(Alignment::Center)
.with_fg(theme::FG)
.with_font(digit_font)
.render(target);
}
self.dec.render(target);
self.inc.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.dec.bounds(sink);
self.inc.bounds(sink);
sink(self.area)
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for NumberInput {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("NumberInput");
t.int("value", self.value as i64);
}
}

@ -0,0 +1,851 @@
use crate::{
error::Error,
micropython::buffer::StrBuffer,
time::Instant,
translations::TR,
ui::{
component::{paginated::PageMsg, Component, ComponentExt, Event, EventCtx, Pad, Paginate},
constant,
display::{self, Color},
geometry::{Insets, Rect},
shape::Renderer,
util::animation_disabled,
},
};
use super::{
theme, Button, ButtonContent, ButtonMsg, ButtonStyleSheet, Loader, LoaderMsg, ScrollBar, Swipe,
SwipeDirection,
};
/// Allows pagination of inner component. Shows scroll bar, confirm & cancel
/// buttons. Optionally handles hold-to-confirm with loader.
pub struct ButtonPage<T, U> {
/// Inner component.
content: T,
/// Cleared when page changes.
pad: Pad,
/// Swipe controller.
swipe: Swipe,
scrollbar: ScrollBar,
/// Hold-to-confirm mode whenever this is `Some(loader)`.
loader: Option<Loader>,
button_cancel: Option<Button<U>>,
button_confirm: Button<U>,
button_prev: Button<&'static str>,
button_next: Button<&'static str>,
/// Show cancel button instead of back button.
cancel_from_any_page: bool,
/// Whether to pass-through left swipe to parent component.
swipe_left: bool,
/// Whether to pass-through right swipe to parent component.
swipe_right: bool,
/// Fade to given backlight level on next paint().
fade: Option<u16>,
}
impl<T> ButtonPage<T, StrBuffer>
where
T: Paginate,
T: Component,
{
pub fn with_hold(mut self) -> Result<Self, Error> {
self.button_confirm = Button::with_text(TR::buttons__hold_to_confirm.try_into()?)
.styled(theme::button_confirm());
self.loader = Some(Loader::new());
Ok(self)
}
}
impl<T, U> ButtonPage<T, U>
where
T: Paginate,
T: Component,
U: AsRef<str> + From<&'static str>,
{
pub fn new(content: T, background: Color) -> Self {
Self {
content,
pad: Pad::with_background(background),
swipe: Swipe::new(),
scrollbar: ScrollBar::vertical(),
loader: None,
button_cancel: Some(Button::with_icon(theme::ICON_CANCEL)),
button_confirm: Button::with_icon(theme::ICON_CONFIRM).styled(theme::button_confirm()),
button_prev: Button::with_icon(theme::ICON_UP).initially_enabled(false),
button_next: Button::with_icon(theme::ICON_DOWN),
cancel_from_any_page: false,
swipe_left: false,
swipe_right: false,
fade: None,
}
}
pub fn without_cancel(mut self) -> Self {
self.button_cancel = None;
self
}
pub fn with_cancel_confirm(mut self, left: Option<U>, right: Option<U>) -> Self {
let cancel = match left {
Some(verb) => match verb.as_ref() {
"^" => Button::with_icon(theme::ICON_UP),
"<" => Button::with_icon(theme::ICON_BACK),
_ => Button::with_text(verb),
},
_ => Button::with_icon(theme::ICON_CANCEL),
};
let confirm = match right {
Some(verb) => Button::with_text(verb).styled(theme::button_confirm()),
_ => Button::with_icon(theme::ICON_CONFIRM).styled(theme::button_confirm()),
};
self.button_cancel = Some(cancel);
self.button_confirm = confirm;
self
}
pub fn with_back_button(mut self) -> Self {
self.cancel_from_any_page = true;
self.button_prev = Button::with_icon(theme::ICON_BACK).initially_enabled(false);
self.button_cancel = Some(Button::with_icon(theme::ICON_BACK));
self
}
pub fn with_cancel_arrow(mut self) -> Self {
self.button_cancel = Some(Button::with_icon(theme::ICON_UP));
self
}
pub fn with_confirm_style(mut self, style: ButtonStyleSheet) -> Self {
self.button_confirm = self.button_confirm.styled(style);
self
}
pub fn with_swipe_left(mut self) -> Self {
self.swipe_left = true;
self
}
pub fn with_swipe_right(mut self) -> Self {
self.swipe_right = true;
self
}
fn setup_swipe(&mut self) {
self.swipe.allow_up = self.scrollbar.has_next_page();
self.swipe.allow_down = self.scrollbar.has_previous_page();
self.swipe.allow_left = self.swipe_left;
self.swipe.allow_right = self.swipe_right;
}
fn change_page(&mut self, ctx: &mut EventCtx, step: isize) {
// Advance scrollbar.
self.scrollbar.go_to_relative(step);
// Adjust the swipe parameters according to the scrollbar.
self.setup_swipe();
// Enable/disable prev button.
self.button_prev
.enable_if(ctx, self.scrollbar.has_previous_page());
// Change the page in the content, make sure it gets completely repainted and
// clear the background under it.
self.content.change_page(self.scrollbar.active_page);
self.content.request_complete_repaint(ctx);
self.pad.clear();
// Swipe has dimmed the screen, so fade back to normal backlight after the next
// paint.
self.fade = Some(theme::BACKLIGHT_NORMAL);
}
fn is_cancel_visible(&self) -> bool {
self.cancel_from_any_page || !self.scrollbar.has_previous_page()
}
/// Area for drawing loader (and black rectangle behind it). Can be outside
/// bounds as we repaint entire UI tree after hiding the loader.
const fn loader_area() -> Rect {
constant::screen()
.inset(theme::borders())
.inset(Insets::bottom(theme::BUTTON_HEIGHT + theme::BUTTON_SPACING))
}
fn handle_swipe(
&mut self,
ctx: &mut EventCtx,
event: Event,
) -> HandleResult<<Self as Component>::Msg> {
if let Some(swipe) = self.swipe.event(ctx, event) {
match swipe {
SwipeDirection::Up => {
// Scroll down, if possible.
return HandleResult::NextPage;
}
SwipeDirection::Down => {
// Scroll up, if possible.
return HandleResult::PrevPage;
}
SwipeDirection::Left if self.swipe_left => {
return HandleResult::Return(PageMsg::SwipeLeft);
}
SwipeDirection::Right if self.swipe_right => {
return HandleResult::Return(PageMsg::SwipeRight);
}
_ => {
// Ignore other directions.
}
}
}
HandleResult::Continue
}
fn handle_button(
&mut self,
ctx: &mut EventCtx,
event: Event,
) -> HandleResult<(Option<<Self as Component>::Msg>, Option<ButtonMsg>)> {
if self.scrollbar.has_next_page() {
if let Some(ButtonMsg::Clicked) = self.button_next.event(ctx, event) {
return HandleResult::NextPage;
}
} else {
let result = self.button_confirm.event(ctx, event);
match result {
Some(ButtonMsg::Clicked) => {
return HandleResult::Return((Some(PageMsg::Confirmed), result))
}
Some(_) => return HandleResult::Return((None, result)),
None => {}
}
}
if self.is_cancel_visible() {
if let Some(ButtonMsg::Clicked) = self.button_cancel.event(ctx, event) {
return HandleResult::Return((Some(PageMsg::Cancelled), None));
}
} else if let Some(ButtonMsg::Clicked) = self.button_prev.event(ctx, event) {
return HandleResult::PrevPage;
}
HandleResult::Continue
}
fn handle_hold(
&mut self,
ctx: &mut EventCtx,
event: Event,
button_msg: &Option<ButtonMsg>,
) -> HandleResult<<Self as Component>::Msg> {
let Some(loader) = &mut self.loader else {
return HandleResult::Continue;
};
let now = Instant::now();
if let Some(LoaderMsg::ShrunkCompletely) = loader.event(ctx, event) {
// Switch it to the initial state, so we stop painting it.
loader.reset();
// Re-draw the whole content tree.
self.content.request_complete_repaint(ctx);
// Loader overpainted our bounds, repaint entire screen from scratch.
ctx.request_repaint_root()
// This can be a result of an animation frame event, we should take
// care to not short-circuit here and deliver the event to the
// content as well.
}
match button_msg {
Some(ButtonMsg::Pressed) => {
loader.start_growing(ctx, now);
loader.pad.clear(); // Clear the remnants of the content.
}
Some(ButtonMsg::Released) => {
loader.start_shrinking(ctx, now);
}
Some(ButtonMsg::Clicked) => {
if loader.is_completely_grown(now) || animation_disabled() {
return HandleResult::Return(PageMsg::Confirmed);
} else {
loader.start_shrinking(ctx, now);
}
}
_ => {}
}
HandleResult::Continue
}
}
enum HandleResult<T> {
Return(T),
PrevPage,
NextPage,
Continue,
}
impl<T, U> Component for ButtonPage<T, U>
where
T: Paginate,
T: Component,
U: AsRef<str> + From<&'static str>,
{
type Msg = PageMsg<T::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
let small_left_button = match (&self.button_cancel, &self.button_confirm) {
(None, _) => true,
(Some(cancel), confirm) => match (cancel.content(), confirm.content()) {
(ButtonContent::Text(t), _) => t.as_ref().len() <= 4,
(ButtonContent::Icon(_), ButtonContent::Icon(_)) => false,
_ => true,
},
};
let layout = PageLayout::new(bounds, small_left_button);
self.pad.place(bounds);
self.swipe.place(bounds);
self.button_cancel.place(layout.button_left);
self.button_confirm.place(layout.button_right);
self.button_prev.place(layout.button_left);
self.button_next.place(layout.button_right);
self.scrollbar.place(layout.scrollbar);
// Layout the content. Try to fit it on a single page first, and reduce the area
// to make space for a scrollbar if it doesn't fit.
self.content.place(layout.content_single_page);
let page_count = {
let count = self.content.page_count();
if count > 1 {
self.content.place(layout.content);
self.content.page_count() // Make sure to re-count it with the
// new size.
} else {
count // Content fits on a single page.
}
};
if page_count == 1 && self.button_cancel.is_none() {
self.button_confirm.place(layout.button_both);
}
// Now that we finally have the page count, we can setup the scrollbar and the
// swiper.
self.scrollbar.set_count_and_active_page(page_count, 0);
self.setup_swipe();
self.loader.place(Self::loader_area());
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
ctx.set_page_count(self.scrollbar.page_count);
match self.handle_swipe(ctx, event) {
HandleResult::Return(r) => return Some(r),
HandleResult::PrevPage => {
self.change_page(ctx, -1);
return None;
}
HandleResult::NextPage => {
self.change_page(ctx, 1);
return None;
}
HandleResult::Continue => {}
}
if let Some(msg) = self.content.event(ctx, event) {
return Some(PageMsg::Content(msg));
}
let mut confirm_button_msg = None;
let mut button_result = None;
match self.handle_button(ctx, event) {
HandleResult::Return((Some(r), None)) => return Some(r),
HandleResult::Return((r, m)) => {
button_result = r;
confirm_button_msg = m;
}
HandleResult::PrevPage => {
self.change_page(ctx, -1);
return None;
}
HandleResult::NextPage => {
self.change_page(ctx, 1);
return None;
}
HandleResult::Continue => {}
}
if self.loader.is_some() {
return match self.handle_hold(ctx, event, &confirm_button_msg) {
HandleResult::Return(r) => Some(r),
HandleResult::Continue => None,
_ => unreachable!(),
};
}
button_result
}
fn paint(&mut self) {
self.pad.paint();
match &self.loader {
Some(l) if l.is_animating() => self.loader.paint(),
_ => {
self.content.paint();
if self.scrollbar.has_pages() {
self.scrollbar.paint();
}
}
}
if self.button_cancel.is_some() && self.is_cancel_visible() {
self.button_cancel.paint();
} else {
self.button_prev.paint();
}
if self.scrollbar.has_next_page() {
self.button_next.paint();
} else {
self.button_confirm.paint();
}
if let Some(val) = self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
fn render(&mut self, target: &mut impl Renderer) {
self.pad.render(target);
match &self.loader {
Some(l) if l.is_animating() => self.loader.render(target),
_ => {
self.content.render(target);
if self.scrollbar.has_pages() {
self.scrollbar.render(target);
}
}
}
if self.button_cancel.is_some() && self.is_cancel_visible() {
self.button_cancel.render(target);
} else {
self.button_prev.render(target);
}
if self.scrollbar.has_next_page() {
self.button_next.render(target);
} else {
self.button_confirm.render(target);
}
if let Some(val) = self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.pad.area);
self.scrollbar.bounds(sink);
self.content.bounds(sink);
self.button_cancel.bounds(sink);
self.button_confirm.bounds(sink);
self.button_prev.bounds(sink);
self.button_next.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T, U> crate::trace::Trace for ButtonPage<T, U>
where
T: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("ButtonPage");
t.int("active_page", self.scrollbar.active_page as i64);
t.int("page_count", self.scrollbar.page_count as i64);
t.bool("hold", self.loader.is_some());
t.child("content", &self.content);
}
}
pub struct PageLayout {
/// Content when it fits on single page (no scrollbar).
pub content_single_page: Rect,
/// Content when multiple pages.
pub content: Rect,
/// Scroll bar when multiple pages.
pub scrollbar: Rect,
/// Controls displayed on last page.
pub button_left: Rect,
pub button_right: Rect,
pub button_both: Rect,
}
impl PageLayout {
const SCROLLBAR_WIDTH: i16 = 8;
const SCROLLBAR_SPACE: i16 = 5;
pub fn new(area: Rect, small_left_button: bool) -> Self {
let (area, button_both) = area.split_bottom(theme::BUTTON_HEIGHT);
let area = area.inset(Insets::bottom(theme::BUTTON_SPACING));
let (_space, content) = area.split_left(theme::CONTENT_BORDER);
let (content_single_page, _space) = content.split_right(theme::CONTENT_BORDER);
let (content, scrollbar) =
content.split_right(Self::SCROLLBAR_SPACE + Self::SCROLLBAR_WIDTH);
let (_space, scrollbar) = scrollbar.split_left(Self::SCROLLBAR_SPACE);
let width = if small_left_button {
theme::BUTTON_WIDTH
} else {
(button_both.width() - theme::BUTTON_SPACING) / 2
};
let (button_left, button_right) = button_both.split_left(width);
let button_right = button_right.inset(Insets::left(theme::BUTTON_SPACING));
Self {
content_single_page,
content,
scrollbar,
button_left,
button_right,
button_both,
}
}
}
#[cfg(test)]
mod tests {
use serde_json;
use crate::{
strutil::SkipPrefix,
trace::tests::trace,
ui::{
component::text::paragraphs::{Paragraph, Paragraphs},
event::TouchEvent,
geometry::Point,
model_mercury::{constant, theme},
},
};
use super::*;
const SCREEN: Rect = constant::screen().inset(theme::borders());
impl SkipPrefix for &str {
fn skip_prefix(&self, chars: usize) -> Self {
&self[chars..]
}
}
fn swipe(component: &mut impl Component, points: &[(i16, i16)]) {
let last = points.len().saturating_sub(1);
let mut first = true;
let mut ctx = EventCtx::new();
for (i, &(x, y)) in points.iter().enumerate() {
let p = Point::new(x, y);
let ev = if first {
TouchEvent::TouchStart(p)
} else if i == last {
TouchEvent::TouchEnd(p)
} else {
TouchEvent::TouchMove(p)
};
component.event(&mut ctx, Event::Touch(ev));
ctx.clear();
first = false;
}
}
fn swipe_up(component: &mut impl Component) {
swipe(component, &[(20, 100), (20, 60), (20, 20)])
}
fn swipe_down(component: &mut impl Component) {
swipe(component, &[(20, 20), (20, 60), (20, 100)])
}
#[test]
fn paragraphs_empty() {
let mut page = ButtonPage::<_, &'static str>::new(
Paragraphs::<[Paragraph<&'static str>; 0]>::new([]),
theme::BG,
);
page.place(SCREEN);
let expected = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 1,
"content": {
"component": "Paragraphs",
"paragraphs": [],
},
"hold": false,
});
assert_eq!(trace(&page), expected);
swipe_up(&mut page);
assert_eq!(trace(&page), expected);
swipe_down(&mut page);
assert_eq!(trace(&page), expected);
}
#[test]
fn paragraphs_single() {
let mut page = ButtonPage::<_, &'static str>::new(
Paragraphs::new([
Paragraph::new(
&theme::TEXT_NORMAL,
"This is the first paragraph and it should fit on the screen entirely.",
),
Paragraph::new(
&theme::TEXT_BOLD,
"Second, bold, paragraph should also fit on the screen whole I think.",
),
]),
theme::BG,
);
page.place(SCREEN);
let expected = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 1,
"content": {
"component": "Paragraphs",
"paragraphs": [
["This is the first", "\n", "paragraph and it should", "\n", "fit on the screen", "\n", "entirely."],
["Second, bold, paragraph", "\n", "should also fit on the", "\n", "screen whole I think."],
],
},
"hold": false,
});
assert_eq!(trace(&page), expected);
swipe_up(&mut page);
assert_eq!(trace(&page), expected);
swipe_down(&mut page);
assert_eq!(trace(&page), expected);
}
#[test]
fn paragraphs_one_long() {
let mut page = ButtonPage::<_, &'static str>::new(
Paragraphs::new(
Paragraph::new(
&theme::TEXT_BOLD,
"This is somewhat long paragraph that goes on and on and on and on and on and will definitely not fit on just a single screen. You have to swipe a bit to see all the text it contains I guess. There's just so much letters in it.",
)
),
theme::BG,
);
page.place(SCREEN);
let first_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 2,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"This is somewhat long", "\n",
"paragraph that goes on", "\n",
"and on and on and on and", "\n",
"on and will definitely not", "\n",
"fit on just a single", "\n",
"screen. You have to", "\n",
"swipe a bit to see all the", "\n",
"text it contains I guess.", "...",
],
],
},
"hold": false,
});
let second_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 1,
"page_count": 2,
"content": {
"component": "Paragraphs",
"paragraphs": [
["There's just so much", "\n", "letters in it."],
],
},
"hold": false,
});
assert_eq!(trace(&page), first_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
swipe_up(&mut page);
assert_eq!(trace(&page), second_page);
swipe_up(&mut page);
assert_eq!(trace(&page), second_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
}
#[test]
fn paragraphs_three_long() {
let mut page = ButtonPage::<_, &'static str>::new(
Paragraphs::new([
Paragraph::new(
&theme::TEXT_BOLD,
"This paragraph is using a bold font. It doesn't need to be all that long.",
),
Paragraph::new(
&theme::TEXT_MONO,
"And this one is using MONO. Monospace is nice for numbers, they have the same width and can be scanned quickly. Even if they span several pages or something.",
),
Paragraph::new(
&theme::TEXT_BOLD,
"Let's add another one for a good measure. This one should overflow all the way to the third page with a bit of luck.",
),
]),
theme::BG,
);
page.place(SCREEN);
let first_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"This paragraph is using a", "\n",
"bold font. It doesn't need", "\n",
"to be all that long.",
],
[
"And this one is u", "\n",
"sing MONO. Monosp", "\n",
"ace is nice for n", "\n",
"umbers, they", "...",
],
],
},
"hold": false,
});
let second_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 1,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"...", "have the same", "\n",
"width and can be", "\n",
"scanned quickly.", "\n",
"Even if they span", "\n",
"several pages or", "\n",
"something.",
],
[
"Let's add another one", "...",
],
],
},
"hold": false,
});
let third_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 2,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"for a good measure. This", "\n",
"one should overflow all", "\n",
"the way to the third page", "\n",
"with a bit of luck.",
],
],
},
"hold": false,
});
assert_eq!(trace(&page), first_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
swipe_up(&mut page);
assert_eq!(trace(&page), second_page);
swipe_up(&mut page);
assert_eq!(trace(&page), third_page);
swipe_up(&mut page);
assert_eq!(trace(&page), third_page);
swipe_down(&mut page);
assert_eq!(trace(&page), second_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
swipe_down(&mut page);
assert_eq!(trace(&page), first_page);
}
#[test]
fn paragraphs_hard_break() {
let mut page = ButtonPage::<_, &'static str>::new(
Paragraphs::new([
Paragraph::new(&theme::TEXT_NORMAL, "Short one.").break_after(),
Paragraph::new(&theme::TEXT_NORMAL, "Short two.").break_after(),
Paragraph::new(&theme::TEXT_NORMAL, "Short three.").break_after(),
]),
theme::BG,
);
page.place(SCREEN);
let first_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 0,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"Short one.",
],
],
},
"hold": false,
});
let second_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 1,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"Short two.",
],
],
},
"hold": false,
});
let third_page = serde_json::json!({
"component": "ButtonPage",
"active_page": 2,
"page_count": 3,
"content": {
"component": "Paragraphs",
"paragraphs": [
[
"Short three.",
],
],
},
"hold": false,
});
assert_eq!(trace(&page), first_page);
swipe_up(&mut page);
assert_eq!(trace(&page), second_page);
swipe_up(&mut page);
assert_eq!(trace(&page), third_page);
swipe_up(&mut page);
assert_eq!(trace(&page), third_page);
}
}

@ -0,0 +1,183 @@
use core::mem;
use crate::{
error::Error,
strutil::StringType,
ui::{
canvas::algo::PI4,
component::{
base::ComponentExt,
paginated::Paginate,
text::paragraphs::{Paragraph, Paragraphs},
Child, Component, Event, EventCtx, Label, Never, Pad,
},
display::{self, Font},
geometry::{Insets, Offset, Rect},
model_mercury::constant,
shape,
shape::Renderer,
util::animation_disabled,
},
};
use super::theme;
pub struct Progress<T> {
title: Child<Label<T>>,
value: u16,
loader_y_offset: i16,
indeterminate: bool,
description: Child<Paragraphs<Paragraph<T>>>,
description_pad: Pad,
update_description: fn(&str) -> Result<T, Error>,
}
impl<T> Progress<T>
where
T: StringType,
{
const AREA: Rect = constant::screen().inset(theme::borders());
pub fn new(
title: T,
indeterminate: bool,
description: T,
update_description: fn(&str) -> Result<T, Error>,
) -> Self {
Self {
title: Label::centered(title, theme::label_progress()).into_child(),
value: 0,
loader_y_offset: 0,
indeterminate,
description: Paragraphs::new(
Paragraph::new(&theme::TEXT_NORMAL, description).centered(),
)
.into_child(),
description_pad: Pad::with_background(theme::BG),
update_description,
}
}
}
impl<T> Component for Progress<T>
where
T: StringType,
{
type Msg = Never;
fn place(&mut self, _bounds: Rect) -> Rect {
let description_lines = 1 + self
.description
.inner()
.inner()
.content()
.as_ref()
.chars()
.filter(|c| *c == '\n')
.count() as i16;
let (title, rest) = Self::AREA.split_top(self.title.inner().max_size().y);
let (loader, description) =
rest.split_bottom(Font::NORMAL.line_height() * description_lines);
let loader = loader.inset(Insets::top(theme::CONTENT_BORDER));
self.title.place(title);
self.loader_y_offset = loader.center().y - constant::screen().center().y;
self.description.place(description);
self.description_pad.place(description);
Self::AREA
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Event::Progress(new_value, new_description) = event {
if mem::replace(&mut self.value, new_value) != new_value {
if !animation_disabled() {
ctx.request_paint();
}
self.description.mutate(ctx, |ctx, para| {
if para.inner_mut().content().as_ref() != new_description {
let new_description = unwrap!((self.update_description)(new_description));
para.inner_mut().update(new_description);
para.change_page(0); // Recompute bounding box.
ctx.request_paint();
self.description_pad.clear();
}
});
}
}
None
}
fn paint(&mut self) {
self.title.paint();
if self.indeterminate {
display::loader_indeterminate(
self.value,
self.loader_y_offset,
theme::FG,
theme::BG,
None,
);
} else {
display::loader(self.value, self.loader_y_offset, theme::FG, theme::BG, None);
}
self.description_pad.paint();
self.description.paint();
}
fn render(&mut self, target: &mut impl Renderer) {
self.title.render(target);
let center = constant::screen().center() + Offset::y(self.loader_y_offset);
let active_color = theme::FG;
let background_color = theme::BG;
let inactive_color = background_color.blend(active_color, 85);
let (start, end) = if self.indeterminate {
let start = (self.value - 100) % 1000;
let end = (self.value + 100) % 1000;
let start = ((start as i32 * 8 * PI4 as i32) / 1000) as i16;
let end = ((end as i32 * 8 * PI4 as i32) / 1000) as i16;
(start, end)
} else {
let end = ((self.value as i32 * 8 * PI4 as i32) / 1000) as i16;
(0, end)
};
shape::Circle::new(center, constant::LOADER_OUTER)
.with_bg(inactive_color)
.render(target);
shape::Circle::new(center, constant::LOADER_OUTER)
.with_bg(active_color)
.with_start_angle(start)
.with_end_angle(end)
.render(target);
shape::Circle::new(center, constant::LOADER_INNER + 2)
.with_bg(active_color)
.render(target);
shape::Circle::new(center, constant::LOADER_INNER)
.with_bg(background_color)
.render(target);
self.description_pad.render(target);
self.description.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(Self::AREA);
self.title.bounds(sink);
self.description.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for Progress<T>
where
T: StringType,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("Progress");
}
}

@ -0,0 +1,179 @@
use crate::ui::{
component::{Component, Event, EventCtx, Never},
display::toif::Icon,
geometry::{Alignment2D, Axis, LinearPlacement, Offset, Rect},
shape,
shape::Renderer,
};
use super::theme;
pub struct ScrollBar {
area: Rect,
layout: LinearPlacement,
pub page_count: usize,
pub active_page: usize,
}
impl ScrollBar {
pub const DOT_SIZE: i16 = 8;
/// If there's more pages than this value then smaller dots are used at the
/// beginning/end of the scrollbar to denote the fact.
const MAX_DOTS: usize = 7;
/// Center to center.
const DOT_INTERVAL: i16 = 18;
pub fn new(axis: Axis) -> Self {
let layout = LinearPlacement::new(axis);
Self {
area: Rect::zero(),
layout: layout.align_at_center().with_spacing(Self::DOT_INTERVAL),
page_count: 0,
active_page: 0,
}
}
pub fn vertical() -> Self {
Self::new(Axis::Vertical)
}
pub fn horizontal() -> Self {
Self::new(Axis::Horizontal)
}
pub fn set_count_and_active_page(&mut self, page_count: usize, active_page: usize) {
self.page_count = page_count;
self.active_page = active_page;
}
pub fn has_pages(&self) -> bool {
self.page_count > 1
}
pub fn has_next_page(&self) -> bool {
self.active_page < self.page_count - 1
}
pub fn has_previous_page(&self) -> bool {
self.active_page > 0
}
pub fn go_to_next_page(&mut self) {
self.go_to_relative(1)
}
pub fn go_to_previous_page(&mut self) {
self.go_to_relative(-1)
}
pub fn go_to_relative(&mut self, step: isize) {
self.go_to(
(self.active_page as isize + step).clamp(0, self.page_count as isize - 1) as usize,
);
}
pub fn go_to(&mut self, active_page: usize) {
self.active_page = active_page;
}
}
impl Component for ScrollBar {
type Msg = Never;
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
None
}
fn paint(&mut self) {
fn dotsize(distance: usize, nhidden: usize) -> Icon {
match (nhidden.saturating_sub(distance)).min(2 - distance) {
0 => theme::DOT_INACTIVE,
1 => theme::DOT_INACTIVE_HALF,
_ => theme::DOT_INACTIVE_QUARTER,
}
}
// Number of visible dots.
let num_shown = self.page_count.min(Self::MAX_DOTS);
// Page indices corresponding to the first (and last) dot.
let first_shown = self
.active_page
.saturating_sub(Self::MAX_DOTS / 2)
.min(self.page_count.saturating_sub(Self::MAX_DOTS));
let last_shown = first_shown + num_shown - 1;
let mut cursor = self.area.center()
- Offset::on_axis(
self.layout.axis,
Self::DOT_INTERVAL * (num_shown.saturating_sub(1) as i16) / 2,
);
for i in first_shown..(last_shown + 1) {
let icon = if i == self.active_page {
theme::DOT_ACTIVE
} else if i <= first_shown + 1 {
let before_first_shown = first_shown;
dotsize(i - first_shown, before_first_shown)
} else if i >= last_shown - 1 {
let after_last_shown = self.page_count - 1 - last_shown;
dotsize(last_shown - i, after_last_shown)
} else {
theme::DOT_INACTIVE
};
icon.draw(cursor, Alignment2D::CENTER, theme::FG, theme::BG);
cursor = cursor + Offset::on_axis(self.layout.axis, Self::DOT_INTERVAL);
}
}
fn render(&mut self, target: &mut impl Renderer) {
fn dotsize(distance: usize, nhidden: usize) -> Icon {
match (nhidden.saturating_sub(distance)).min(2 - distance) {
0 => theme::DOT_INACTIVE,
1 => theme::DOT_INACTIVE_HALF,
_ => theme::DOT_INACTIVE_QUARTER,
}
}
// Number of visible dots.
let num_shown = self.page_count.min(Self::MAX_DOTS);
// Page indices corresponding to the first (and last) dot.
let first_shown = self
.active_page
.saturating_sub(Self::MAX_DOTS / 2)
.min(self.page_count.saturating_sub(Self::MAX_DOTS));
let last_shown = first_shown + num_shown - 1;
let mut cursor = self.area.center()
- Offset::on_axis(
self.layout.axis,
Self::DOT_INTERVAL * (num_shown.saturating_sub(1) as i16) / 2,
);
for i in first_shown..(last_shown + 1) {
let icon = if i == self.active_page {
theme::DOT_ACTIVE
} else if i <= first_shown + 1 {
let before_first_shown = first_shown;
dotsize(i - first_shown, before_first_shown)
} else if i >= last_shown - 1 {
let after_last_shown = self.page_count - 1 - last_shown;
dotsize(last_shown - i, after_last_shown)
} else {
theme::DOT_INACTIVE
};
shape::ToifImage::new(cursor, icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(theme::FG)
.render(target);
cursor = cursor + Offset::on_axis(self.layout.axis, Self::DOT_INTERVAL);
}
}
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
bounds
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area);
}
}

@ -0,0 +1,199 @@
use crate::ui::{
component::{base::ComponentExt, Component, Event, EventCtx, Pad, PageMsg, Paginate},
display::{self, Color},
geometry::{Axis, Insets, Rect},
shape::Renderer,
};
use super::{theme, ScrollBar, Swipe, SwipeDirection};
const SCROLLBAR_HEIGHT: i16 = 18;
const SCROLLBAR_BORDER: i16 = 4;
pub struct SimplePage<T> {
content: T,
pad: Pad,
swipe: Swipe,
scrollbar: ScrollBar,
axis: Axis,
swipe_right_to_go_back: bool,
fade: Option<u16>,
}
impl<T> SimplePage<T>
where
T: Paginate,
T: Component,
{
pub fn new(content: T, axis: Axis, background: Color) -> Self {
Self {
content,
swipe: Swipe::new(),
pad: Pad::with_background(background),
scrollbar: ScrollBar::new(axis),
axis,
swipe_right_to_go_back: false,
fade: None,
}
}
pub fn horizontal(content: T, background: Color) -> Self {
Self::new(content, Axis::Horizontal, background)
}
pub fn vertical(content: T, background: Color) -> Self {
Self::new(content, Axis::Vertical, background)
}
pub fn with_swipe_right_to_go_back(mut self) -> Self {
self.swipe_right_to_go_back = true;
self
}
pub fn inner(&self) -> &T {
&self.content
}
fn setup_swipe(&mut self) {
if self.is_horizontal() {
self.swipe.allow_left = self.scrollbar.has_next_page();
self.swipe.allow_right =
self.scrollbar.has_previous_page() || self.swipe_right_to_go_back;
} else {
self.swipe.allow_up = self.scrollbar.has_next_page();
self.swipe.allow_down = self.scrollbar.has_previous_page();
self.swipe.allow_right = self.swipe_right_to_go_back;
}
}
fn change_page(&mut self, ctx: &mut EventCtx, step: isize) {
// Advance scrollbar.
self.scrollbar.go_to_relative(step);
// Adjust the swipe parameters according to the scrollbar.
self.setup_swipe();
// Change the page in the content, make sure it gets completely repainted and
// clear the background under it.
self.content.change_page(self.scrollbar.active_page);
self.content.request_complete_repaint(ctx);
self.pad.clear();
// Swipe has dimmed the screen, so fade back to normal backlight after the next
// paint.
self.fade = Some(theme::BACKLIGHT_NORMAL);
}
fn is_horizontal(&self) -> bool {
matches!(self.axis, Axis::Horizontal)
}
}
impl<T> Component for SimplePage<T>
where
T: Paginate,
T: Component,
{
type Msg = PageMsg<T::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
self.swipe.place(bounds);
let (content, scrollbar) = if self.is_horizontal() {
bounds.split_bottom(SCROLLBAR_HEIGHT + SCROLLBAR_BORDER)
} else {
bounds.split_right(SCROLLBAR_HEIGHT + SCROLLBAR_BORDER)
};
self.content.place(bounds);
if self.content.page_count() > 1 {
self.pad.place(content);
self.content.place(content);
} else {
self.pad.place(bounds);
}
if self.is_horizontal() {
self.scrollbar
.place(scrollbar.inset(Insets::bottom(SCROLLBAR_BORDER)));
} else {
self.scrollbar
.place(scrollbar.inset(Insets::right(SCROLLBAR_BORDER)));
}
self.scrollbar
.set_count_and_active_page(self.content.page_count(), 0);
self.setup_swipe();
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
ctx.set_page_count(self.scrollbar.page_count);
if let Some(swipe) = self.swipe.event(ctx, event) {
match (swipe, self.axis) {
(SwipeDirection::Left, Axis::Horizontal) | (SwipeDirection::Up, Axis::Vertical) => {
self.change_page(ctx, 1);
return None;
}
(SwipeDirection::Right, _)
if self.swipe_right_to_go_back && self.scrollbar.active_page == 0 =>
{
return Some(PageMsg::Cancelled);
}
(SwipeDirection::Right, Axis::Horizontal)
| (SwipeDirection::Down, Axis::Vertical) => {
self.change_page(ctx, -1);
return None;
}
_ => {
// Ignore other directions.
}
}
}
self.content.event(ctx, event).map(PageMsg::Content)
}
fn paint(&mut self) {
self.pad.paint();
self.content.paint();
if self.scrollbar.has_pages() {
self.scrollbar.paint();
}
if let Some(val) = self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
fn render(&mut self, target: &mut impl Renderer) {
self.pad.render(target);
self.content.render(target);
if self.scrollbar.has_pages() {
self.scrollbar.render(target);
}
if let Some(val) = self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.pad.area);
self.scrollbar.bounds(sink);
self.content.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for SimplePage<T>
where
T: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("SimplePage");
t.int("active_page", self.scrollbar.active_page as i64);
t.int("page_count", self.scrollbar.page_count as i64);
t.child("content", &self.content);
}
}

@ -0,0 +1,165 @@
use crate::ui::{
component::{Component, Event, EventCtx},
display,
event::TouchEvent,
geometry::{Point, Rect},
shape::Renderer,
};
use super::theme;
pub enum SwipeDirection {
Up,
Down,
Left,
Right,
}
pub struct Swipe {
pub area: Rect,
pub allow_up: bool,
pub allow_down: bool,
pub allow_left: bool,
pub allow_right: bool,
backlight_start: u16,
backlight_end: u16,
origin: Option<Point>,
}
impl Swipe {
const DISTANCE: i32 = 120;
const THRESHOLD: f32 = 0.3;
pub fn new() -> Self {
Self {
area: Rect::zero(),
allow_up: false,
allow_down: false,
allow_left: false,
allow_right: false,
backlight_start: theme::BACKLIGHT_NORMAL,
backlight_end: theme::BACKLIGHT_NONE,
origin: None,
}
}
pub fn vertical() -> Self {
Self::new().up().down()
}
pub fn horizontal() -> Self {
Self::new().left().right()
}
pub fn up(mut self) -> Self {
self.allow_up = true;
self
}
pub fn down(mut self) -> Self {
self.allow_down = true;
self
}
pub fn left(mut self) -> Self {
self.allow_left = true;
self
}
pub fn right(mut self) -> Self {
self.allow_right = true;
self
}
fn is_active(&self) -> bool {
self.allow_up || self.allow_down || self.allow_left || self.allow_right
}
fn ratio(&self, dist: i16) -> f32 {
(dist as f32 / Self::DISTANCE as f32).min(1.0)
}
fn backlight(&self, ratio: f32) {
let start = self.backlight_start as f32;
let end = self.backlight_end as f32;
let value = start + ratio * (end - start);
display::set_backlight(value as u16);
}
}
impl Component for Swipe {
type Msg = SwipeDirection;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
self.area
}
fn event(&mut self, _ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if !self.is_active() {
return None;
}
match (event, self.origin) {
(Event::Touch(TouchEvent::TouchStart(pos)), _) if self.area.contains(pos) => {
// Mark the starting position of this touch.
self.origin.replace(pos);
}
(Event::Touch(TouchEvent::TouchMove(pos)), Some(origin)) => {
// Consider our allowed directions and the touch distance and modify the display
// backlight accordingly.
let ofs = pos - origin;
let abs = ofs.abs();
if abs.x > abs.y && (self.allow_left || self.allow_right) {
// Horizontal direction.
if (ofs.x < 0 && self.allow_left) || (ofs.x > 0 && self.allow_right) {
self.backlight(self.ratio(abs.x));
}
} else if abs.x < abs.y && (self.allow_up || self.allow_down) {
// Vertical direction.
if (ofs.y < 0 && self.allow_up) || (ofs.y > 0 && self.allow_down) {
self.backlight(self.ratio(abs.y));
}
};
}
(Event::Touch(TouchEvent::TouchEnd(pos)), Some(origin)) => {
// Touch interaction is over, reset the position.
self.origin.take();
// Compare the touch distance with our allowed directions and determine if it
// constitutes a valid swipe.
let ofs = pos - origin;
let abs = ofs.abs();
if abs.x > abs.y && (self.allow_left || self.allow_right) {
// Horizontal direction.
if self.ratio(abs.x) >= Self::THRESHOLD {
if ofs.x < 0 && self.allow_left {
return Some(SwipeDirection::Left);
} else if ofs.x > 0 && self.allow_right {
return Some(SwipeDirection::Right);
}
}
} else if abs.x < abs.y && (self.allow_up || self.allow_down) {
// Vertical direction.
if self.ratio(abs.y) >= Self::THRESHOLD {
if ofs.y < 0 && self.allow_up {
return Some(SwipeDirection::Up);
} else if ofs.y > 0 && self.allow_down {
return Some(SwipeDirection::Down);
}
}
};
// Swipe did not happen, reset the backlight.
self.backlight(0.0);
}
_ => {
// Do nothing.
}
}
None
}
fn paint(&mut self) {}
fn render(&mut self, _target: &mut impl Renderer) {}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,77 @@
use crate::ui::{
canvas::{BasicCanvas, Viewport},
display::Color,
geometry::{Offset, Rect},
shape::{DrawingCache, ProgressiveRenderer},
};
use crate::trezorhal::bitmap::{BitmapView, Dma2d};
use static_alloc::Bump;
pub fn render_on_display<F>(clip: Option<Rect>, bg_color: Option<Color>, mut func: F)
where
F: FnMut(&mut ProgressiveRenderer<Bump<[u8; 40 * 1024]>, DisplayModelMercury>),
{
#[link_section = ".no_dma_buffers"]
static mut BUMP_A: Bump<[u8; 40 * 1024]> = Bump::uninit();
#[link_section = ".buf"]
static mut BUMP_B: Bump<[u8; 16 * 1024]> = Bump::uninit();
let bump_a = unsafe { &mut *core::ptr::addr_of_mut!(BUMP_A) };
let bump_b = unsafe { &mut *core::ptr::addr_of_mut!(BUMP_B) };
{
let cache = DrawingCache::new(bump_a, bump_b);
let mut canvas = DisplayModelMercury::acquire().unwrap();
if let Some(clip) = clip {
canvas.set_viewport(Viewport::new(clip));
}
let mut target = ProgressiveRenderer::new(&mut canvas, bg_color, &cache, bump_a, 30);
func(&mut target);
target.render(16);
}
bump_a.reset();
bump_b.reset();
}
pub struct DisplayModelMercury {
size: Offset,
viewport: Viewport,
}
impl DisplayModelMercury {
pub fn acquire() -> Option<Self> {
let size = Offset::new(240, 240); // TODO
let viewport = Viewport::from_size(size);
Some(Self { size, viewport })
}
}
impl BasicCanvas for DisplayModelMercury {
fn viewport(&self) -> Viewport {
self.viewport
}
fn set_viewport(&mut self, viewport: Viewport) {
self.viewport = viewport.absolute_clip(self.bounds());
}
fn size(&self) -> Offset {
self.size
}
fn fill_rect(&mut self, r: Rect, color: Color) {
let r = r.translate(self.viewport.origin);
Dma2d::wnd565_fill(r, self.viewport.clip, color);
}
fn draw_bitmap(&mut self, r: Rect, bitmap: BitmapView) {
let r = r.translate(self.viewport.origin);
Dma2d::wnd565_copy(r, self.viewport.clip, &bitmap);
}
}

@ -4,6 +4,468 @@ CANCELLED: UiResult
INFO: UiResult
# rust/src/ui/model_mercury/layout.rs
def disable_animation(disable: bool) -> None:
"""Disable animations, debug builds only."""
# rust/src/ui/model_mercury/layout.rs
def check_homescreen_format(data: bytes) -> bool:
"""Check homescreen format and dimensions."""
# rust/src/ui/model_mercury/layout.rs
def confirm_action(
*,
title: str,
action: str | None,
description: str | None,
verb: str | None = None,
verb_cancel: str | None = None,
hold: bool = False,
hold_danger: bool = False,
reverse: bool = False,
) -> object:
"""Confirm action."""
# rust/src/ui/model_mercury/layout.rs
def confirm_emphasized(
*,
title: str,
items: Iterable[str | tuple[bool, str]],
verb: str | None = None,
) -> object:
"""Confirm formatted text that has been pre-split in python. For tuples
the first component is a bool indicating whether this part is emphasized."""
# rust/src/ui/model_mercury/layout.rs
def confirm_homescreen(
*,
title: str,
image: bytes,
) -> object:
"""Confirm homescreen."""
# rust/src/ui/model_mercury/layout.rs
def confirm_blob(
*,
title: str,
data: str | bytes,
description: str | None,
extra: str | None,
verb: str | None = None,
verb_cancel: str | None = None,
hold: bool = False,
chunkify: bool = False,
) -> object:
"""Confirm byte sequence data."""
# rust/src/ui/model_mercury/layout.rs
def confirm_address(
*,
title: str,
data: str | bytes,
description: str | None,
verb: str | None = "CONFIRM",
extra: str | None,
chunkify: bool = False,
) -> object:
"""Confirm address. Similar to `confirm_blob` but has corner info button
and allows left swipe which does the same thing as the button."""
# rust/src/ui/model_mercury/layout.rs
def confirm_properties(
*,
title: str,
items: list[tuple[str | None, str | bytes | None, bool]],
hold: bool = False,
) -> object:
"""Confirm list of key-value pairs. The third component in the tuple should be True if
the value is to be rendered as binary with monospace font, False otherwise."""
# rust/src/ui/model_mercury/layout.rs
def confirm_reset_device(
*,
title: str,
button: str,
) -> object:
"""Confirm TOS before device setup."""
# rust/src/ui/model_mercury/layout.rs
def show_address_details(
*,
qr_title: str,
address: str,
case_sensitive: bool,
details_title: str,
account: str | None,
path: str | None,
xpubs: list[tuple[str, str]],
) -> object:
"""Show address details - QR code, account, path, cosigner xpubs."""
# rust/src/ui/model_mercury/layout.rs
def show_info_with_cancel(
*,
title: str,
items: Iterable[Tuple[str, str]],
horizontal: bool = False,
chunkify: bool = False,
) -> object:
"""Show metadata for outgoing transaction."""
# rust/src/ui/model_mercury/layout.rs
def confirm_value(
*,
title: str,
value: str,
description: str | None,
subtitle: str | None,
verb: str | None = None,
verb_cancel: str | None = None,
info_button: bool = False,
hold: bool = False,
chunkify: bool = False,
text_mono: bool = True,
) -> object:
"""Confirm value. Merge of confirm_total and confirm_output."""
# rust/src/ui/model_mercury/layout.rs
def confirm_total(
*,
title: str,
items: Iterable[tuple[str, str]],
info_button: bool = False,
cancel_arrow: bool = False,
) -> object:
"""Transaction summary. Always hold to confirm."""
# rust/src/ui/model_mercury/layout.rs
def confirm_modify_output(
*,
sign: int,
amount_change: str,
amount_new: str,
) -> object:
"""Decrease or increase output amount."""
# rust/src/ui/model_mercury/layout.rs
def confirm_modify_fee(
*,
title: str,
sign: int,
user_fee_change: str,
total_fee_new: str,
fee_rate_amount: str | None, # ignored
) -> object:
"""Decrease or increase transaction fee."""
# rust/src/ui/model_mercury/layout.rs
def confirm_fido(
*,
title: str,
app_name: str,
icon_name: str | None,
accounts: list[str | None],
) -> int | object:
"""FIDO confirmation.
Returns page index in case of confirmation and CANCELLED otherwise.
"""
# rust/src/ui/model_mercury/layout.rs
def show_error(
*,
title: str,
button: str = "CONTINUE",
description: str = "",
allow_cancel: bool = False,
time_ms: int = 0,
) -> object:
"""Error modal. No buttons shown when `button` is empty string."""
# rust/src/ui/model_mercury/layout.rs
def show_warning(
*,
title: str,
button: str = "CONTINUE",
value: str = "",
description: str = "",
allow_cancel: bool = False,
time_ms: int = 0,
) -> object:
"""Warning modal. No buttons shown when `button` is empty string."""
# rust/src/ui/model_mercury/layout.rs
def show_success(
*,
title: str,
button: str = "CONTINUE",
description: str = "",
allow_cancel: bool = False,
time_ms: int = 0,
) -> object:
"""Success modal. No buttons shown when `button` is empty string."""
# rust/src/ui/model_mercury/layout.rs
def show_info(
*,
title: str,
button: str = "CONTINUE",
description: str = "",
allow_cancel: bool = False,
time_ms: int = 0,
) -> object:
"""Info modal. No buttons shown when `button` is empty string."""
# rust/src/ui/model_mercury/layout.rs
def show_mismatch(*, title: str) -> object:
"""Warning modal, receiving address mismatch."""
# rust/src/ui/model_mercury/layout.rs
def show_simple(
*,
title: str | None,
description: str = "",
button: str = "",
) -> object:
"""Simple dialog with text and one button."""
# rust/src/ui/model_mercury/layout.rs
def confirm_with_info(
*,
title: str,
button: str,
info_button: str,
items: Iterable[tuple[int, str]],
) -> object:
"""Confirm given items but with third button. Always single page
without scrolling."""
# rust/src/ui/model_mercury/layout.rs
def confirm_more(
*,
title: str,
button: str,
items: Iterable[tuple[int, str]],
) -> object:
"""Confirm long content with the possibility to go back from any page.
Meant to be used with confirm_with_info."""
# rust/src/ui/model_mercury/layout.rs
def confirm_coinjoin(
*,
max_rounds: str,
max_feerate: str,
) -> object:
"""Confirm coinjoin authorization."""
# rust/src/ui/model_mercury/layout.rs
def request_pin(
*,
prompt: str,
subprompt: str,
allow_cancel: bool = True,
wrong_pin: bool = False,
) -> str | object:
"""Request pin on device."""
# rust/src/ui/model_mercury/layout.rs
def request_passphrase(
*,
prompt: str,
max_len: int,
) -> str | object:
"""Passphrase input keyboard."""
# rust/src/ui/model_mercury/layout.rs
def request_bip39(
*,
prompt: str,
prefill_word: str,
can_go_back: bool,
) -> str:
"""BIP39 word input keyboard."""
# rust/src/ui/model_mercury/layout.rs
def request_slip39(
*,
prompt: str,
prefill_word: str,
can_go_back: bool,
) -> str:
"""SLIP39 word input keyboard."""
# rust/src/ui/model_mercury/layout.rs
def select_word(
*,
title: str,
description: str,
words: Iterable[str],
) -> int:
"""Select mnemonic word from three possibilities - seed check after backup. The
iterable must be of exact size. Returns index in range `0..3`."""
# rust/src/ui/model_mercury/layout.rs
def show_share_words(
*,
title: str,
pages: Iterable[str],
) -> object:
"""Show mnemonic for backup. Expects the words pre-divided into individual pages."""
# rust/src/ui/model_mercury/layout.rs
def request_number(
*,
title: str,
count: int,
min_count: int,
max_count: int,
description: Callable[[int], str] | None = None,
) -> object:
"""Number input with + and - buttons, description, and info button."""
# rust/src/ui/model_mercury/layout.rs
def show_checklist(
*,
title: str,
items: Iterable[str],
active: int,
button: str,
) -> object:
"""Checklist of backup steps. Active index is highlighted, previous items have check
mark next to them."""
# rust/src/ui/model_mercury/layout.rs
def confirm_recovery(
*,
title: str,
description: str,
button: str,
dry_run: bool,
info_button: bool = False,
) -> object:
"""Device recovery homescreen."""
# rust/src/ui/model_mercury/layout.rs
def select_word_count(
*,
dry_run: bool,
) -> int | str: # TT returns int
"""Select mnemonic word count from (12, 18, 20, 24, 33)."""
# rust/src/ui/model_mercury/layout.rs
def show_group_share_success(
*,
lines: Iterable[str]
) -> int:
"""Shown after successfully finishing a group."""
# rust/src/ui/model_mercury/layout.rs
def show_remaining_shares(
*,
pages: Iterable[tuple[str, str]],
) -> int:
"""Shows SLIP39 state after info button is pressed on `confirm_recovery`."""
# rust/src/ui/model_mercury/layout.rs
def show_progress(
*,
title: str,
indeterminate: bool = False,
description: str = "",
) -> object:
"""Show progress loader. Please note that the number of lines reserved on screen for
description is determined at construction time. If you want multiline descriptions
make sure the initial description has at least that amount of lines."""
# rust/src/ui/model_mercury/layout.rs
def show_progress_coinjoin(
*,
title: str,
indeterminate: bool = False,
time_ms: int = 0,
skip_first_paint: bool = False,
) -> object:
"""Show progress loader for coinjoin. Returns CANCELLED after a specified time when
time_ms timeout is passed."""
# rust/src/ui/model_mercury/layout.rs
def show_homescreen(
*,
label: str | None,
hold: bool,
notification: str | None,
notification_level: int = 0,
skip_first_paint: bool,
) -> CANCELLED:
"""Idle homescreen."""
# rust/src/ui/model_mercury/layout.rs
def show_lockscreen(
*,
label: str | None,
bootscreen: bool,
skip_first_paint: bool,
coinjoin_authorized: bool = False,
) -> CANCELLED:
"""Homescreen for locked device."""
# rust/src/ui/model_mercury/layout.rs
def confirm_firmware_update(
*,
description: str,
fingerprint: str,
) -> None:
"""Ask whether to update firmware, optionally show fingerprint. Shared with bootloader."""
# rust/src/ui/model_mercury/layout.rs
def show_wait_text(/, message: str) -> None:
"""Show single-line text in the middle of the screen."""
CONFIRMED: object
CANCELLED: object
INFO: object
# rust/src/ui/model_tr/layout.rs
def disable_animation(disable: bool) -> None:
"""Disable animations, debug builds only."""

File diff suppressed because it is too large Load Diff

@ -0,0 +1,89 @@
from typing import TYPE_CHECKING
import trezorui2
from trezor.enums import ButtonRequestType
from ..common import interact
from . import RustLayout
if TYPE_CHECKING:
from trezor.loop import AwaitableTask
if __debug__:
from trezor import io, ui
from ... import Result
class _RustFidoLayoutImpl(RustLayout):
def create_tasks(self) -> tuple[AwaitableTask, ...]:
return (
self.handle_timers(),
self.handle_input_and_rendering(),
self.handle_swipe(),
self.handle_debug_confirm(),
)
async def handle_debug_confirm(self) -> None:
from apps.debug import result_signal
_event_id, result = await result_signal()
if result is not trezorui2.CONFIRMED:
raise Result(result)
for event, x, y in (
(io.TOUCH_START, 220, 220),
(io.TOUCH_END, 220, 220),
):
msg = self.layout.touch_event(event, x, y)
self.layout.paint()
ui.refresh()
if msg is not None:
raise Result(msg)
_RustFidoLayout = _RustFidoLayoutImpl
else:
_RustFidoLayout = RustLayout
async def confirm_fido(
header: str,
app_name: str,
icon_name: str | None,
accounts: list[str | None],
) -> int:
"""Webauthn confirmation for one or more credentials."""
confirm = _RustFidoLayout(
trezorui2.confirm_fido(
title=header.upper(),
app_name=app_name,
icon_name=icon_name,
accounts=accounts,
)
)
result = await interact(confirm, "confirm_fido", ButtonRequestType.Other)
# The Rust side returns either an int or `CANCELLED`. We detect the int situation
# and assume cancellation otherwise.
if isinstance(result, int):
return result
# Late import won't get executed on the happy path.
from trezor.wire import ActionCancelled
raise ActionCancelled
async def confirm_fido_reset() -> bool:
from trezor import TR
confirm = RustLayout(
trezorui2.confirm_action(
title=TR.fido__title_reset,
action=TR.fido__erase_credentials,
description=TR.words__really_wanna,
reverse=True,
)
)
return (await confirm) is trezorui2.CONFIRMED

@ -0,0 +1,143 @@
from typing import TYPE_CHECKING
import storage.cache as storage_cache
import trezorui2
from trezor import TR, ui
from . import RustLayout
if TYPE_CHECKING:
from typing import Any, Tuple
from trezor import loop
class HomescreenBase(RustLayout):
RENDER_INDICATOR: object | None = None
def __init__(self, layout: Any) -> None:
super().__init__(layout=layout)
def _paint(self) -> None:
self.layout.paint()
ui.refresh()
def _first_paint(self) -> None:
if storage_cache.homescreen_shown is not self.RENDER_INDICATOR:
super()._first_paint()
storage_cache.homescreen_shown = self.RENDER_INDICATOR
else:
self._paint()
if __debug__:
# In __debug__ mode, ignore {confirm,swipe,input}_signal.
def create_tasks(self) -> tuple[loop.AwaitableTask, ...]:
return (
self.handle_timers(),
self.handle_input_and_rendering(),
self.handle_click_signal(), # so we can receive debug events
)
class Homescreen(HomescreenBase):
RENDER_INDICATOR = storage_cache.HOMESCREEN_ON
def __init__(
self,
label: str | None,
notification: str | None,
notification_is_error: bool,
hold_to_lock: bool,
) -> None:
level = 1
if notification is not None:
if notification == TR.homescreen__title_coinjoin_authorized:
level = 3
elif notification == TR.homescreen__title_experimental_mode:
level = 2
elif notification_is_error:
level = 0
skip = storage_cache.homescreen_shown is self.RENDER_INDICATOR
super().__init__(
layout=trezorui2.show_homescreen(
label=label,
notification=notification,
notification_level=level,
hold=hold_to_lock,
skip_first_paint=skip,
),
)
async def usb_checker_task(self) -> None:
from trezor import io, loop
usbcheck = loop.wait(io.USB_CHECK)
while True:
is_connected = await usbcheck
self.layout.usb_event(is_connected)
self.layout.paint()
ui.refresh()
def create_tasks(self) -> Tuple[loop.AwaitableTask, ...]:
return super().create_tasks() + (self.usb_checker_task(),)
class Lockscreen(HomescreenBase):
RENDER_INDICATOR = storage_cache.LOCKSCREEN_ON
BACKLIGHT_LEVEL = ui.style.BACKLIGHT_LOW
def __init__(
self,
label: str | None,
bootscreen: bool = False,
coinjoin_authorized: bool = False,
) -> None:
self.bootscreen = bootscreen
if bootscreen:
self.BACKLIGHT_LEVEL = ui.style.BACKLIGHT_NORMAL
skip = (
not bootscreen and storage_cache.homescreen_shown is self.RENDER_INDICATOR
)
super().__init__(
layout=trezorui2.show_lockscreen(
label=label,
bootscreen=bootscreen,
skip_first_paint=skip,
coinjoin_authorized=coinjoin_authorized,
),
)
async def __iter__(self) -> Any:
result = await super().__iter__()
if self.bootscreen:
self.request_complete_repaint()
return result
class Busyscreen(HomescreenBase):
RENDER_INDICATOR = storage_cache.BUSYSCREEN_ON
def __init__(self, delay_ms: int) -> None:
from trezor import TR
skip = storage_cache.homescreen_shown is self.RENDER_INDICATOR
super().__init__(
layout=trezorui2.show_progress_coinjoin(
title=TR.coinjoin__waiting_for_others,
indeterminate=True,
time_ms=delay_ms,
skip_first_paint=skip,
)
)
async def __iter__(self) -> Any:
from apps.base import set_homescreen
# Handle timeout.
result = await super().__iter__()
assert result == trezorui2.CANCELLED
storage_cache.delete(storage_cache.APP_COMMON_BUSY_DEADLINE_MS)
set_homescreen()
return result

@ -0,0 +1,72 @@
from typing import TYPE_CHECKING
import trezorui2
from trezor import TR, ui
if TYPE_CHECKING:
from typing import Any
from ..common import ProgressLayout
class RustProgress:
def __init__(
self,
layout: Any,
):
self.layout = layout
ui.backlight_fade(ui.style.BACKLIGHT_DIM)
self.layout.attach_timer_fn(self.set_timer)
self.layout.paint()
ui.refresh()
ui.backlight_fade(ui.style.BACKLIGHT_NORMAL)
def set_timer(self, token: int, deadline: int) -> None:
raise RuntimeError # progress layouts should not set timers
def report(self, value: int, description: str | None = None):
msg = self.layout.progress_event(value, description or "")
assert msg is None
self.layout.paint()
ui.refresh()
def progress(
message: str | None = None,
description: str | None = None,
indeterminate: bool = False,
) -> ProgressLayout:
message = message or TR.progress__please_wait # def_arg
return RustProgress(
layout=trezorui2.show_progress(
title=message.upper(),
indeterminate=indeterminate,
description=description or "",
)
)
def bitcoin_progress(message: str) -> ProgressLayout:
return progress(message)
def coinjoin_progress(message: str) -> ProgressLayout:
return RustProgress(
layout=trezorui2.show_progress_coinjoin(title=message, indeterminate=False)
)
def pin_progress(message: str, description: str) -> ProgressLayout:
return progress(message, description=description)
def monero_keyimage_sync_progress() -> ProgressLayout:
return progress("", TR.progress__syncing)
def monero_live_refresh_progress() -> ProgressLayout:
return progress("", TR.progress__refreshing, indeterminate=True)
def monero_transaction_progress_inner() -> ProgressLayout:
return progress("", TR.progress__signing_transaction)

@ -0,0 +1,171 @@
from typing import Callable, Iterable
import trezorui2
from trezor import TR
from trezor.enums import ButtonRequestType
from trezor.wire.context import wait as ctx_wait
from ..common import interact
from . import RustLayout, raise_if_not_confirmed
CONFIRMED = trezorui2.CONFIRMED # global_import_cache
INFO = trezorui2.INFO # global_import_cache
async def _is_confirmed_info(
dialog: RustLayout,
info_func: Callable,
) -> bool:
while True:
result = await ctx_wait(dialog)
if result is trezorui2.INFO:
await info_func()
dialog.request_complete_repaint()
else:
return result is CONFIRMED
async def request_word_count(dry_run: bool) -> int:
selector = RustLayout(trezorui2.select_word_count(dry_run=dry_run))
count = await interact(selector, "word_count", ButtonRequestType.MnemonicWordCount)
return int(count)
async def request_word(
word_index: int, word_count: int, is_slip39: bool, prefill_word: str = ""
) -> str:
prompt = TR.recovery__type_word_x_of_y_template.format(word_index + 1, word_count)
can_go_back = word_index > 0
if is_slip39:
keyboard = RustLayout(
trezorui2.request_slip39(
prompt=prompt, prefill_word=prefill_word, can_go_back=can_go_back
)
)
else:
keyboard = RustLayout(
trezorui2.request_bip39(
prompt=prompt, prefill_word=prefill_word, can_go_back=can_go_back
)
)
word: str = await ctx_wait(keyboard)
return word
async def show_remaining_shares(
groups: Iterable[tuple[int, tuple[str, ...]]], # remaining + list 3 words
shares_remaining: list[int],
group_threshold: int,
) -> None:
from trezor import strings
from trezor.crypto.slip39 import MAX_SHARE_COUNT
pages: list[tuple[str, str]] = []
for remaining, group in groups:
if 0 < remaining < MAX_SHARE_COUNT:
title = strings.format_plural(
TR.recovery__x_more_items_starting_template_plural,
remaining,
TR.plurals__x_shares_needed,
)
words = "\n".join(group)
pages.append((title, words))
elif (
remaining == MAX_SHARE_COUNT and shares_remaining.count(0) < group_threshold
):
groups_remaining = group_threshold - shares_remaining.count(0)
title = strings.format_plural(
TR.recovery__x_more_items_starting_template_plural,
groups_remaining,
TR.plurals__x_groups_needed,
)
words = "\n".join(group)
pages.append((title, words))
await raise_if_not_confirmed(
interact(
RustLayout(trezorui2.show_remaining_shares(pages=pages)),
"show_shares",
ButtonRequestType.Other,
)
)
async def show_group_share_success(share_index: int, group_index: int) -> None:
await raise_if_not_confirmed(
interact(
RustLayout(
trezorui2.show_group_share_success(
lines=[
TR.recovery__you_have_entered,
TR.recovery__share_num_template.format(share_index + 1),
TR.words__from,
TR.recovery__group_num_template.format(group_index + 1),
],
)
),
"share_success",
ButtonRequestType.Other,
)
)
async def continue_recovery(
button_label: str,
text: str,
subtext: str | None,
info_func: Callable | None,
dry_run: bool,
show_info: bool = False, # unused on TT
) -> bool:
from ..common import button_request
if show_info:
# Show this just one-time
description = TR.recovery__only_first_n_letters
else:
description = subtext or ""
homepage = RustLayout(
trezorui2.confirm_recovery(
title=text,
description=description,
button=button_label.upper(),
info_button=info_func is not None,
dry_run=dry_run,
)
)
await button_request("recovery", ButtonRequestType.RecoveryHomepage)
if info_func is not None:
return await _is_confirmed_info(homepage, info_func)
else:
result = await ctx_wait(homepage)
return result is CONFIRMED
async def show_recovery_warning(
br_type: str,
content: str,
subheader: str | None = None,
button: str | None = None,
br_code: ButtonRequestType = ButtonRequestType.Warning,
) -> None:
button = button or TR.buttons__try_again # def_arg
await raise_if_not_confirmed(
interact(
RustLayout(
trezorui2.show_warning(
title=content,
description=subheader or "",
button=button.upper(),
allow_cancel=False,
)
),
br_type,
br_code,
)
)

@ -0,0 +1,369 @@
from typing import TYPE_CHECKING
import trezorui2
from trezor import TR
from trezor.enums import ButtonRequestType
from trezor.wire import ActionCancelled
from trezor.wire.context import wait as ctx_wait
from ..common import interact
from . import RustLayout, raise_if_not_confirmed
if TYPE_CHECKING:
from typing import Callable, Sequence
from trezor.enums import BackupType
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,
group_index: int | None = None,
) -> None:
if share_index is None:
title = TR.reset__recovery_seed_title
elif group_index is None:
title = TR.reset__recovery_share_title_template.format(share_index + 1)
else:
title = TR.reset__group_share_title_template.format(
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,
),
),
"backup_words",
ButtonRequestType.ResetDevice,
)
if result != CONFIRMED:
raise ActionCancelled
async def select_word(
words: Sequence[str],
share_index: int | None,
checked_index: int,
count: int,
group_index: int | None = None,
) -> str:
if share_index is None:
title: str = TR.reset__check_seed_title
elif group_index is None:
title = TR.reset__check_share_title_template.format(share_index + 1)
else:
title = TR.reset__check_group_share_title_template.format(
group_index + 1, share_index + 1
)
# It may happen (with a very low probability)
# that there will be less than three unique words to choose from.
# In that case, duplicating the last word to make it three.
words = list(words)
while len(words) < 3:
words.append(words[-1])
result = await ctx_wait(
RustLayout(
trezorui2.select_word(
title=title,
description=TR.reset__select_word_x_of_y_template.format(
checked_index + 1, count
),
words=(words[0], words[1], words[2]),
)
)
)
if __debug__ and isinstance(result, str):
return result
assert isinstance(result, int) and 0 <= result <= 2
return words[result]
async def slip39_show_checklist(step: int, backup_type: BackupType) -> None:
from trezor.enums import BackupType
assert backup_type in (BackupType.Slip39_Basic, BackupType.Slip39_Advanced)
items = (
(
TR.reset__slip39_checklist_set_num_shares,
TR.reset__slip39_checklist_set_threshold,
TR.reset__slip39_checklist_write_down_recovery,
)
if backup_type == BackupType.Slip39_Basic
else (
TR.reset__slip39_checklist_set_num_groups,
TR.reset__slip39_checklist_set_num_shares,
TR.reset__slip39_checklist_set_sizes_longer,
)
)
result = await interact(
RustLayout(
trezorui2.show_checklist(
title=TR.reset__slip39_checklist_title,
button=TR.buttons__continue,
active=step,
items=items,
)
),
"slip39_checklist",
ButtonRequestType.ResetDevice,
)
if result != CONFIRMED:
raise ActionCancelled
async def _prompt_number(
title: str,
description: Callable[[int], str],
info: Callable[[int], str],
count: int,
min_count: int,
max_count: int,
br_name: str,
) -> int:
num_input = RustLayout(
trezorui2.request_number(
title=title.upper(),
description=description,
count=count,
min_count=min_count,
max_count=max_count,
)
)
while True:
result = await interact(
num_input,
br_name,
ButtonRequestType.ResetDevice,
)
if __debug__:
if not isinstance(result, tuple):
# DebugLink currently can't send number of shares and it doesn't
# change the counter either so just use the initial value.
result = (result, count)
status, value = result
if status == CONFIRMED:
assert isinstance(value, int)
return value
await ctx_wait(
RustLayout(
trezorui2.show_simple(
title=None,
description=info(value),
button=TR.buttons__ok_i_understand,
)
)
)
num_input.request_complete_repaint()
async def slip39_prompt_threshold(
num_of_shares: int, group_id: int | None = None
) -> int:
count = num_of_shares // 2 + 1
# min value of share threshold is 2 unless the number of shares is 1
# number of shares 1 is possible in advanced slip39
min_count = min(2, num_of_shares)
max_count = num_of_shares
def description(count: int) -> str:
if group_id is None:
if count == 1:
return TR.reset__you_need_one_share
elif count == max_count:
return TR.reset__need_all_share_template.format(count)
else:
return TR.reset__need_any_share_template.format(count)
else:
return TR.reset__num_shares_for_group_template.format(group_id + 1)
def info(count: int) -> str:
# TODO: this is madness...
text = TR.reset__the_threshold_sets_the_number_of_shares
if group_id is None:
text += TR.reset__needed_to_recover_your_wallet
text += TR.reset__set_it_to_count_template.format(count)
if num_of_shares == 1:
text += TR.reset__one_share
elif num_of_shares == count:
text += TR.reset__all_x_of_y_template.format(count, num_of_shares)
else:
text += TR.reset__any_x_of_y_template.format(count, num_of_shares)
text += "."
else:
text += TR.reset__needed_to_form_a_group
text += TR.reset__set_it_to_count_template.format(count)
if num_of_shares == 1:
text += TR.reset__one_share + " "
elif num_of_shares == count:
text += TR.reset__all_x_of_y_template.format(count, num_of_shares)
else:
text += TR.reset__any_x_of_y_template.format(count, num_of_shares)
text += " " + TR.reset__to_form_group_template.format(group_id + 1)
return text
return await _prompt_number(
TR.reset__title_set_threshold,
description,
info,
count,
min_count,
max_count,
"slip39_threshold",
)
async def slip39_prompt_number_of_shares(group_id: int | None = None) -> int:
count = 5
min_count = 1
max_count = 16
def description(i: int):
if group_id is None:
if i == 1:
return TR.reset__only_one_share_will_be_created
else:
return TR.reset__num_of_share_holders_template.format(i)
else:
return TR.reset__total_number_of_shares_in_group_template.format(
group_id + 1
)
if group_id is None:
info = TR.reset__num_of_shares_basic_info
else:
info = TR.reset__num_of_shares_advanced_info_template.format(group_id + 1)
return await _prompt_number(
TR.reset__title_set_number_of_shares,
description,
lambda i: info,
count,
min_count,
max_count,
"slip39_shares",
)
async def slip39_advanced_prompt_number_of_groups() -> int:
count = 5
min_count = 2
max_count = 16
description = TR.reset__group_description
info = TR.reset__group_info
return await _prompt_number(
TR.reset__title_set_number_of_groups,
lambda i: description,
lambda i: info,
count,
min_count,
max_count,
"slip39_groups",
)
async def slip39_advanced_prompt_group_threshold(num_of_groups: int) -> int:
count = num_of_groups // 2 + 1
min_count = 1
max_count = num_of_groups
description = TR.reset__required_number_of_groups
info = TR.reset__advanced_group_threshold_info
return await _prompt_number(
TR.reset__title_set_group_threshold,
lambda i: description,
lambda i: info,
count,
min_count,
max_count,
"slip39_group_threshold",
)
async def show_warning_backup(slip39: bool) -> None:
result = await interact(
RustLayout(
trezorui2.show_info(
title=TR.reset__never_make_digital_copy,
button=TR.buttons__ok_i_understand,
allow_cancel=False,
)
),
"backup_warning",
ButtonRequestType.ResetDevice,
)
if result != CONFIRMED:
raise ActionCancelled
async def show_success_backup() -> None:
from . import show_success
await show_success(
"success_backup",
TR.reset__use_your_backup,
TR.reset__your_backup_is_done,
)
async def show_reset_warning(
br_type: str,
content: str,
subheader: str | None = None,
button: str | None = None,
br_code: ButtonRequestType = ButtonRequestType.Warning,
) -> None:
button = button or TR.buttons__try_again # def_arg
await raise_if_not_confirmed(
interact(
RustLayout(
trezorui2.show_warning(
title=subheader or "",
description=content,
button=button.upper(),
allow_cancel=False,
)
),
br_type,
br_code,
)
)
Loading…
Cancel
Save