refactor(core/rust/ui): hold-to-confirm for paged content

[no changelog]
pull/2357/head
Martin Milata 2 years ago
parent 24a1f2e25e
commit ee1497b87e

@ -33,7 +33,7 @@ impl<T> Animation<T> {
T: Lerp,
{
let factor = self.elapsed(now) / self.duration;
T::lerp(self.from, self.to, factor)
T::lerp_bounded(self.from, self.to, factor)
}
/// Seek the animation such that `value` would be the current value.

@ -351,16 +351,15 @@ impl<T> Button<T> {
F1: Fn(ButtonMsg) -> Option<R>,
T: AsRef<str>,
{
const BUTTON_SPACING: i32 = 6;
(
GridPlaced::new(left)
.with_grid(1, 3)
.with_spacing(BUTTON_SPACING)
.with_spacing(theme::BUTTON_SPACING)
.with_row_col(0, 0)
.map(left_map),
GridPlaced::new(right)
.with_grid(1, 3)
.with_spacing(BUTTON_SPACING)
.with_spacing(theme::BUTTON_SPACING)
.with_from_to((0, 1), (0, 2))
.map(right_map),
)

@ -1,136 +0,0 @@
use crate::{
time::Instant,
ui::{
component::{Child, Component, ComponentExt, Event, EventCtx, Pad},
geometry::Rect,
model_tt::component::DialogLayout,
},
};
use super::{theme, Button, ButtonMsg, Loader, LoaderMsg};
pub enum HoldToConfirmMsg<T> {
Content(T),
Confirmed,
Cancelled,
}
pub struct HoldToConfirm<T> {
loader: Loader,
content: Child<T>,
cancel: Child<Button<&'static str>>,
confirm: Child<Button<&'static str>>,
pad: Pad,
}
impl<T> HoldToConfirm<T>
where
T: Component,
{
pub fn new(content: T) -> Self {
Self {
loader: Loader::new(0),
content: Child::new(content),
cancel: Child::new(Button::with_text("Cancel")),
confirm: Child::new(Button::with_text("Hold")),
pad: Pad::with_background(theme::BG),
}
}
pub fn inner(&self) -> &T {
self.content.inner()
}
}
impl<T> Component for HoldToConfirm<T>
where
T: Component,
{
type Msg = HoldToConfirmMsg<T::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
let layout = DialogLayout::middle(bounds);
self.loader.place(layout.content);
self.content.place(layout.content);
let (left, right) = layout.controls.split_left(layout.controls.size().x);
self.cancel.place(left);
self.confirm.place(right);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let now = Instant::now();
if let Some(LoaderMsg::ShrunkCompletely) = self.loader.event(ctx, event) {
// Clear the remnants of the loader.
self.pad.clear();
// Switch it to the initial state, so we stop painting it.
self.loader.reset();
// Re-draw the whole content tree.
self.content.request_complete_repaint(ctx);
// 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.
}
if let Some(msg) = self.content.event(ctx, event) {
return Some(Self::Msg::Content(msg));
}
if let Some(ButtonMsg::Clicked) = self.cancel.event(ctx, event) {
return Some(Self::Msg::Cancelled);
}
match self.confirm.event(ctx, event) {
Some(ButtonMsg::Pressed) => {
self.loader.start_growing(ctx, now);
self.pad.clear(); // Clear the remnants of the content.
}
Some(ButtonMsg::Released) => {
self.loader.start_shrinking(ctx, now);
}
Some(ButtonMsg::Clicked) => {
if self.loader.is_completely_grown(now) {
self.loader.reset();
return Some(HoldToConfirmMsg::Confirmed);
} else {
self.loader.start_shrinking(ctx, now);
}
}
_ => {}
}
None
}
fn paint(&mut self) {
self.pad.paint();
if self.loader.is_animating() {
self.loader.paint();
} else {
self.content.paint();
}
self.cancel.paint();
self.confirm.paint();
}
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.pad.area);
if self.loader.is_animating() {
self.loader.bounds(sink)
} else {
self.content.bounds(sink)
}
self.cancel.bounds(sink);
self.confirm.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for HoldToConfirm<T>
where
T: crate::trace::Trace,
{
fn trace(&self, d: &mut dyn crate::trace::Tracer) {
d.open("HoldToConfirm");
self.content.trace(d);
d.close();
}
}

@ -0,0 +1,217 @@
use crate::{
time::Instant,
ui::{
component::{Child, Component, ComponentExt, Event, EventCtx, Pad},
geometry::{Grid, Rect},
model_tt::component::DialogLayout,
},
};
use super::{theme, Button, ButtonMsg, Loader, LoaderMsg};
pub enum HoldToConfirmMsg<T> {
Content(T),
Confirmed,
Cancelled,
}
pub struct HoldToConfirm<T> {
loader: Loader,
content: Child<T>,
buttons: CancelHold,
pad: Pad,
}
impl<T> HoldToConfirm<T>
where
T: Component,
{
pub fn new(content: T) -> Self {
Self {
loader: Loader::new(),
content: Child::new(content),
buttons: CancelHold::new(),
pad: Pad::with_background(theme::BG),
}
}
pub fn inner(&self) -> &T {
self.content.inner()
}
}
impl<T> Component for HoldToConfirm<T>
where
T: Component,
{
type Msg = HoldToConfirmMsg<T::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
let layout = DialogLayout::middle(bounds);
self.pad.place(layout.content);
self.loader.place(layout.content);
self.content.place(layout.content);
self.buttons.place(layout.controls);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(msg) = self.content.event(ctx, event) {
return Some(HoldToConfirmMsg::Content(msg));
}
let button_msg = match self.buttons.event(ctx, event) {
Some(CancelHoldMsg::Cancelled) => return Some(HoldToConfirmMsg::Cancelled),
Some(CancelHoldMsg::HoldButton(b)) => Some(b),
_ => None,
};
if handle_hold_event(
ctx,
event,
button_msg,
&mut self.loader,
&mut self.pad,
&mut self.content,
) {
return Some(HoldToConfirmMsg::Confirmed);
}
None
}
fn paint(&mut self) {
self.pad.paint();
if self.loader.is_animating() {
self.loader.paint();
} else {
self.content.paint();
}
self.buttons.paint();
}
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.pad.area);
if self.loader.is_animating() {
self.loader.bounds(sink)
} else {
self.content.bounds(sink)
}
self.buttons.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for HoldToConfirm<T>
where
T: crate::trace::Trace,
{
fn trace(&self, d: &mut dyn crate::trace::Tracer) {
d.open("HoldToConfirm");
self.content.trace(d);
d.close();
}
}
pub struct CancelHold {
cancel: Button<&'static str>,
hold: Button<&'static str>,
}
pub enum CancelHoldMsg {
Cancelled,
HoldButton(ButtonMsg),
}
impl CancelHold {
pub fn new() -> Self {
Self {
cancel: Button::with_icon(theme::ICON_CANCEL),
hold: Button::with_text("HOLD TO CONFIRM").styled(theme::button_confirm()),
}
}
}
impl Component for CancelHold {
type Msg = CancelHoldMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let grid = Grid::new(bounds, 1, 4).with_spacing(theme::BUTTON_SPACING);
self.cancel.place(grid.row_col(0, 0));
self.hold
.place(grid.row_col(0, 1).union(grid.row_col(0, 3)));
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(ButtonMsg::Clicked) = self.cancel.event(ctx, event) {
return Some(CancelHoldMsg::Cancelled);
}
self.hold.event(ctx, event).map(CancelHoldMsg::HoldButton)
}
fn paint(&mut self) {
self.cancel.paint();
self.hold.paint();
}
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.cancel.bounds(sink);
self.hold.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for CancelHold {
fn trace(&self, d: &mut dyn crate::trace::Tracer) {
d.string("CancelHold")
}
}
/// Hold-to-confirm logic to be called from event handler of the component that
/// owns `pad`, `loader`, and `content` and a Button. It is expected that the
/// associated button already processed `event` and returned `button_msg`.
/// Returns `true` when the interaction successfully finished.
#[must_use]
pub fn handle_hold_event<T>(
ctx: &mut EventCtx,
event: Event,
button_msg: Option<ButtonMsg>,
loader: &mut Loader,
pad: &mut Pad,
content: &mut T,
) -> bool
where
T: Component,
{
let now = Instant::now();
if let Some(LoaderMsg::ShrunkCompletely) = loader.event(ctx, event) {
// Clear the remnants of the loader.
pad.clear();
// Switch it to the initial state, so we stop painting it.
loader.reset();
// Re-draw the whole content tree.
content.request_complete_repaint(ctx);
// 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);
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) {
loader.reset();
return true;
} else {
loader.start_shrinking(ctx, now);
}
}
_ => {}
}
false
}

@ -5,6 +5,7 @@ use crate::{
component::{Component, Event, EventCtx},
display::{self, Color},
geometry::{Offset, Rect},
model_tt::constant,
},
};
@ -32,9 +33,9 @@ pub struct Loader {
impl Loader {
pub const SIZE: Offset = Offset::new(120, 120);
pub fn new(offset_y: i32) -> Self {
pub fn new() -> Self {
Self {
offset_y,
offset_y: 0,
state: State::Initial,
growing_duration: Duration::from_millis(1000),
shrinking_duration: Duration::from_millis(500),
@ -70,13 +71,16 @@ impl Loader {
now,
);
if let State::Growing(growing) = &self.state {
anim.seek_to_value(display::LOADER_MAX - growing.value(now));
anim.seek_to_value(display::LOADER_MAX.saturating_sub(growing.value(now)));
}
self.state = State::Shrinking(anim);
// The animation should be already progressing at this point, so we don't need
// to request another animation frames, but we should request to get painted
// after this event pass.
// Request anim frame as the animation may not be running, e.g. when already
// grown completely.
ctx.request_anim_frame();
// We don't have to wait for the animation frame event with next paint,
// let's do that now.
ctx.request_paint();
}
@ -112,8 +116,11 @@ impl Component for Loader {
type Msg = LoaderMsg;
fn place(&mut self, bounds: Rect) -> Rect {
// TODO: Return the correct size.
bounds
// Current loader API only takes Y-offset relative to screen center, which we
// compute from the bounds center point.
let screen_center = constant::screen().center();
self.offset_y = screen_center.y - bounds.center().y;
Rect::from_center_and_size(screen_center + Offset::y(self.offset_y), Self::SIZE)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
@ -188,7 +195,7 @@ mod tests {
#[test]
fn loader_yields_expected_progress() {
let mut ctx = EventCtx::new();
let mut l = Loader::new(0);
let mut l = Loader::new();
let t = Instant::now();
assert_eq!(l.progress(t), None);
l.start_growing(&mut ctx, t);

@ -1,7 +1,7 @@
mod button;
mod confirm;
mod dialog;
mod frame;
mod hold_to_confirm;
mod keyboard;
mod loader;
mod page;
@ -9,9 +9,9 @@ mod scroll;
mod swipe;
pub use button::{Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet};
pub use confirm::{HoldToConfirm, HoldToConfirmMsg};
pub use dialog::{Dialog, DialogLayout, DialogMsg};
pub use frame::Frame;
pub use hold_to_confirm::{HoldToConfirm, HoldToConfirmMsg};
pub use keyboard::{
bip39::Bip39Input,
mnemonic::{MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg},
@ -20,7 +20,7 @@ pub use keyboard::{
slip39::Slip39Input,
};
pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet};
pub use page::SwipePage;
pub use page::{SwipeHoldPage, SwipePage};
pub use scroll::ScrollBar;
pub use swipe::{Swipe, SwipeDirection};

@ -1,12 +1,15 @@
use crate::ui::{
component::{
base::ComponentExt, paginated::PageMsg, Component, Event, EventCtx, Pad, Paginate,
base::ComponentExt, paginated::PageMsg, Component, Event, EventCtx, Label, Pad, Paginate,
},
display::{self, Color},
geometry::{Offset, Rect},
geometry::Rect,
};
use super::{theme, Button, ScrollBar, Swipe, SwipeDirection};
use super::{
hold_to_confirm::{handle_hold_event, CancelHold, CancelHoldMsg},
theme, Button, Loader, ScrollBar, Swipe, SwipeDirection,
};
pub struct SwipePage<T, U> {
content: T,
@ -14,6 +17,7 @@ pub struct SwipePage<T, U> {
pad: Pad,
swipe: Swipe,
scrollbar: ScrollBar,
hint: Label<&'static str>,
fade: Option<i32>,
}
@ -30,6 +34,7 @@ where
scrollbar: ScrollBar::vertical(),
swipe: Swipe::new(),
pad: Pad::with_background(background),
hint: Label::centered("SWIPE TO CONTINUE", theme::label_page_hint()),
fade: None,
}
}
@ -53,16 +58,6 @@ where
// paint.
self.fade = Some(theme::BACKLIGHT_NORMAL);
}
fn paint_hint(&mut self) {
display::text_center(
self.pad.area.bottom_center() - Offset::y(3),
"SWIPE TO CONTINUE",
theme::FONT_BOLD, // FIXME: Figma has this as 14px but bold is 16px
theme::GREY_LIGHT,
theme::BG,
);
}
}
impl<T, U> Component for SwipePage<T, U>
@ -77,6 +72,7 @@ where
let layout = PageLayout::new(bounds);
self.pad.place(bounds);
self.swipe.place(bounds);
self.hint.place(layout.hint);
self.buttons.place(layout.buttons);
self.scrollbar.place(layout.scrollbar);
@ -129,6 +125,8 @@ where
if let Some(msg) = self.buttons.event(ctx, event) {
return Some(PageMsg::Controls(msg));
}
} else {
self.hint.event(ctx, event);
}
None
}
@ -140,7 +138,7 @@ where
self.scrollbar.paint();
}
if self.scrollbar.has_next_page() {
self.paint_hint();
self.hint.paint();
} else {
self.buttons.paint();
}
@ -156,6 +154,8 @@ where
self.content.bounds(sink);
if !self.scrollbar.has_next_page() {
self.buttons.bounds(sink);
} else {
self.hint.bounds(sink);
}
}
}
@ -181,15 +181,18 @@ pub struct PageLayout {
pub content: Rect,
pub scrollbar: Rect,
pub buttons: Rect,
pub hint: Rect,
}
impl PageLayout {
const BUTTON_SPACE: i32 = 6;
const SCROLLBAR_WIDTH: i32 = 10;
const SCROLLBAR_SPACE: i32 = 10;
const HINT_OFF: i32 = 19;
pub fn new(area: Rect) -> Self {
let (content, buttons) = area.split_bottom(Button::<&str>::HEIGHT);
let (_, hint) = area.split_bottom(Self::HINT_OFF);
let (content, _space) = content.split_bottom(Self::BUTTON_SPACE);
let (buttons, _space) = buttons.split_right(theme::CONTENT_BORDER);
let (_space, content) = content.split_left(theme::CONTENT_BORDER);
@ -203,10 +206,103 @@ impl PageLayout {
content,
scrollbar,
buttons,
hint,
}
}
}
pub struct SwipeHoldPage<T> {
inner: SwipePage<T, CancelHold>,
loader: Loader,
}
impl<T> SwipeHoldPage<T>
where
T: Paginate,
T: Component,
{
pub fn new(content: T, background: Color) -> Self {
let buttons = CancelHold::new();
Self {
inner: SwipePage::new(content, buttons, background),
loader: Loader::new(),
}
}
}
impl<T> Component for SwipeHoldPage<T>
where
T: Paginate,
T: Component,
{
type Msg = PageMsg<T::Msg, bool>;
fn place(&mut self, bounds: Rect) -> Rect {
self.inner.place(bounds);
self.loader.place(self.inner.pad.area);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let msg = self.inner.event(ctx, event);
let button_msg = match msg {
Some(PageMsg::Content(c)) => return Some(PageMsg::Content(c)),
Some(PageMsg::Controls(CancelHoldMsg::Cancelled)) => {
return Some(PageMsg::Controls(false))
}
Some(PageMsg::Controls(CancelHoldMsg::HoldButton(b))) => Some(b),
_ => None,
};
if handle_hold_event(
ctx,
event,
button_msg,
&mut self.loader,
&mut self.inner.pad,
&mut self.inner.content,
) {
return Some(PageMsg::Controls(true));
}
None
}
fn paint(&mut self) {
self.inner.pad.paint();
if self.loader.is_animating() {
self.loader.paint()
} else {
self.inner.content.paint();
}
if self.inner.scrollbar.has_pages() {
self.inner.scrollbar.paint();
}
if self.inner.scrollbar.has_next_page() {
self.inner.hint.paint();
} else {
self.inner.buttons.paint();
}
if let Some(val) = self.inner.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.loader.bounds(sink);
self.inner.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for SwipeHoldPage<T>
where
T: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
self.inner.trace(t)
}
}
#[cfg(test)]
mod tests {
use crate::{

@ -21,7 +21,7 @@ use super::{
component::{
Bip39Input, Button, ButtonMsg, Dialog, DialogMsg, Frame, HoldToConfirm, HoldToConfirmMsg,
MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg, PassphraseKeyboard,
PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg, Slip39Input, SwipePage,
PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg, Slip39Input, SwipeHoldPage, SwipePage,
},
theme,
};
@ -116,6 +116,19 @@ where
}
}
impl<T> ComponentMsgObj for SwipeHoldPage<T>
where
T: Component + Paginate,
{
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
PageMsg::Content(_) => Err(Error::TypeError),
PageMsg::Controls(true) => Ok(CONFIRMED.as_obj()),
PageMsg::Controls(false) => Ok(CANCELLED.as_obj()),
}
}
}
extern "C" fn new_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| {
let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;

@ -87,6 +87,14 @@ pub fn label_keyboard_minor() -> LabelStyle {
}
}
pub fn label_page_hint() -> LabelStyle {
LabelStyle {
font: FONT_BOLD,
text_color: GREY_LIGHT,
background_color: BG,
}
}
pub fn button_default() -> ButtonStyleSheet {
ButtonStyleSheet {
normal: &ButtonStyle {
@ -285,6 +293,7 @@ impl DefaultTextTheme for TTDefaultText {
pub const CONTENT_BORDER: i32 = 5;
pub const KEYBOARD_SPACING: i32 = 8;
pub const BUTTON_SPACING: i32 = 6;
/// +----------+
/// | 13 |

Loading…
Cancel
Save