feat(core): T3T1 vertical menu

pull/3644/head
obrusvit 2 months ago committed by Martin Milata
parent 4708572802
commit 25b0e3b048

@ -528,6 +528,7 @@ static void _librust_qstrs(void) {
MP_QSTR_show_share_words;
MP_QSTR_show_simple;
MP_QSTR_show_success;
MP_QSTR_show_tx_context_menu;
MP_QSTR_show_wait_text;
MP_QSTR_show_warning;
MP_QSTR_sign;

@ -34,9 +34,10 @@ pub struct Button<T> {
}
impl<T> Button<T> {
/// Offsets the baseline of the button text either up (negative) or down
/// (positive).
pub const BASELINE_OFFSET: i16 = -2;
/// Offsets the baseline of the button text
/// -x/+x => left/right
/// -y/+y => up/down
pub const BASELINE_OFFSET: Offset = Offset::new(2, 6);
pub const fn new(content: ButtonContent<T>) -> Self {
Self {
@ -58,8 +59,8 @@ impl<T> Button<T> {
Self::new(ButtonContent::Icon(icon))
}
pub const fn with_icon_and_text(content: IconText) -> Self {
Self::new(ButtonContent::IconAndText(content))
pub const fn with_icon_and_text(content: IconText<T>) -> Self {
Self::new(ButtonContent::IconAndText::<T>(content))
}
pub const fn with_icon_blend(bg: Icon, fg: Icon, fg_offset: Offset) -> Self {
@ -211,11 +212,7 @@ impl<T> Button<T> {
ButtonContent::Empty => {}
ButtonContent::Text(text) => {
let text = text.as_ref();
let width = style.font.text_width(text);
let height = style.font.text_height();
let start_of_baseline = self.area.center()
+ Offset::new(-width / 2, height / 2)
+ Offset::y(Self::BASELINE_OFFSET);
let start_of_baseline = self.area.center() + Self::BASELINE_OFFSET;
display::text_left(
start_of_baseline,
text,
@ -252,11 +249,7 @@ impl<T> Button<T> {
ButtonContent::Empty => {}
ButtonContent::Text(text) => {
let text = text.as_ref();
let width = style.font.text_width(text);
let height = style.font.text_height();
let start_of_baseline = self.area.center()
+ Offset::new(-width / 2, height / 2)
+ Offset::y(Self::BASELINE_OFFSET);
let start_of_baseline = self.area.left_center() + Self::BASELINE_OFFSET;
shape::Text::new(start_of_baseline, text)
.with_font(style.font)
.with_fg(style.text_color)
@ -269,7 +262,7 @@ impl<T> Button<T> {
.render(target);
}
ButtonContent::IconAndText(child) => {
child.paint(self.area, self.style(), Self::BASELINE_OFFSET);
child.render(target, self.area, style, Self::BASELINE_OFFSET);
}
ButtonContent::IconBlend(bg, fg, offset) => {
shape::Bar::new(self.area)
@ -399,7 +392,7 @@ where
ButtonContent::Text(text) => t.string("text", text.as_ref().into()),
ButtonContent::Icon(_) => t.bool("icon", true),
ButtonContent::IconAndText(content) => {
t.string("text", content.text.into());
t.string("text", content.text.as_ref().into());
t.bool("icon", true);
}
ButtonContent::IconBlend(_, _, _) => t.bool("icon", true),
@ -420,7 +413,7 @@ pub enum ButtonContent<T> {
Empty,
Text(T),
Icon(Icon),
IconAndText(IconText),
IconAndText(IconText<T>),
IconBlend(Icon, Icon, Offset),
}
@ -538,43 +531,6 @@ impl<T> Button<T> {
total_height,
)
}
pub fn select_word(
words: [T; 3],
) -> CancelInfoConfirm<
T,
impl Fn(ButtonMsg) -> Option<SelectWordMsg>,
impl Fn(ButtonMsg) -> Option<SelectWordMsg>,
impl Fn(ButtonMsg) -> Option<SelectWordMsg>,
>
where
T: AsRef<str>,
{
let btn = move |i, word| {
Button::with_text(word)
.styled(theme::button_pin())
.map(move |msg| {
(matches!(msg, ButtonMsg::Clicked)).then(|| SelectWordMsg::Selected(i))
})
};
let [top, middle, bottom] = words;
let total_height = 3 * theme::BUTTON_HEIGHT + 2 * theme::BUTTON_SPACING;
FixedHeightBar::bottom(
Split::top(
theme::BUTTON_HEIGHT,
theme::BUTTON_SPACING,
btn(0, top),
Split::top(
theme::BUTTON_HEIGHT,
theme::BUTTON_SPACING,
btn(1, middle),
btn(2, bottom),
),
),
total_height,
)
}
}
pub enum CancelConfirmMsg {
@ -600,23 +556,25 @@ pub enum SelectWordMsg {
}
#[derive(PartialEq, Eq)]
pub struct IconText {
text: &'static str,
pub struct IconText<T> {
text: T,
icon: Icon,
}
impl IconText {
impl<T> IconText<T>
where
T: AsRef<str>,
{
const ICON_SPACE: i16 = 46;
const ICON_MARGIN: i16 = 4;
const TEXT_MARGIN: i16 = 6;
pub fn new(text: &'static str, icon: Icon) -> Self {
pub fn new(text: T, icon: Icon) -> Self {
Self { text, icon }
}
pub fn paint(&self, area: Rect, style: &ButtonStyle, baseline_offset: i16) {
let width = style.font.text_width(self.text);
let height = style.font.text_height();
pub fn paint(&self, area: Rect, style: &ButtonStyle, baseline_offset: Offset) {
let width = style.font.text_width(self.text.as_ref());
let mut use_icon = false;
let mut use_text = false;
@ -625,8 +583,7 @@ impl IconText {
area.top_left().x + ((Self::ICON_SPACE + Self::ICON_MARGIN) / 2),
area.center().y,
);
let mut text_pos =
area.center() + Offset::new(-width / 2, height / 2) + Offset::y(baseline_offset);
let mut text_pos = area.left_center() + baseline_offset;
if area.width() > (Self::ICON_SPACE + Self::TEXT_MARGIN + width) {
//display both icon and text
@ -644,7 +601,7 @@ impl IconText {
if use_text {
display::text_left(
text_pos,
self.text,
self.text.as_ref(),
style.font,
style.text_color,
style.button_color,
@ -660,4 +617,47 @@ impl IconText {
);
}
}
pub fn render<'s>(
& self,
target: &mut impl Renderer<'s>,
area: Rect,
style: &ButtonStyle,
baseline_offset: Offset,
) {
let width = style.font.text_width(self.text.as_ref());
let mut use_icon = false;
let mut use_text = false;
let mut icon_pos = Point::new(
area.top_left().x + ((Self::ICON_SPACE + Self::ICON_MARGIN) / 2),
area.center().y,
);
let mut text_pos = area.left_center() + baseline_offset;
if area.width() > (Self::ICON_SPACE + Self::TEXT_MARGIN + width) {
//display both icon and text
text_pos = Point::new(area.top_left().x + Self::ICON_SPACE, text_pos.y);
use_text = true;
use_icon = true;
} else if area.width() > (width + Self::TEXT_MARGIN) {
use_text = true;
} else {
//if we can't fit the text, retreat to centering the icon
icon_pos = area.center();
use_icon = true;
}
if use_text {
shape::Text::new(text_pos, self.text.as_ref())
.with_fg(style.text_color)
.render(target);
}
if use_icon {
shape::ToifImage::new(icon_pos, self.icon.toif)
.with_align(Alignment2D::CENTER)
.with_fg(style.text_color)
.render(target);
}
}
}

@ -61,7 +61,7 @@ where
}
pub fn with_subtitle(mut self, subtitle: U) -> Self {
let style = theme::label_title_sub();
let style = theme::TEXT_SUB;
self.title = Child::new(self.title.into_inner().top_aligned());
self.subtitle = Some(Child::new(Label::new(
subtitle,
@ -126,7 +126,7 @@ where
fn place(&mut self, bounds: Rect) -> Rect {
let (mut header_area, content_area) = bounds.split_top(TITLE_HEIGHT);
let content_area = content_area.inset(Insets::right(TITLE_SPACE));
let content_area = content_area.inset(Insets::top(TITLE_SPACE));
if let Some(b) = &mut self.button {
let (rest, button_area) = header_area.split_right(TITLE_HEIGHT);

@ -22,7 +22,7 @@ use crate::{
ui::{
constant::HEIGHT,
display::{
tjpgd::{jpeg_test, BufferInput},
tjpgd::BufferInput,
toif::{Toif, ToifFormat},
},
model_mercury::component::homescreen::render::{

@ -6,6 +6,7 @@ mod button;
mod coinjoin_progress;
mod dialog;
mod fido;
mod vertical_menu;
#[rustfmt::skip]
mod fido_icons;
mod error;
@ -57,6 +58,7 @@ pub use result::{ResultFooter, ResultScreen, ResultStyle};
pub use scroll::ScrollBar;
pub use simple_page::SimplePage;
pub use swipe::{Swipe, SwipeDirection};
pub use vertical_menu::{VerticalMenu, VerticalMenuChoiceMsg};
pub use welcome_screen::WelcomeScreen;
use super::theme;

@ -226,7 +226,7 @@ impl Component for NumberInput {
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;
let y_offset = digit_font.text_height() / 2 + Button::<&str>::BASELINE_OFFSET.y;
display::rect_fill(self.area, theme::BG);
display::text_center(
self.area.center() + Offset::y(y_offset),
@ -245,7 +245,7 @@ impl Component for NumberInput {
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;
let y_offset = digit_font.text_height() / 2 + Button::<&str>::BASELINE_OFFSET.y;
shape::Bar::new(self.area).with_bg(theme::BG).render(target);
shape::Text::new(self.area.center() + Offset::y(y_offset), text)

@ -0,0 +1,156 @@
use heapless::Vec;
use super::theme;
use crate::ui::{
component::{base::Component, Event, EventCtx},
display::Icon,
geometry::Rect,
model_mercury::component::button::{Button, ButtonMsg, IconText},
shape::{Bar, Renderer},
};
pub enum VerticalMenuChoiceMsg {
Selected(usize),
}
/// Number of buttons.
/// Presently, VerticalMenu holds only fixed number of buttons.
/// TODO: for scrollable menu, the implementation must change.
const N_ITEMS: usize = 3;
/// Number of visual separators between buttons.
const N_SEPS: usize = N_ITEMS - 1;
/// Fixed height of each menu button.
const MENU_BUTTON_HEIGHT: i16 = 64;
/// Fixed height of a separator.
const MENU_SEP_HEIGHT: i16 = 2;
type VerticalMenuButtons<T> = Vec<Button<T>, N_ITEMS>;
type AreasForSeparators = Vec<Rect, N_SEPS>;
pub struct VerticalMenu<T> {
area: Rect,
/// buttons placed vertically from top to bottom
buttons: VerticalMenuButtons<T>,
/// areas for visual separators between buttons
areas_sep: AreasForSeparators,
}
impl<T> VerticalMenu<T>
where
T: AsRef<str>,
{
fn new(buttons: VerticalMenuButtons<T>) -> Self {
Self {
area: Rect::zero(),
buttons,
areas_sep: AreasForSeparators::new(),
}
}
pub fn select_word(words: [T; 3]) -> Self {
let mut buttons_vec = VerticalMenuButtons::new();
for word in words {
let button = Button::with_text(word).styled(theme::button_vertical_menu());
unwrap!(buttons_vec.push(button));
}
Self::new(buttons_vec)
}
pub fn context_menu(options: [(T, Icon); 3]) -> Self {
// TODO: this is just POC
let mut buttons_vec = VerticalMenuButtons::new();
for opt in options {
let button_theme;
match opt.1 {
theme::ICON_CANCEL => {
button_theme = theme::button_vertical_menu_orange();
}
_ => {
button_theme = theme::button_vertical_menu();
}
}
unwrap!(buttons_vec.push(
Button::with_icon_and_text(IconText::new(opt.0, opt.1)).styled(button_theme)
));
}
Self::new(buttons_vec)
}
}
impl<T> Component for VerticalMenu<T>
where
T: AsRef<str>,
{
type Msg = VerticalMenuChoiceMsg;
fn place(&mut self, bounds: Rect) -> Rect {
// VerticalMenu is supposed to be used in Frame, the remaining space is just
// enought to fit 3 buttons separated by thin bars
let height_bounds_expected = 3 * MENU_BUTTON_HEIGHT + 2 * MENU_SEP_HEIGHT;
assert!(bounds.height() == height_bounds_expected);
self.area = bounds;
self.areas_sep.clear();
let mut remaining = bounds;
for i in 0..N_ITEMS {
let (area_button, new_remaining) = remaining.split_top(MENU_BUTTON_HEIGHT);
self.buttons[i].place(area_button);
remaining = new_remaining;
if i < N_SEPS {
let (area_sep, new_remaining) = remaining.split_top(MENU_SEP_HEIGHT);
unwrap!(self.areas_sep.push(area_sep));
remaining = new_remaining;
}
}
self.area
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
for (i, button) in self.buttons.iter_mut().enumerate() {
if let Some(ButtonMsg::Clicked) = button.event(ctx, event) {
return Some(VerticalMenuChoiceMsg::Selected(i));
}
}
None
}
fn paint(&mut self) {
// TODO remove when ui-t3t1 done
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
// render buttons separated by thin bars
for button in &self.buttons {
button.render(target);
}
for area in self.areas_sep.iter() {
Bar::new(*area)
.with_thickness(MENU_SEP_HEIGHT)
.with_fg(theme::GREY_EXTRA_DARK)
.render(target);
}
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.area);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for VerticalMenu<T>
where
T: AsRef<str>,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("VerticalMenu");
t.in_list("buttons", &|button_list| {
for button in &self.buttons {
button_list.child(button);
}
});
}
}

@ -34,7 +34,7 @@ use crate::{
},
Border, Component, Empty, FormattedText, Label, Never, Qr, Timeout,
},
display::tjpgd::jpeg_info,
display::{tjpgd::jpeg_info, Icon},
geometry,
layout::{
obj::{ComponentMsgObj, LayoutObj},
@ -52,7 +52,8 @@ use super::{
FidoMsg, Frame, FrameMsg, Homescreen, HomescreenMsg, IconDialog, Lockscreen, MnemonicInput,
MnemonicKeyboard, MnemonicKeyboardMsg, NumberInputDialog, NumberInputDialogMsg,
PassphraseKeyboard, PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg, Progress,
SelectWordCount, SelectWordCountMsg, SelectWordMsg, SimplePage, Slip39Input,
SelectWordCount, SelectWordCountMsg, SelectWordMsg, SimplePage, Slip39Input, VerticalMenu,
VerticalMenuChoiceMsg,
},
theme,
};
@ -100,6 +101,16 @@ impl TryFrom<SelectWordCountMsg> for Obj {
}
}
impl TryFrom<VerticalMenuChoiceMsg> for Obj {
type Error = Error;
fn try_from(value: VerticalMenuChoiceMsg) -> Result<Self, Self::Error> {
match value {
VerticalMenuChoiceMsg::Selected(i) => i.try_into(),
}
}
}
impl<F, T, U> ComponentMsgObj for FidoConfirm<F, T, U>
where
F: Fn(usize) -> T,
@ -195,6 +206,17 @@ where
}
}
impl<T> ComponentMsgObj for VerticalMenu<T>
where
T: AsRef<str>,
{
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
VerticalMenuChoiceMsg::Selected(i) => i.try_into(),
}
}
}
impl<T, U> ComponentMsgObj for ButtonPage<T, U>
where
T: Component + Paginate,
@ -1299,11 +1321,30 @@ extern "C" fn new_select_word(n_args: usize, args: *const Obj, kwargs: *mut Map)
let words_iterable: Obj = kwargs.get(Qstr::MP_QSTR_words)?;
let words: [StrBuffer; 3] = util::iter_into_array(words_iterable)?;
let paragraphs = Paragraphs::new([Paragraph::new(&theme::TEXT_DEMIBOLD, description)]);
let obj = LayoutObj::new(Frame::left_aligned(
title,
Dialog::new(paragraphs, Button::select_word(words)),
))?;
let content = VerticalMenu::select_word(words);
let frame_with_menu = Frame::left_aligned(title, content).with_subtitle(description);
let obj = LayoutObj::new(frame_with_menu)?;
Ok(obj.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
}
extern "C" fn new_show_tx_context_menu(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], _kwargs: &Map| {
// TODO: this is just POC
let title: StrBuffer = StrBuffer::from("");
let options: [(StrBuffer, Icon); 3] = [
(StrBuffer::from("Address QR code"), theme::ICON_QR_CODE),
(
StrBuffer::from("Fee info"),
theme::ICON_CHEVRON_RIGHT,
),
(StrBuffer::from("Cancel transaction"), theme::ICON_CANCEL),
];
let content = VerticalMenu::context_menu(options);
let frame_with_menu = Frame::left_aligned(title, content).with_cancel_button();
let obj = LayoutObj::new(frame_with_menu)?;
Ok(obj.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
@ -2033,6 +2074,11 @@ pub static mp_module_trezorui2: Module = obj_module! {
/// iterable must be of exact size. Returns index in range `0..3`."""
Qstr::MP_QSTR_select_word => obj_fn_kw!(0, new_select_word).as_obj(),
/// def show_tx_context_menu() -> LayoutObj[int]:
/// """Show transaction context menu with the options for 1) Address QR code, 2) Fee
/// information, 3) Cancel transaction"""
Qstr::MP_QSTR_show_tx_context_menu => obj_fn_kw!(0, new_show_tx_context_menu).as_obj(),
/// def show_share_words(
/// *,
/// title: str,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 B

After

Width:  |  Height:  |  Size: 226 B

@ -57,7 +57,7 @@ pub const FATAL_ERROR_COLOR: Color = Color::rgb(0xE7, 0x0E, 0x0E);
pub const FATAL_ERROR_HIGHLIGHT_COLOR: Color = Color::rgb(0xFF, 0x41, 0x41);
// Commonly used corner radius (i.e. for buttons).
pub const RADIUS: u8 = 2;
pub const RADIUS: u8 = 0;
// Full-size QR code.
pub const QR_SIDE_MAX: u32 = 140;
@ -416,6 +416,70 @@ pub const fn button_info() -> ButtonStyleSheet {
}
}
pub const fn button_vertical_menu() -> ButtonStyleSheet {
ButtonStyleSheet {
normal: &ButtonStyle {
font: Font::NORMAL,
text_color: GREY_EXTRA_LIGHT,
button_color: BG,
background_color: BG,
border_color: BG,
border_radius: RADIUS,
border_width: 0,
},
// TODO: change when figma done
active: &ButtonStyle {
font: Font::NORMAL,
text_color: FG,
button_color: GREEN_LIME,
background_color: GREY_EXTRA_DARK,
border_color: GREEN_LIME,
border_radius: RADIUS,
border_width: 0,
},
disabled: &ButtonStyle {
font: Font::NORMAL,
text_color: GREY_LIGHT,
button_color: GREEN_LIME,
background_color: GREY_EXTRA_DARK,
border_color: GREEN_LIME,
border_radius: RADIUS,
border_width: 0,
},
}
}
pub const fn button_vertical_menu_orange() -> ButtonStyleSheet {
ButtonStyleSheet {
normal: &ButtonStyle {
font: Font::NORMAL,
text_color: ORANGE_LIGHT,
button_color: BG,
background_color: BG,
border_color: BG,
border_radius: RADIUS,
border_width: 0,
},
active: &ButtonStyle {
font: Font::NORMAL,
text_color: FG,
button_color: GREEN_LIME,
background_color: GREY_EXTRA_DARK,
border_color: GREEN_LIME,
border_radius: RADIUS,
border_width: 0,
},
disabled: &ButtonStyle {
font: Font::NORMAL,
text_color: GREY_LIGHT,
button_color: GREEN_LIME,
background_color: GREY_EXTRA_DARK,
border_color: GREEN_LIME,
border_radius: RADIUS,
border_width: 0,
},
}
}
pub const fn button_pin() -> ButtonStyleSheet {
ButtonStyleSheet {
normal: &ButtonStyle {

@ -389,6 +389,12 @@ def select_word(
iterable must be of exact size. Returns index in range `0..3`."""
# rust/src/ui/model_mercury/layout.rs
def show_tx_context_menu() -> LayoutObj[int]:
"""Show transaction context menu with the options for 1) Address QR code, 2) Fee
information, 3) Cancel transaction"""
# rust/src/ui/model_mercury/layout.rs
def show_share_words(
*,

Loading…
Cancel
Save