mirror of
https://github.com/trezor/trezor-firmware.git
synced 2025-01-31 18:00:58 +00:00
feat(core): init T3T1 UI layouts
Start with copy of components and layouts from T2T1.
This commit is contained in:
parent
2ba42d716e
commit
8d4472a68e
@ -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);
|
||||||
|
}
|
||||||
|
}
|
233
core/embed/rust/src/ui/model_mercury/component/dialog.rs
Normal file
233
core/embed/rust/src/ui/model_mercury/component/dialog.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
246
core/embed/rust/src/ui/model_mercury/component/fido.rs
Normal file
246
core/embed/rust/src/ui/model_mercury/component/fido.rs
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
80
core/embed/rust/src/ui/model_mercury/component/fido_icons.rs
Normal file
80
core/embed/rust/src/ui/model_mercury/component/fido_icons.rs
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
598
core/embed/rust/src/ui/model_mercury/component/homescreen/mod.rs
Normal file
598
core/embed/rust/src/ui/model_mercury/component/homescreen/mod.rs
Normal file
@ -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();
|
||||||
|
}
|
326
core/embed/rust/src/ui/model_mercury/component/keyboard/bip39.rs
Normal file
326
core/embed/rust/src/ui/model_mercury/component/keyboard/bip39.rs
Normal file
@ -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());
|
||||||
|
}
|
||||||
|
}
|
572
core/embed/rust/src/ui/model_mercury/component/keyboard/pin.rs
Normal file
572
core/embed/rust/src/ui/model_mercury/component/keyboard/pin.rs
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
276
core/embed/rust/src/ui/model_mercury/component/number_input.rs
Normal file
276
core/embed/rust/src/ui/model_mercury/component/number_input.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
851
core/embed/rust/src/ui/model_mercury/component/page.rs
Normal file
851
core/embed/rust/src/ui/model_mercury/component/page.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
183
core/embed/rust/src/ui/model_mercury/component/progress.rs
Normal file
183
core/embed/rust/src/ui/model_mercury/component/progress.rs
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
179
core/embed/rust/src/ui/model_mercury/component/scroll.rs
Normal file
179
core/embed/rust/src/ui/model_mercury/component/scroll.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
199
core/embed/rust/src/ui/model_mercury/component/simple_page.rs
Normal file
199
core/embed/rust/src/ui/model_mercury/component/simple_page.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
165
core/embed/rust/src/ui/model_mercury/component/swipe.rs
Normal file
165
core/embed/rust/src/ui/model_mercury/component/swipe.rs
Normal file
@ -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) {}
|
||||||
|
}
|
2167
core/embed/rust/src/ui/model_mercury/layout.rs
Normal file
2167
core/embed/rust/src/ui/model_mercury/layout.rs
Normal file
File diff suppressed because it is too large
Load Diff
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_apple.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_apple.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_aws.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_aws.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_binance.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_binance.toif
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_bitfinex.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_bitfinex.toif
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_coinbase.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_coinbase.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_dashlane.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_dashlane.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_dropbox.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_dropbox.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_duo.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_duo.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_facebook.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_facebook.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_fastmail.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_fastmail.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_fedora.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_fedora.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_gandi.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_gandi.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_gemini.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_gemini.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_github.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_github.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_gitlab.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_gitlab.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_google.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_google.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_invity.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_invity.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_keeper.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_keeper.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_kraken.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_kraken.toif
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_mojeid.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_mojeid.toif
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_proton.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_proton.toif
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_stripe.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_stripe.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_tutanota.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_tutanota.toif
Normal file
Binary file not shown.
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_webauthn.toif
Normal file
BIN
core/embed/rust/src/ui/model_mercury/res/fido/icon_webauthn.toif
Normal file
Binary file not shown.
77
core/embed/rust/src/ui/shape/model/model_mercury.rs
Normal file
77
core/embed/rust/src/ui/shape/model/model_mercury.rs
Normal file
@ -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
|
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
|
# rust/src/ui/model_tr/layout.rs
|
||||||
def disable_animation(disable: bool) -> None:
|
def disable_animation(disable: bool) -> None:
|
||||||
"""Disable animations, debug builds only."""
|
"""Disable animations, debug builds only."""
|
||||||
|
1524
core/src/trezor/ui/layouts/mercury/__init__.py
Normal file
1524
core/src/trezor/ui/layouts/mercury/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
89
core/src/trezor/ui/layouts/mercury/fido.py
Normal file
89
core/src/trezor/ui/layouts/mercury/fido.py
Normal file
@ -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
|
143
core/src/trezor/ui/layouts/mercury/homescreen.py
Normal file
143
core/src/trezor/ui/layouts/mercury/homescreen.py
Normal file
@ -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
|
72
core/src/trezor/ui/layouts/mercury/progress.py
Normal file
72
core/src/trezor/ui/layouts/mercury/progress.py
Normal file
@ -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)
|
171
core/src/trezor/ui/layouts/mercury/recovery.py
Normal file
171
core/src/trezor/ui/layouts/mercury/recovery.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
|
)
|
369
core/src/trezor/ui/layouts/mercury/reset.py
Normal file
369
core/src/trezor/ui/layouts/mercury/reset.py
Normal file
@ -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…
Reference in New Issue
Block a user