Merge de383f2a8e
into 9c20847fdd
@ -0,0 +1,151 @@
|
||||
use crate::ui::{
|
||||
component::{Component, Event, EventCtx},
|
||||
event::TouchEvent,
|
||||
geometry::{Point, Rect},
|
||||
shape::Renderer,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum SwipeDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Copy of `model_tt/component/swipe.rs` but without the backlight handling.
|
||||
pub struct Swipe {
|
||||
pub area: Rect,
|
||||
pub allow_up: bool,
|
||||
pub allow_down: bool,
|
||||
pub allow_left: bool,
|
||||
pub allow_right: bool,
|
||||
|
||||
origin: Option<Point>,
|
||||
}
|
||||
|
||||
impl Swipe {
|
||||
const DISTANCE: i32 = 120;
|
||||
const THRESHOLD: f32 = 0.2;
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
allow_up: false,
|
||||
allow_down: false,
|
||||
allow_left: false,
|
||||
allow_right: false,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn paint(&mut self) {}
|
||||
|
||||
fn render<'s>(&'s self, _target: &mut impl Renderer<'s>) {}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
use crate::ui::{
|
||||
component::{EventCtx, SwipeDirection},
|
||||
geometry::Offset,
|
||||
};
|
||||
use num_traits::ToPrimitive;
|
||||
|
||||
impl SwipeDirection {
|
||||
pub fn as_offset(self, size: Offset) -> Offset {
|
||||
match self {
|
||||
SwipeDirection::Up => Offset::y(-size.y),
|
||||
SwipeDirection::Down => Offset::y(size.y),
|
||||
SwipeDirection::Left => Offset::x(-size.x),
|
||||
SwipeDirection::Right => Offset::x(size.x),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Component must implement this trait in order to be part of swipe-based flow.
|
||||
/// The process of receiving a swipe is two-step, because in order to render the
|
||||
/// transition animation Flow makes a copy of the pre-swipe state of the
|
||||
/// component to render it along with the post-swipe state.
|
||||
pub trait Swipable {
|
||||
/// Return true if component can handle swipe in a given direction.
|
||||
fn can_swipe(&self, _direction: SwipeDirection) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Make component react to swipe event. Only called if component returned
|
||||
/// true in the previous function.
|
||||
fn swiped(&mut self, _ctx: &mut EventCtx, _direction: SwipeDirection) {}
|
||||
}
|
||||
|
||||
/// Component::Msg for component parts of a flow. Converting results of
|
||||
/// different screens to a shared type makes things easier to work with.
|
||||
///
|
||||
/// Also currently the type for message emitted by Flow::event to
|
||||
/// micropython. They don't need to be the same.
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum FlowMsg {
|
||||
Confirmed,
|
||||
Cancelled,
|
||||
Info,
|
||||
Choice(usize),
|
||||
}
|
||||
|
||||
/// Composable event handler result.
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum Decision<Q> {
|
||||
/// Do nothing, continue with processing next handler.
|
||||
Nothing,
|
||||
|
||||
/// Initiate transition to another state, end event processing.
|
||||
/// NOTE: it might make sense to include Option<ButtonRequest> here
|
||||
Goto(Q, SwipeDirection),
|
||||
|
||||
/// Yield a message to the caller of the flow (i.e. micropython), end event
|
||||
/// processing.
|
||||
Return(FlowMsg),
|
||||
}
|
||||
|
||||
impl<Q> Decision<Q> {
|
||||
pub fn or_else(self, func: impl FnOnce() -> Self) -> Self {
|
||||
match self {
|
||||
Decision::Nothing => func(),
|
||||
_ => self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes the flow logic as a set of states, and transitions between them
|
||||
/// triggered by events and swipes.
|
||||
pub trait FlowState
|
||||
where
|
||||
Self: Sized + Copy + PartialEq + Eq + ToPrimitive,
|
||||
{
|
||||
/// There needs to be a mapping from states to indices of the FlowStore
|
||||
/// array. Default implementation works for states that are enums, the
|
||||
/// FlowStore has to have number of elements equal to number of states.
|
||||
fn index(&self) -> usize {
|
||||
unwrap!(self.to_usize())
|
||||
}
|
||||
|
||||
/// What to do when user swipes on screen and current component doesn't
|
||||
/// respond to swipe of that direction.
|
||||
fn handle_swipe(&self, direction: SwipeDirection) -> Decision<Self>;
|
||||
|
||||
/// What to do when the current component emits a message in response to an
|
||||
/// event.
|
||||
fn handle_event(&self, msg: FlowMsg) -> Decision<Self>;
|
||||
}
|
@ -0,0 +1,202 @@
|
||||
use crate::{
|
||||
error,
|
||||
time::{Duration, Instant},
|
||||
ui::{
|
||||
animation::Animation,
|
||||
component::{Component, Event, EventCtx, Swipe, SwipeDirection},
|
||||
flow::{base::Decision, FlowMsg, FlowState, FlowStore},
|
||||
geometry::{Offset, Rect},
|
||||
shape::Renderer,
|
||||
},
|
||||
};
|
||||
|
||||
const ANIMATION_DURATION: Duration = Duration::from_millis(333);
|
||||
|
||||
/// Given a state enum and a corresponding FlowStore, create a Component that
|
||||
/// implements a swipe navigation between the states with animated transitions.
|
||||
///
|
||||
/// If a swipe is detected:
|
||||
/// - currently active component is asked to handle the event,
|
||||
/// - if it can't then FlowState::handle_swipe is consulted.
|
||||
pub struct SwipeFlow<Q, S> {
|
||||
/// Current state.
|
||||
state: Q,
|
||||
/// FlowStore with all screens/components.
|
||||
store: S,
|
||||
/// `Some` when state transition animation is in progress.
|
||||
transition: Option<Transition<Q>>,
|
||||
/// Swipe detector.
|
||||
swipe: Swipe,
|
||||
/// Animation parameter.
|
||||
anim_offset: Offset,
|
||||
}
|
||||
|
||||
struct Transition<Q> {
|
||||
prev_state: Q,
|
||||
animation: Animation<Offset>,
|
||||
direction: SwipeDirection,
|
||||
}
|
||||
|
||||
impl<Q: FlowState, S: FlowStore> SwipeFlow<Q, S> {
|
||||
pub fn new(init: Q, store: S) -> Result<Self, error::Error> {
|
||||
Ok(Self {
|
||||
state: init,
|
||||
store,
|
||||
transition: None,
|
||||
swipe: Swipe::new().down().up().left().right(),
|
||||
anim_offset: Offset::zero(),
|
||||
})
|
||||
}
|
||||
|
||||
fn goto(&mut self, ctx: &mut EventCtx, direction: SwipeDirection, state: Q) {
|
||||
self.transition = Some(Transition {
|
||||
prev_state: self.state,
|
||||
animation: Animation::new(
|
||||
Offset::zero(),
|
||||
direction.as_offset(self.anim_offset),
|
||||
ANIMATION_DURATION,
|
||||
Instant::now(),
|
||||
),
|
||||
direction,
|
||||
});
|
||||
self.state = state;
|
||||
ctx.request_anim_frame();
|
||||
ctx.request_paint()
|
||||
}
|
||||
|
||||
fn render_state<'s>(&'s self, state: Q, target: &mut impl Renderer<'s>) {
|
||||
self.store.render(state.index(), target)
|
||||
}
|
||||
|
||||
fn render_transition<'s>(&'s self, transition: &Transition<Q>, target: &mut impl Renderer<'s>) {
|
||||
let off = transition.animation.value(Instant::now());
|
||||
|
||||
if self.state == transition.prev_state {
|
||||
target.with_origin(off, &|target| {
|
||||
self.store.render_cloned(target);
|
||||
});
|
||||
} else {
|
||||
target.with_origin(off, &|target| {
|
||||
self.render_state(transition.prev_state, target);
|
||||
});
|
||||
}
|
||||
target.with_origin(
|
||||
off - transition.direction.as_offset(self.anim_offset),
|
||||
&|target| {
|
||||
self.render_state(self.state, target);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_transition(&mut self, ctx: &mut EventCtx) {
|
||||
if let Some(transition) = &self.transition {
|
||||
if transition.animation.finished(Instant::now()) {
|
||||
self.transition = None;
|
||||
unwrap!(self.store.clone(None)); // Free the clone.
|
||||
|
||||
let msg = self.store.event(self.state.index(), ctx, Event::Attach);
|
||||
assert!(msg.is_none())
|
||||
} else {
|
||||
ctx.request_anim_frame();
|
||||
}
|
||||
ctx.request_paint();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_swipe_child(&mut self, ctx: &mut EventCtx, direction: SwipeDirection) -> Decision<Q> {
|
||||
let i = self.state.index();
|
||||
if self.store.map_swipable(i, |s| s.can_swipe(direction)) {
|
||||
// Before handling the swipe we make a copy of the original state so that we
|
||||
// can render both states in the transition animation.
|
||||
unwrap!(self.store.clone(Some(i)));
|
||||
self.store.map_swipable(i, |s| s.swiped(ctx, direction));
|
||||
Decision::Goto(self.state, direction)
|
||||
} else {
|
||||
Decision::Nothing
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event_child(&mut self, ctx: &mut EventCtx, event: Event) -> Decision<Q> {
|
||||
let msg = self.store.event(self.state.index(), ctx, event);
|
||||
if let Some(msg) = msg {
|
||||
self.state.handle_event(msg)
|
||||
} else {
|
||||
Decision::Nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Q: FlowState, S: FlowStore> Component for SwipeFlow<Q, S> {
|
||||
type Msg = FlowMsg;
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
// Save screen size for slide animation. Once we have reasonable constants trait
|
||||
// this can be set in the constructor.
|
||||
self.anim_offset = bounds.size();
|
||||
|
||||
self.swipe.place(bounds);
|
||||
self.store.place(bounds)
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
// TODO: are there any events we want to send to all? timers perhaps?
|
||||
if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event {
|
||||
self.handle_transition(ctx);
|
||||
}
|
||||
// Ignore events while transition is running.
|
||||
if self.transition.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut decision = Decision::Nothing;
|
||||
if let Some(direction) = self.swipe.event(ctx, event) {
|
||||
decision = self
|
||||
.handle_swipe_child(ctx, direction)
|
||||
.or_else(|| self.state.handle_swipe(direction));
|
||||
}
|
||||
decision = decision.or_else(|| self.handle_event_child(ctx, event));
|
||||
|
||||
match decision {
|
||||
Decision::Nothing => None,
|
||||
Decision::Goto(next_state, direction) => {
|
||||
self.goto(ctx, direction, next_state);
|
||||
None
|
||||
}
|
||||
Decision::Return(msg) => Some(msg),
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self) {}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
if let Some(transition) = &self.transition {
|
||||
self.render_transition(transition, target)
|
||||
} else {
|
||||
self.render_state(self.state, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl<Q: FlowState, S: FlowStore> crate::trace::Trace for SwipeFlow<Q, S> {
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
self.store.trace(self.state.index(), t)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "micropython")]
|
||||
impl<Q: FlowState, S: FlowStore> crate::ui::layout::obj::ComponentMsgObj for SwipeFlow<Q, S> {
|
||||
fn msg_try_into_obj(
|
||||
&self,
|
||||
msg: Self::Msg,
|
||||
) -> Result<crate::micropython::obj::Obj, error::Error> {
|
||||
match msg {
|
||||
FlowMsg::Confirmed => Ok(crate::ui::layout::result::CONFIRMED.as_obj()),
|
||||
FlowMsg::Cancelled => Ok(crate::ui::layout::result::CANCELLED.as_obj()),
|
||||
FlowMsg::Info => Ok(crate::ui::layout::result::INFO.as_obj()),
|
||||
FlowMsg::Choice(i) => {
|
||||
Ok((crate::ui::layout::result::CONFIRMED.as_obj(), i.try_into()?).try_into()?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
pub mod base;
|
||||
mod flow;
|
||||
pub mod page;
|
||||
mod store;
|
||||
|
||||
pub use base::{FlowMsg, FlowState, Swipable};
|
||||
pub use flow::SwipeFlow;
|
||||
pub use page::{IgnoreSwipe, SwipePage};
|
||||
pub use store::{flow_store, FlowStore};
|
@ -0,0 +1,132 @@
|
||||
use crate::ui::{
|
||||
component::{Component, Event, EventCtx, Paginate, SwipeDirection},
|
||||
flow::base::Swipable,
|
||||
geometry::{Axis, Rect},
|
||||
shape::Renderer,
|
||||
};
|
||||
|
||||
/// Allows any implementor of `Paginate` to be part of `Swipable` UI flow.
|
||||
#[derive(Clone)]
|
||||
pub struct SwipePage<T> {
|
||||
inner: T,
|
||||
axis: Axis,
|
||||
pages: usize,
|
||||
current: usize,
|
||||
}
|
||||
|
||||
impl<T> SwipePage<T> {
|
||||
pub fn vertical(inner: T) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
axis: Axis::Vertical,
|
||||
pages: 1,
|
||||
current: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Component + Paginate> Component for SwipePage<T> {
|
||||
type Msg = T::Msg;
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
let result = self.inner.place(bounds);
|
||||
self.pages = self.inner.page_count();
|
||||
result
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
let msg = self.inner.event(ctx, event);
|
||||
msg
|
||||
}
|
||||
|
||||
fn paint(&mut self) {
|
||||
self.inner.paint()
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
self.inner.render(target)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Component + Paginate> Swipable for SwipePage<T> {
|
||||
fn can_swipe(&self, direction: SwipeDirection) -> bool {
|
||||
match (self.axis, direction) {
|
||||
(Axis::Horizontal, SwipeDirection::Up | SwipeDirection::Down) => false,
|
||||
(Axis::Vertical, SwipeDirection::Left | SwipeDirection::Right) => false,
|
||||
(_, SwipeDirection::Left | SwipeDirection::Up) => self.current + 1 < self.pages,
|
||||
(_, SwipeDirection::Right | SwipeDirection::Down) => self.current > 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn swiped(&mut self, ctx: &mut EventCtx, direction: SwipeDirection) {
|
||||
match (self.axis, direction) {
|
||||
(Axis::Horizontal, SwipeDirection::Up | SwipeDirection::Down) => return,
|
||||
(Axis::Vertical, SwipeDirection::Left | SwipeDirection::Right) => return,
|
||||
(_, SwipeDirection::Left | SwipeDirection::Up) => {
|
||||
self.current = (self.current + 1).min(self.pages - 1);
|
||||
}
|
||||
(_, SwipeDirection::Right | SwipeDirection::Down) => {
|
||||
self.current = self.current.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
self.inner.change_page(self.current);
|
||||
ctx.request_paint();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl<T> crate::trace::Trace for SwipePage<T>
|
||||
where
|
||||
T: crate::trace::Trace,
|
||||
{
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
self.inner.trace(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Make any component swipable by ignoring all swipe events.
|
||||
#[derive(Clone)]
|
||||
pub struct IgnoreSwipe<T>(T);
|
||||
|
||||
impl<T> IgnoreSwipe<T> {
|
||||
pub fn new(inner: T) -> Self {
|
||||
IgnoreSwipe(inner)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Component> Component for IgnoreSwipe<T> {
|
||||
type Msg = T::Msg;
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
self.0.place(bounds)
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
self.0.event(ctx, event)
|
||||
}
|
||||
|
||||
fn paint(&mut self) {
|
||||
self.0.paint()
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
self.0.render(target)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Swipable for IgnoreSwipe<T> {
|
||||
fn can_swipe(&self, _direction: SwipeDirection) -> bool {
|
||||
false
|
||||
}
|
||||
fn swiped(&mut self, _ctx: &mut EventCtx, _direction: SwipeDirection) {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl<T> crate::trace::Trace for IgnoreSwipe<T>
|
||||
where
|
||||
T: crate::trace::Trace,
|
||||
{
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
self.0.trace(t)
|
||||
}
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
use crate::{
|
||||
error,
|
||||
maybe_trace::MaybeTrace,
|
||||
ui::{
|
||||
component::{Component, Event, EventCtx},
|
||||
flow::base::{FlowMsg, Swipable},
|
||||
geometry::Rect,
|
||||
shape::Renderer,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::micropython::gc::Gc;
|
||||
|
||||
/// `FlowStore` is essentially `Vec<Gc<dyn Component + Swipable>>` except that
|
||||
/// `trait Component` is not object-safe so it ends up being a kind of
|
||||
/// recursively-defined tuple.
|
||||
///
|
||||
/// Additionally the store makes it possible to make a clone of one of its
|
||||
/// items, in order to make it possible to render transition animations.
|
||||
pub trait FlowStore {
|
||||
/// Call `Component::place` on all elements.
|
||||
fn place(&mut self, bounds: Rect) -> Rect;
|
||||
|
||||
/// Call `Component::event` on i-th element, if it emits a message it is
|
||||
/// converted to `FlowMsg` using a function.
|
||||
fn event(&mut self, i: usize, ctx: &mut EventCtx, event: Event) -> Option<FlowMsg>;
|
||||
|
||||
/// Call `Component::render` on i-th element.
|
||||
fn render<'s>(&'s self, i: usize, target: &mut impl Renderer<'s>);
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
/// Call `Trace::trace` on i-th element.
|
||||
fn trace(&self, i: usize, t: &mut dyn crate::trace::Tracer);
|
||||
|
||||
/// Forward `Swipable` methods to i-th element.
|
||||
fn map_swipable<T>(&mut self, i: usize, func: impl FnOnce(&mut dyn Swipable) -> T) -> T;
|
||||
|
||||
/// Make a clone of i-th element, or free all clones if None is given.
|
||||
fn clone(&mut self, i: Option<usize>) -> Result<(), error::Error>;
|
||||
|
||||
/// Call `Component::render` on the cloned element.
|
||||
fn render_cloned<'s>(&'s self, target: &mut impl Renderer<'s>);
|
||||
|
||||
/// Add a Component to the end of a `FlowStore`.
|
||||
fn add<E: Component + MaybeTrace + Swipable + Clone>(
|
||||
self,
|
||||
elem: E,
|
||||
func: fn(E::Msg) -> Option<FlowMsg>,
|
||||
) -> Result<impl FlowStore, error::Error>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
/// Create new empty flow store.
|
||||
pub fn flow_store() -> impl FlowStore {
|
||||
FlowEmpty {}
|
||||
}
|
||||
|
||||
/// Terminating element of a recursive structure.
|
||||
struct FlowEmpty;
|
||||
|
||||
// Methods that take an index panic because it's always out of bounds.
|
||||
impl FlowStore for FlowEmpty {
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
bounds
|
||||
}
|
||||
|
||||
fn event(&mut self, _i: usize, _ctx: &mut EventCtx, _event: Event) -> Option<FlowMsg> {
|
||||
panic!()
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, _i: usize, _target: &mut impl Renderer<'s>) {
|
||||
panic!()
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
fn trace(&self, _i: usize, _t: &mut dyn crate::trace::Tracer) {
|
||||
panic!()
|
||||
}
|
||||
|
||||
fn map_swipable<T>(&mut self, _i: usize, _func: impl FnOnce(&mut dyn Swipable) -> T) -> T {
|
||||
panic!()
|
||||
}
|
||||
|
||||
fn clone(&mut self, _i: Option<usize>) -> Result<(), error::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn render_cloned<'s>(&'s self, _target: &mut impl Renderer<'s>) {}
|
||||
|
||||
fn add<E: Component + MaybeTrace + Swipable + Clone>(
|
||||
self,
|
||||
elem: E,
|
||||
func: fn(E::Msg) -> Option<FlowMsg>,
|
||||
) -> Result<impl FlowStore, error::Error>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Ok(FlowComponent {
|
||||
elem: Gc::new(elem)?,
|
||||
func,
|
||||
cloned: None,
|
||||
next: Self,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FlowComponent<E: Component, P> {
|
||||
/// Component allocated on micropython heap.
|
||||
pub elem: Gc<E>,
|
||||
|
||||
/// Clone.
|
||||
pub cloned: Option<Gc<E>>,
|
||||
|
||||
/// Function to convert message to `FlowMsg`.
|
||||
pub func: fn(E::Msg) -> Option<FlowMsg>,
|
||||
|
||||
/// Nested FlowStore.
|
||||
pub next: P,
|
||||
}
|
||||
|
||||
impl<E: Component, P> FlowComponent<E, P> {
|
||||
fn as_ref(&self) -> &E {
|
||||
&self.elem
|
||||
}
|
||||
|
||||
fn as_mut(&mut self) -> &mut E {
|
||||
// SAFETY: micropython can only access this object through LayoutObj which wraps
|
||||
// us in a RefCell which guarantees uniqueness
|
||||
unsafe { Gc::as_mut(&mut self.elem) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<E, P> FlowStore for FlowComponent<E, P>
|
||||
where
|
||||
E: Component + MaybeTrace + Swipable + Clone,
|
||||
P: FlowStore,
|
||||
{
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
self.as_mut().place(bounds);
|
||||
self.next.place(bounds);
|
||||
bounds
|
||||
}
|
||||
|
||||
fn event(&mut self, i: usize, ctx: &mut EventCtx, event: Event) -> Option<FlowMsg> {
|
||||
if i == 0 {
|
||||
let msg = self.as_mut().event(ctx, event);
|
||||
msg.and_then(self.func)
|
||||
} else {
|
||||
self.next.event(i - 1, ctx, event)
|
||||
}
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, i: usize, target: &mut impl Renderer<'s>) {
|
||||
if i == 0 {
|
||||
self.as_ref().render(target)
|
||||
} else {
|
||||
self.next.render(i - 1, target)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
fn trace(&self, i: usize, t: &mut dyn crate::trace::Tracer) {
|
||||
if i == 0 {
|
||||
self.as_ref().trace(t)
|
||||
} else {
|
||||
self.next.trace(i - 1, t)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_swipable<T>(&mut self, i: usize, func: impl FnOnce(&mut dyn Swipable) -> T) -> T {
|
||||
if i == 0 {
|
||||
func(self.as_mut())
|
||||
} else {
|
||||
self.next.map_swipable(i - 1, func)
|
||||
}
|
||||
}
|
||||
|
||||
fn clone(&mut self, i: Option<usize>) -> Result<(), error::Error> {
|
||||
match i {
|
||||
None => {
|
||||
// FIXME: how to ensure the allocation is returned?
|
||||
self.cloned = None;
|
||||
self.next.clone(None)?
|
||||
}
|
||||
Some(0) => {
|
||||
self.cloned = Some(Gc::new(self.as_ref().clone())?);
|
||||
self.next.clone(None)?
|
||||
}
|
||||
Some(i) => {
|
||||
self.cloned = None;
|
||||
self.next.clone(Some(i - 1))?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_cloned<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
if let Some(cloned) = &self.cloned {
|
||||
cloned.render(target)
|
||||
}
|
||||
self.next.render_cloned(target);
|
||||
}
|
||||
|
||||
fn add<F: Component + MaybeTrace + Swipable + Clone>(
|
||||
self,
|
||||
elem: F,
|
||||
func: fn(F::Msg) -> Option<FlowMsg>,
|
||||
) -> Result<impl FlowStore, error::Error>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Ok(FlowComponent {
|
||||
elem: self.elem,
|
||||
func: self.func,
|
||||
cloned: None,
|
||||
next: self.next.add(elem, func)?,
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,205 @@
|
||||
use heapless::Vec;
|
||||
|
||||
use crate::{
|
||||
error::Error,
|
||||
strutil::TString,
|
||||
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 {
|
||||
qr_code: Frame<Qr>,
|
||||
details: Frame<Paragraphs<ParagraphVecShort<'static>>>,
|
||||
xpub_view: Frame<Paragraphs<Paragraph<'static>>>,
|
||||
xpubs: Vec<(TString<'static>, TString<'static>), MAX_XPUBS>,
|
||||
xpub_page_count: Vec<u8, MAX_XPUBS>,
|
||||
current_page: usize,
|
||||
}
|
||||
|
||||
impl AddressDetails {
|
||||
pub fn new(
|
||||
qr_title: TString<'static>,
|
||||
qr_address: TString<'static>,
|
||||
case_sensitive: bool,
|
||||
details_title: TString<'static>,
|
||||
account: Option<TString<'static>>,
|
||||
path: Option<TString<'static>>,
|
||||
) -> Result<Self, Error> {
|
||||
let mut para = ParagraphVecShort::new();
|
||||
if let Some(a) = account {
|
||||
para.add(Paragraph::new(
|
||||
&theme::TEXT_NORMAL,
|
||||
TR::words__account_colon,
|
||||
));
|
||||
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,
|
||||
));
|
||||
para.add(Paragraph::new(&theme::TEXT_MONO, p));
|
||||
}
|
||||
let result = Self {
|
||||
qr_code: Frame::left_aligned(
|
||||
qr_title,
|
||||
qr_address
|
||||
.map(|s| Qr::new(s, case_sensitive))?
|
||||
.with_border(7),
|
||||
)
|
||||
.with_cancel_button()
|
||||
.with_border(theme::borders_horizontal_scroll()),
|
||||
details: Frame::left_aligned(
|
||||
details_title,
|
||||
para.into_paragraphs(),
|
||||
)
|
||||
.with_cancel_button()
|
||||
.with_border(theme::borders_horizontal_scroll()),
|
||||
xpub_view: Frame::left_aligned(
|
||||
" \n ".into(),
|
||||
Paragraph::new(&theme::TEXT_MONO, "").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: TString<'static>,
|
||||
xpub: TString<'static>,
|
||||
) -> Result<(), Error> {
|
||||
self.xpubs
|
||||
.push((title, xpub))
|
||||
.map_err(|_| Error::OutOfRange)
|
||||
}
|
||||
|
||||
fn switch_xpub(&mut self, i: usize, page: usize) -> usize {
|
||||
// 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);
|
||||
self.xpub_view.update_content(&mut dummy_ctx, |_ctx, p| {
|
||||
p.inner_mut().update(self.xpubs[i].1);
|
||||
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 Paginate for AddressDetails {
|
||||
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 Component for AddressDetails {
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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 crate::trace::Trace for AddressDetails {
|
||||
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,172 @@
|
||||
use core::mem;
|
||||
|
||||
use crate::{
|
||||
error::Error,
|
||||
maybe_trace::MaybeTrace,
|
||||
strutil::TString,
|
||||
translations::TR,
|
||||
ui::{
|
||||
component::{
|
||||
base::Never, Bar, Child, Component, ComponentExt, Empty, Event, EventCtx, Label, Split,
|
||||
},
|
||||
display::loader::{loader_circular_uncompress, LoaderDimensions},
|
||||
geometry::{Insets, Offset, Rect},
|
||||
model_mercury::constant,
|
||||
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<U> {
|
||||
value: u16,
|
||||
indeterminate: bool,
|
||||
content: Child<Frame<Split<Empty, U>>>,
|
||||
// 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<'static>,
|
||||
}
|
||||
|
||||
impl<U> CoinJoinProgress<U> {
|
||||
pub fn new(
|
||||
text: TString<'static>,
|
||||
indeterminate: bool,
|
||||
) -> Result<CoinJoinProgress<impl Component<Msg = Never> + MaybeTrace>, Error> {
|
||||
let style = theme::label_coinjoin_progress();
|
||||
let label = Label::centered(TR::coinjoin__title_do_not_disconnect.into(), style)
|
||||
.vertically_centered();
|
||||
let bg = Bar::new(style.background_color, theme::BG, 2);
|
||||
let inner = (bg, label);
|
||||
CoinJoinProgress::with_background(text, inner, indeterminate)
|
||||
}
|
||||
}
|
||||
|
||||
impl<U> CoinJoinProgress<U>
|
||||
where
|
||||
U: Component<Msg = Never>,
|
||||
{
|
||||
pub fn with_background(
|
||||
text: TString<'static>,
|
||||
inner: U,
|
||||
indeterminate: bool,
|
||||
) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
value: 0,
|
||||
indeterminate,
|
||||
content: Frame::centered(
|
||||
TR::coinjoin__title_progress.into(),
|
||||
Split::bottom(RECTANGLE_HEIGHT, 0, Empty, inner),
|
||||
)
|
||||
.into_child(),
|
||||
label: Label::centered(text, theme::TEXT_NORMAL),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<U> Component for CoinJoinProgress<U>
|
||||
where
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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 as i32 - 100) % 1000;
|
||||
let end = (self.value as i32 + 100) % 1000;
|
||||
let start = ((start * 8 * shape::PI4 as i32) / 1000) as i16;
|
||||
let end = ((end * 8 * shape::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<U> crate::trace::Trace for CoinJoinProgress<U>
|
||||
where
|
||||
U: Component + crate::trace::Trace,
|
||||
{
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("CoinJoinProgress");
|
||||
t.child("label", &self.label);
|
||||
t.child("content", &self.content);
|
||||
}
|
||||
}
|
@ -0,0 +1,234 @@
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IconDialog<U> {
|
||||
image: Child<BlendedImage>,
|
||||
paragraphs: Paragraphs<ParagraphVecShort<'static>>,
|
||||
controls: Child<U>,
|
||||
}
|
||||
|
||||
impl<U> IconDialog<U>
|
||||
where
|
||||
U: Component,
|
||||
{
|
||||
pub fn new(icon: BlendedImage, title: impl Into<TString<'static>>, 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<'static>) -> Self {
|
||||
if !para.content().is_empty() {
|
||||
self.paragraphs.inner_mut().add(para);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_text(self, style: &'static TextStyle, text: impl Into<TString<'static>>) -> Self {
|
||||
self.with_paragraph(Paragraph::new(style, text).centered())
|
||||
}
|
||||
|
||||
pub fn with_description(self, description: impl Into<TString<'static>>) -> Self {
|
||||
self.with_text(&theme::TEXT_NORMAL_OFF_WHITE, description)
|
||||
}
|
||||
|
||||
pub fn with_value(self, value: impl Into<TString<'static>>) -> Self {
|
||||
self.with_text(&theme::TEXT_MONO, value)
|
||||
}
|
||||
|
||||
pub fn new_shares(lines: [impl Into<TString<'static>>; 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<U> Component for IconDialog<U>
|
||||
where
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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<U> crate::trace::Trace for IconDialog<U>
|
||||
where
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "micropython")]
|
||||
impl<U> crate::ui::flow::Swipable for IconDialog<U> {}
|
@ -0,0 +1,262 @@
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
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;
|
||||
use core::cell::Cell;
|
||||
|
||||
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) -> TString<'static>, U> {
|
||||
page_swipe: Swipe,
|
||||
app_name: Label<'static>,
|
||||
account_name: Label<'static>,
|
||||
icon: Child<Image>,
|
||||
/// Function/closure that will return appropriate page on demand.
|
||||
get_account: F,
|
||||
scrollbar: ScrollBar,
|
||||
fade: Cell<bool>,
|
||||
controls: U,
|
||||
}
|
||||
|
||||
impl<F, U> FidoConfirm<F, U>
|
||||
where
|
||||
F: Fn(usize) -> TString<'static>,
|
||||
U: Component<Msg = CancelConfirmMsg>,
|
||||
{
|
||||
pub fn new(
|
||||
app_name: TString<'static>,
|
||||
get_account: F,
|
||||
page_count: usize,
|
||||
icon_name: Option<TString<'static>>,
|
||||
controls: U,
|
||||
) -> Self {
|
||||
let icon_data = get_fido_icon_data(icon_name);
|
||||
|
||||
// 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();
|
||||
|
||||
// NOTE: This is an ugly hotfix for the erroneous behavior of
|
||||
// TextLayout used in the account_name Label. In this
|
||||
// particular case, TextLayout calculates the wrong height of
|
||||
// fitted text that's higher than the TextLayout bound itself.
|
||||
//
|
||||
// The following two lines should be swapped when the problem with
|
||||
// TextLayout is fixed.
|
||||
//
|
||||
// See also, continuation of this hotfix in the place() function.
|
||||
|
||||
// let current_account = get_account(scrollbar.active_page);
|
||||
let current_account = "".into();
|
||||
|
||||
Self {
|
||||
app_name: Label::centered(app_name, theme::TEXT_DEMIBOLD),
|
||||
account_name: Label::centered(current_account, theme::TEXT_DEMIBOLD),
|
||||
page_swipe,
|
||||
icon: Child::new(Image::new(icon_data)),
|
||||
get_account,
|
||||
scrollbar,
|
||||
fade: Cell::new(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();
|
||||
|
||||
let current_account = (self.get_account)(self.active_page());
|
||||
self.account_name.set_text(current_account);
|
||||
|
||||
// Redraw the page.
|
||||
ctx.request_paint();
|
||||
|
||||
// Reset backlight to normal level on next paint.
|
||||
self.fade.set(true);
|
||||
}
|
||||
|
||||
fn active_page(&self) -> usize {
|
||||
self.scrollbar.active_page
|
||||
}
|
||||
}
|
||||
|
||||
impl<F, U> Component for FidoConfirm<F, U>
|
||||
where
|
||||
F: Fn(usize) -> TString<'static>,
|
||||
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);
|
||||
|
||||
// NOTE: This is a hotfix used due to the erroneous behavior of TextLayout.
|
||||
// This line should be removed when the problem with TextLayout is fixed.
|
||||
// See also the code for FidoConfirm::new().
|
||||
self.account_name
|
||||
.set_text((self.get_account)(self.scrollbar.active_page));
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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.)
|
||||
let account_name = self.account_name.text();
|
||||
let app_name = self.app_name.text();
|
||||
if !account_name.is_empty() && account_name != app_name {
|
||||
self.account_name.paint();
|
||||
}
|
||||
|
||||
if self.fade.take() {
|
||||
// Note that this is blocking and takes some time.
|
||||
display::fade_backlight(theme::BACKLIGHT_NORMAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
self.icon.render(target);
|
||||
self.controls.render(target);
|
||||
self.app_name.render(target);
|
||||
|
||||
if self.scrollbar.page_count > 1 {
|
||||
self.scrollbar.render(target);
|
||||
}
|
||||
|
||||
// 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.)
|
||||
let account_name = self.account_name.text();
|
||||
let app_name = self.app_name.text();
|
||||
if !account_name.is_empty() && account_name != app_name {
|
||||
self.account_name.render(target);
|
||||
}
|
||||
|
||||
if self.fade.take() {
|
||||
// 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> crate::trace::Trace for FidoConfirm<F, T>
|
||||
where
|
||||
F: Fn(usize) -> TString<'static>,
|
||||
{
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("FidoConfirm");
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
//! generated from webauthn_icons.rs.mako
|
||||
//! (by running `make templates` in `core`)
|
||||
//! do not edit manually!
|
||||
|
||||
|
||||
use crate::strutil::TString;
|
||||
|
||||
|
||||
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(icon_name: Option<TString<'static>>) -> &'static [u8] {
|
||||
if let Some(icon_name) = icon_name {
|
||||
icon_name.map(|c| match c {
|
||||
"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,38 @@
|
||||
//! generated from webauthn_icons.rs.mako
|
||||
//! (by running `make templates` in `core`)
|
||||
//! do not edit manually!
|
||||
|
||||
|
||||
use crate::strutil::TString;
|
||||
|
||||
<%
|
||||
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(icon_name: Option<TString<'static>>) -> &'static [u8] {
|
||||
if let Some(icon_name) = icon_name {
|
||||
icon_name.map(|c| match c {
|
||||
% for icon_name, var_name in icons:
|
||||
"${icon_name}" => ICON_${var_name},
|
||||
% endfor
|
||||
_ => ICON_WEBAUTHN,
|
||||
})
|
||||
} else {
|
||||
ICON_WEBAUTHN
|
||||
}
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
ui::{
|
||||
component::{text::TextStyle, Component, Event, EventCtx, Never},
|
||||
geometry::{Alignment, Offset, Rect},
|
||||
model_mercury::theme,
|
||||
shape::{Renderer, Text},
|
||||
},
|
||||
};
|
||||
|
||||
/// Component showing a task instruction (e.g. "Swipe up") and optionally task
|
||||
/// description (e.g. "Confirm transaction") to a user. A host of this component
|
||||
/// is responsible of providing the exact area considering also the spacing. The
|
||||
/// height must be 18px (only instruction) or 37px (both description and
|
||||
/// instruction). The content and style of both description and instruction is
|
||||
/// configurable separatedly.
|
||||
#[derive(Clone)]
|
||||
pub struct Footer<'a> {
|
||||
area: Rect,
|
||||
text_instruction: TString<'a>,
|
||||
text_description: Option<TString<'a>>,
|
||||
style_instruction: &'static TextStyle,
|
||||
style_description: &'static TextStyle,
|
||||
}
|
||||
|
||||
impl<'a> Footer<'a> {
|
||||
/// height of the component with only instruction [px]
|
||||
pub const HEIGHT_SIMPLE: i16 = 18;
|
||||
/// height of the component with both description and instruction [px]
|
||||
pub const HEIGHT_DEFAULT: i16 = 37;
|
||||
|
||||
pub fn new<T: Into<TString<'a>>>(instruction: T) -> Self {
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
text_instruction: instruction.into(),
|
||||
text_description: None,
|
||||
style_instruction: &theme::TEXT_SUB_GREY,
|
||||
style_description: &theme::TEXT_SUB_GREY_LIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_description<T: Into<TString<'a>>>(self, description: T) -> Self {
|
||||
Self {
|
||||
text_description: Some(description.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_instruction<T: Into<TString<'a>>>(&mut self, ctx: &mut EventCtx, s: T) {
|
||||
self.text_instruction = s.into();
|
||||
ctx.request_paint();
|
||||
}
|
||||
|
||||
pub fn update_description<T: Into<TString<'a>>>(&mut self, ctx: &mut EventCtx, s: T) {
|
||||
self.text_description = Some(s.into());
|
||||
ctx.request_paint();
|
||||
}
|
||||
|
||||
pub fn update_instruction_style(&mut self, ctx: &mut EventCtx, style: &'static TextStyle) {
|
||||
self.style_instruction = style;
|
||||
ctx.request_paint();
|
||||
}
|
||||
|
||||
pub fn update_description_style(&mut self, ctx: &mut EventCtx, style: &'static TextStyle) {
|
||||
self.style_description = style;
|
||||
ctx.request_paint();
|
||||
}
|
||||
|
||||
pub fn height(&self) -> i16 {
|
||||
if self.text_description.is_some() {
|
||||
Footer::HEIGHT_DEFAULT
|
||||
} else {
|
||||
Footer::HEIGHT_SIMPLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Component for Footer<'a> {
|
||||
type Msg = Never;
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
let h = bounds.height();
|
||||
assert!(h == Footer::HEIGHT_SIMPLE || h == Footer::HEIGHT_DEFAULT);
|
||||
self.area = bounds;
|
||||
bounds
|
||||
}
|
||||
|
||||
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
|
||||
None
|
||||
}
|
||||
|
||||
fn paint(&mut self) {
|
||||
// TODO: remove when ui-t3t1 done
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
// show description only if there is space for it
|
||||
if self.area.height() == Footer::HEIGHT_DEFAULT {
|
||||
if let Some(description) = self.text_description {
|
||||
let area_description = self.area.split_top(Footer::HEIGHT_SIMPLE).0;
|
||||
let text_description_font_descent = self
|
||||
.style_description
|
||||
.text_font
|
||||
.visible_text_height_ex("Ay")
|
||||
.1;
|
||||
let text_description_baseline =
|
||||
area_description.bottom_center() - Offset::y(text_description_font_descent);
|
||||
|
||||
description.map(|t| {
|
||||
Text::new(text_description_baseline, t)
|
||||
.with_font(self.style_description.text_font)
|
||||
.with_fg(self.style_description.text_color)
|
||||
.with_align(Alignment::Center)
|
||||
.render(target);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let area_instruction = self.area.split_bottom(Footer::HEIGHT_SIMPLE).1;
|
||||
let text_instruction_font_descent = self
|
||||
.style_instruction
|
||||
.text_font
|
||||
.visible_text_height_ex("Ay")
|
||||
.1;
|
||||
let text_instruction_baseline =
|
||||
area_instruction.bottom_center() - Offset::y(text_instruction_font_descent);
|
||||
self.text_instruction.map(|t| {
|
||||
Text::new(text_instruction_baseline, t)
|
||||
.with_font(self.style_instruction.text_font)
|
||||
.with_fg(self.style_instruction.text_color)
|
||||
.with_align(Alignment::Center)
|
||||
.render(target);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_bounds")]
|
||||
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
|
||||
sink(self.area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl crate::trace::Trace for Footer<'_> {
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("Footer");
|
||||
if let Some(description) = self.text_description {
|
||||
t.string("description", description);
|
||||
}
|
||||
t.string("instruction", self.text_instruction);
|
||||
}
|
||||
}
|
@ -0,0 +1,605 @@
|
||||
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::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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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) => img.as_ref(),
|
||||
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 r = Rect::new(Point::new(6, 198), Point::new(234, 233));
|
||||
shape::Bar::new(r)
|
||||
.with_bg(Color::black())
|
||||
.with_alpha(89)
|
||||
.with_radius(3)
|
||||
.render(target);
|
||||
|
||||
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<'a> {
|
||||
label: TString<'a>,
|
||||
custom_image: Option<Gc<[u8]>>,
|
||||
bootscreen: bool,
|
||||
coinjoin_authorized: bool,
|
||||
}
|
||||
|
||||
impl<'a> Lockscreen<'a> {
|
||||
pub fn new(label: TString<'a>, 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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
let img_data = match self.custom_image {
|
||||
Some(ref img) => img.as_ref(),
|
||||
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(140)
|
||||
.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) && crate::ui::display::tjpgd::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];
|
||||
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];
|
||||
self.totals.buffer[RED_IDX][i] -= lines[self.rem_idx][RED_IDX][i];
|
||||
self.totals.buffer[GREEN_IDX][i] -= lines[self.rem_idx][GREEN_IDX][i];
|
||||
self.totals.buffer[BLUE_IDX][i] -= lines[self.rem_idx][BLUE_IDX][i];
|
||||
}
|
||||
}
|
||||
|
||||
fn inc_add(&mut self) {
|
||||
self.add_idx += 1;
|
||||
if self.add_idx >= DECOMP_LINES {
|
||||
self.add_idx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn inc_rem(&mut self) {
|
||||
self.rem_idx += 1;
|
||||
if self.rem_idx >= DECOMP_LINES {
|
||||
self.rem_idx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn get_line_num(&self) -> i16 {
|
||||
self.line_num
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn get_data(buffer: &mut BufferJpeg, line_num: i16, mcu_height: i16) -> &mut [u16] {
|
||||
let data_start = ((line_num % mcu_height) * WIDTH) as usize;
|
||||
let data_end = (((line_num % mcu_height) + 1) * WIDTH) as usize;
|
||||
&mut buffer.buffer[data_start..data_end]
|
||||
}
|
||||
|
||||
pub fn homescreen_blurred(data: &mut dyn HomescreenDecompressor, texts: &[HomescreenText]) {
|
||||
let mut icon_data = [0_u8; (HOMESCREEN_MAX_ICON_SIZE * HOMESCREEN_MAX_ICON_SIZE / 2) as usize];
|
||||
|
||||
let mut text_buffer = BufferText::get_cleared();
|
||||
let mut fg_buffer_0 = BufferLine4bpp::get_cleared();
|
||||
let mut img_buffer_0 = BufferLine16bpp::get_cleared();
|
||||
let mut fg_buffer_1 = BufferLine4bpp::get_cleared();
|
||||
let mut img_buffer_1 = BufferLine16bpp::get_cleared();
|
||||
|
||||
let mut next_text_idx = 1;
|
||||
let mut text_info =
|
||||
homescreen_position_text(unwrap!(texts.first()), &mut text_buffer, &mut icon_data);
|
||||
|
||||
let mcu_height = data.get_height();
|
||||
data.decompress();
|
||||
|
||||
set_window(screen());
|
||||
|
||||
let mut blurring = BlurringContext::new();
|
||||
|
||||
// handling top edge case: preload the edge value N+1 times
|
||||
blurring.compute_line_avgs(data.get_data(), mcu_height);
|
||||
|
||||
for _ in 0..=BLUR_RADIUS {
|
||||
blurring.vertical_avg_add();
|
||||
}
|
||||
blurring.inc_add();
|
||||
|
||||
// load enough values to be able to compute first line averages
|
||||
for _ in 0..BLUR_RADIUS {
|
||||
blurring.compute_line_avgs(data.get_data(), mcu_height);
|
||||
blurring.vertical_avg_add();
|
||||
blurring.inc_add();
|
||||
|
||||
if (blurring.get_line_num() % mcu_height) == 0 {
|
||||
data.decompress();
|
||||
}
|
||||
}
|
||||
|
||||
for y in 0..HEIGHT {
|
||||
// several lines have been already decompressed before this loop, adjust for
|
||||
// that
|
||||
if y < HOMESCREEN_IMAGE_HEIGHT - (BLUR_RADIUS + 1) {
|
||||
blurring.compute_line_avgs(data.get_data(), mcu_height);
|
||||
}
|
||||
|
||||
let done = if y % 2 == 0 {
|
||||
homescreen_line_blurred(
|
||||
&icon_data,
|
||||
&mut text_buffer,
|
||||
&mut fg_buffer_0,
|
||||
&mut img_buffer_0,
|
||||
text_info,
|
||||
&blurring,
|
||||
y,
|
||||
)
|
||||
} else {
|
||||
homescreen_line_blurred(
|
||||
&icon_data,
|
||||
&mut text_buffer,
|
||||
&mut fg_buffer_1,
|
||||
&mut img_buffer_1,
|
||||
text_info,
|
||||
&blurring,
|
||||
y,
|
||||
)
|
||||
};
|
||||
|
||||
if done {
|
||||
(text_info, next_text_idx) = homescreen_next_text(
|
||||
texts,
|
||||
&mut text_buffer,
|
||||
&mut icon_data,
|
||||
text_info,
|
||||
next_text_idx,
|
||||
);
|
||||
}
|
||||
|
||||
blurring.vertical_avg();
|
||||
|
||||
// handling bottom edge case: stop incrementing counter, adding the edge value
|
||||
// for the rest of image
|
||||
// the extra -1 is to indicate that this was the last decompressed line,
|
||||
// in the next pass the docompression and compute_line_avgs won't happen
|
||||
if y < HOMESCREEN_IMAGE_HEIGHT - (BLUR_RADIUS + 1) - 1 {
|
||||
blurring.inc_add();
|
||||
}
|
||||
|
||||
if y == HOMESCREEN_IMAGE_HEIGHT {
|
||||
// reached end of image, clear avgs (display black)
|
||||
blurring.clear();
|
||||
}
|
||||
|
||||
// only start incrementing remove index when enough lines have been loaded
|
||||
if y >= (BLUR_RADIUS) {
|
||||
blurring.inc_rem();
|
||||
}
|
||||
|
||||
if (blurring.get_line_num() % mcu_height) == 0 && (blurring.get_line_num() < HEIGHT) {
|
||||
data.decompress();
|
||||
}
|
||||
}
|
||||
dma2d_wait_for_transfer();
|
||||
}
|
||||
|
||||
pub fn homescreen(
|
||||
data: &mut dyn HomescreenDecompressor,
|
||||
texts: &[HomescreenText],
|
||||
notification: Option<HomescreenNotification>,
|
||||
notification_only: bool,
|
||||
) {
|
||||
let mut icon_data = [0_u8; (HOMESCREEN_MAX_ICON_SIZE * HOMESCREEN_MAX_ICON_SIZE / 2) as usize];
|
||||
|
||||
let mut text_buffer = BufferText::get_cleared();
|
||||
let mut fg_buffer_0 = BufferLine4bpp::get_cleared();
|
||||
let mut img_buffer_0 = BufferLine16bpp::get_cleared();
|
||||
let mut fg_buffer_1 = BufferLine4bpp::get_cleared();
|
||||
let mut img_buffer_1 = BufferLine16bpp::get_cleared();
|
||||
|
||||
let mut next_text_idx = 0;
|
||||
let mut text_info = if let Some(notification) = notification {
|
||||
rect_fill_rounded_buffer(
|
||||
Rect::from_top_left_and_size(
|
||||
Point::new(NOTIFICATION_BORDER, 0),
|
||||
Offset::new(WIDTH - NOTIFICATION_BORDER * 2, NOTIFICATION_HEIGHT),
|
||||
),
|
||||
2,
|
||||
&mut text_buffer,
|
||||
);
|
||||
let area = Rect::new(
|
||||
Point::new(0, NOTIFICATION_BORDER),
|
||||
Point::new(WIDTH, NOTIFICATION_HEIGHT + NOTIFICATION_BORDER),
|
||||
);
|
||||
HomescreenTextInfo {
|
||||
text_area: area,
|
||||
text_width: WIDTH,
|
||||
text_color: notification.color,
|
||||
icon_area: None,
|
||||
}
|
||||
} else {
|
||||
next_text_idx += 1;
|
||||
homescreen_position_text(unwrap!(texts.first()), &mut text_buffer, &mut icon_data)
|
||||
};
|
||||
|
||||
set_window(screen());
|
||||
|
||||
let mcu_height = data.get_height();
|
||||
|
||||
for y in 0..HEIGHT {
|
||||
if (y % mcu_height) == 0 {
|
||||
data.decompress();
|
||||
}
|
||||
|
||||
let done = if y % 2 == 0 {
|
||||
homescreen_line(
|
||||
&icon_data,
|
||||
&mut text_buffer,
|
||||
text_info,
|
||||
data.get_data(),
|
||||
&mut fg_buffer_0,
|
||||
&mut img_buffer_0,
|
||||
mcu_height,
|
||||
y,
|
||||
)
|
||||
} else {
|
||||
homescreen_line(
|
||||
&icon_data,
|
||||
&mut text_buffer,
|
||||
text_info,
|
||||
data.get_data(),
|
||||
&mut fg_buffer_1,
|
||||
&mut img_buffer_1,
|
||||
mcu_height,
|
||||
y,
|
||||
)
|
||||
};
|
||||
|
||||
if done {
|
||||
if notification.is_some() && next_text_idx == 0 {
|
||||
//finished notification area, let interrupt and draw the text
|
||||
let notification = unwrap!(notification);
|
||||
|
||||
let style = TextStyle {
|
||||
background_color: notification.color,
|
||||
..theme::TEXT_BOLD
|
||||
};
|
||||
|
||||
dma2d_wait_for_transfer();
|
||||
|
||||
drop(fg_buffer_0);
|
||||
drop(fg_buffer_1);
|
||||
|
||||
icon_text_center(
|
||||
text_info.text_area.center(),
|
||||
notification.icon,
|
||||
8,
|
||||
notification.text,
|
||||
style,
|
||||
Offset::new(1, -2),
|
||||
);
|
||||
|
||||
fg_buffer_0 = BufferLine4bpp::get_cleared();
|
||||
fg_buffer_1 = BufferLine4bpp::get_cleared();
|
||||
|
||||
set_window(
|
||||
screen()
|
||||
.split_top(NOTIFICATION_HEIGHT + NOTIFICATION_BORDER)
|
||||
.1,
|
||||
);
|
||||
}
|
||||
|
||||
if notification_only && next_text_idx == 0 {
|
||||
dma2d_wait_for_transfer();
|
||||
return;
|
||||
}
|
||||
|
||||
(text_info, next_text_idx) = homescreen_next_text(
|
||||
texts,
|
||||
&mut text_buffer,
|
||||
&mut icon_data,
|
||||
text_info,
|
||||
next_text_idx,
|
||||
);
|
||||
}
|
||||
}
|
||||
dma2d_wait_for_transfer();
|
||||
}
|
@ -0,0 +1,326 @@
|
||||
use crate::{
|
||||
trezorhal::bip39,
|
||||
ui::{
|
||||
component::{text::common::TextBox, Component, Event, EventCtx},
|
||||
display,
|
||||
geometry::{Alignment2D, Offset, Rect},
|
||||
model_mercury::{
|
||||
component::{
|
||||
keyboard::{
|
||||
common::{paint_pending_marker, render_pending_marker, MultiTapKeyboard},
|
||||
mnemonic::{MnemonicInput, MnemonicInputMsg, MNEMONIC_KEY_COUNT},
|
||||
},
|
||||
Button, ButtonContent, ButtonMsg,
|
||||
},
|
||||
theme,
|
||||
},
|
||||
shape,
|
||||
shape::Renderer,
|
||||
},
|
||||
};
|
||||
use heapless::String;
|
||||
|
||||
const MAX_LENGTH: usize = 8;
|
||||
|
||||
pub struct Bip39Input {
|
||||
button: Button<>,
|
||||
// used only to keep track of suggestion text color
|
||||
button_suggestion: Button<>,
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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(unwrap!(String::try_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("".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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<'s>(
|
||||
target: &mut impl Renderer<'s>,
|
||||
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::{
|
||||
strutil::TString,
|
||||
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> {
|
||||
/// Initial prompt, displayed on empty input.
|
||||
prompt: Child<Maybe<Label<'static>>>,
|
||||
/// Backspace button.
|
||||
back: Child<Maybe<Button>>,
|
||||
/// Input area, acting as the auto-complete and confirm button.
|
||||
input: Child<Maybe<T>>,
|
||||
/// Key buttons.
|
||||
keys: [Child<Button>; 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> MnemonicKeyboard<T>
|
||||
where
|
||||
T: MnemonicInput,
|
||||
{
|
||||
pub fn new(input: T, prompt: TString<'static>, 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.into()).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> Component for MnemonicKeyboard<T>
|
||||
where
|
||||
T: MnemonicInput,
|
||||
{
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
self.prompt.render(target);
|
||||
self.input.render(target);
|
||||
self.back.render(target);
|
||||
|
||||
for btn in &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> crate::trace::Trace for MnemonicKeyboard<T>
|
||||
where
|
||||
T: MnemonicInput + crate::trace::Trace,
|
||||
{
|
||||
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,444 @@
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
use core::cell::Cell;
|
||||
|
||||
pub enum PassphraseKeyboardMsg {
|
||||
Confirmed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub struct PassphraseKeyboard {
|
||||
page_swipe: Swipe,
|
||||
input: Child<Input>,
|
||||
back: Child<Button>,
|
||||
confirm: Child<Button>,
|
||||
keys: [Child<Button>; KEY_COUNT],
|
||||
scrollbar: ScrollBar,
|
||||
fade: Cell<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: Cell::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn key_text(content: &ButtonContent) -> TString<'static> {
|
||||
match content {
|
||||
ButtonContent::Text(text) => *text,
|
||||
ButtonContent::Icon(_) => " ".into(),
|
||||
ButtonContent::IconAndText(_) => " ".into(),
|
||||
ButtonContent::Empty => "".into(),
|
||||
ButtonContent::IconBlend(_, _, _) => "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn key_content(text: &'static str) -> ButtonContent {
|
||||
match text {
|
||||
" " => ButtonContent::Icon(theme::ICON_SPACE),
|
||||
t => ButtonContent::Text(t.into()),
|
||||
}
|
||||
}
|
||||
|
||||
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.set(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 = text.map(|c| i.multi_tap.click_key(ctx, key, c));
|
||||
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.take() {
|
||||
// Note that this is blocking and takes some time.
|
||||
display::fade_backlight(theme::BACKLIGHT_NORMAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
self.input.render(target);
|
||||
self.scrollbar.render(target);
|
||||
self.confirm.render(target);
|
||||
self.back.render(target);
|
||||
for btn in &self.keys {
|
||||
btn.render(target);
|
||||
}
|
||||
if self.fade.take() {
|
||||
// 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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
let style = theme::label_keyboard();
|
||||
|
||||
let text_baseline = self.area.top_left() + Offset::y(style.text_font.text_height())
|
||||
- Offset::y(style.text_font.text_baseline());
|
||||
|
||||
let text = self.textbox.content();
|
||||
|
||||
shape::Bar::new(self.area).with_bg(theme::BG).render(target);
|
||||
|
||||
// Find out how much text can fit into the textbox.
|
||||
// Accounting for the pending marker, which draws itself one pixel longer than
|
||||
// the last character
|
||||
let available_area_width = self.area.width() - 1;
|
||||
let text_to_display =
|
||||
long_line_content_with_ellipsis(text, "...", style.text_font, available_area_width);
|
||||
|
||||
shape::Text::new(text_baseline, &text_to_display)
|
||||
.with_font(style.text_font)
|
||||
.with_fg(style.text_color)
|
||||
.render(target);
|
||||
|
||||
// Paint the pending marker.
|
||||
if self.multi_tap.pending_key().is_some() {
|
||||
render_pending_marker(
|
||||
target,
|
||||
text_baseline,
|
||||
&text_to_display,
|
||||
style.text_font,
|
||||
style.text_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_bounds")]
|
||||
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
|
||||
sink(self.area)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl crate::trace::Trace for PassphraseKeyboard {
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("PassphraseKeyboard");
|
||||
t.string("passphrase", self.passphrase().into());
|
||||
}
|
||||
}
|
@ -0,0 +1,568 @@
|
||||
use core::mem;
|
||||
use heapless::String;
|
||||
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
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<'a> {
|
||||
allow_cancel: bool,
|
||||
major_prompt: Child<Label<'a>>,
|
||||
minor_prompt: Child<Label<'a>>,
|
||||
major_warning: Option<Child<Label<'a>>>,
|
||||
textbox: Child<PinDots>,
|
||||
textbox_pad: Pad,
|
||||
erase_btn: Child<Maybe<Button>>,
|
||||
cancel_btn: Child<Maybe<Button>>,
|
||||
confirm_btn: Child<Button>,
|
||||
digit_btns: [Child<Button>; DIGIT_COUNT],
|
||||
warning_timer: Option<TimerToken>,
|
||||
}
|
||||
|
||||
impl<'a> PinKeyboard<'a> {
|
||||
// Label position fine-tuning.
|
||||
const MAJOR_OFF: Offset = Offset::y(11);
|
||||
const MINOR_OFF: Offset = Offset::y(11);
|
||||
|
||||
pub fn new(
|
||||
major_prompt: TString<'a>,
|
||||
minor_prompt: TString<'a>,
|
||||
major_warning: Option<TString<'a>>,
|
||||
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>; 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(|c| Button::with_text(c.into()))
|
||||
.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 Component for PinKeyboard<'_> {
|
||||
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() {
|
||||
text.map(|text| {
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
self.erase_btn.render(target);
|
||||
self.textbox_pad.render(target);
|
||||
if self.textbox.inner().is_empty() {
|
||||
if let Some(ref 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 &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<'s>(&self, area: Rect, target: &mut impl Renderer<'s>) {
|
||||
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<'s>(&self, area: Rect, target: &mut impl Renderer<'s>) {
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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 crate::trace::Trace for PinKeyboard<'_> {
|
||||
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 {
|
||||
text.map(|text| {
|
||||
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,
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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("".into()));
|
||||
}
|
||||
}
|
||||
|
||||
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; NUMBERS.len()],
|
||||
}
|
||||
|
||||
pub enum SelectWordCountMsg {
|
||||
Selected(u32),
|
||||
}
|
||||
|
||||
impl SelectWordCount {
|
||||
pub fn new() -> Self {
|
||||
SelectWordCount {
|
||||
button: LABELS.map(|t| Button::with_text(t.into()).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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
for btn in self.button.iter() {
|
||||
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");
|
||||
}
|
||||
}
|
@ -1,18 +1,76 @@
|
||||
#[cfg(feature = "translations")]
|
||||
mod address_details;
|
||||
pub mod bl_confirm;
|
||||
mod button;
|
||||
#[cfg(feature = "translations")]
|
||||
mod coinjoin_progress;
|
||||
mod dialog;
|
||||
mod fido;
|
||||
mod footer;
|
||||
mod vertical_menu;
|
||||
#[rustfmt::skip]
|
||||
mod fido_icons;
|
||||
mod error;
|
||||
mod frame;
|
||||
#[cfg(feature = "micropython")]
|
||||
mod homescreen;
|
||||
mod keyboard;
|
||||
mod loader;
|
||||
#[cfg(feature = "translations")]
|
||||
mod number_input;
|
||||
#[cfg(feature = "translations")]
|
||||
mod page;
|
||||
mod progress;
|
||||
mod prompt_screen;
|
||||
mod result;
|
||||
mod scroll;
|
||||
#[cfg(feature = "translations")]
|
||||
mod share_words;
|
||||
mod simple_page;
|
||||
mod status_screen;
|
||||
mod swipe;
|
||||
mod swipe_up_screen;
|
||||
mod welcome_screen;
|
||||
|
||||
#[cfg(feature = "translations")]
|
||||
pub use address_details::AddressDetails;
|
||||
pub use button::{
|
||||
Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, CancelInfoConfirmMsg, IconText,
|
||||
Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, CancelConfirmMsg,
|
||||
CancelInfoConfirmMsg, IconText,
|
||||
};
|
||||
#[cfg(feature = "translations")]
|
||||
pub use coinjoin_progress::CoinJoinProgress;
|
||||
pub use dialog::{Dialog, DialogMsg, IconDialog};
|
||||
pub use error::ErrorScreen;
|
||||
pub use fido::{FidoConfirm, FidoMsg};
|
||||
pub use footer::Footer;
|
||||
pub use frame::{Frame, FrameMsg};
|
||||
#[cfg(feature = "micropython")]
|
||||
pub use homescreen::{check_homescreen_format, Homescreen, HomescreenMsg, Lockscreen};
|
||||
pub use keyboard::{
|
||||
bip39::Bip39Input,
|
||||
mnemonic::{MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg},
|
||||
passphrase::{PassphraseKeyboard, PassphraseKeyboardMsg},
|
||||
pin::{PinKeyboard, PinKeyboardMsg},
|
||||
slip39::Slip39Input,
|
||||
word_count::{SelectWordCount, SelectWordCountMsg},
|
||||
};
|
||||
pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet};
|
||||
#[cfg(feature = "translations")]
|
||||
pub use number_input::{NumberInputDialog, NumberInputDialogMsg};
|
||||
#[cfg(feature = "translations")]
|
||||
pub use page::ButtonPage;
|
||||
pub use progress::Progress;
|
||||
pub use prompt_screen::PromptScreen;
|
||||
pub use result::{ResultFooter, ResultScreen, ResultStyle};
|
||||
pub use scroll::ScrollBar;
|
||||
#[cfg(feature = "translations")]
|
||||
pub use share_words::ShareWords;
|
||||
pub use simple_page::SimplePage;
|
||||
pub use status_screen::StatusScreen;
|
||||
pub use swipe::{Swipe, SwipeDirection};
|
||||
pub use swipe_up_screen::{SwipeUpScreen, SwipeUpScreenMsg};
|
||||
pub use vertical_menu::{VerticalMenu, VerticalMenuChoiceMsg};
|
||||
pub use welcome_screen::WelcomeScreen;
|
||||
|
||||
use super::{constant, theme};
|
||||
|
@ -0,0 +1,272 @@
|
||||
use crate::{
|
||||
error::Error,
|
||||
strutil::{self, TString},
|
||||
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<F>
|
||||
where
|
||||
F: Fn(u32) -> TString<'static>,
|
||||
{
|
||||
area: Rect,
|
||||
description_func: F,
|
||||
input: Child<NumberInput>,
|
||||
paragraphs: Child<Paragraphs<Paragraph<'static>>>,
|
||||
paragraphs_pad: Pad,
|
||||
info_button: Child<Button>,
|
||||
confirm_button: Child<Button>,
|
||||
}
|
||||
|
||||
impl<F> NumberInputDialog<F>
|
||||
where
|
||||
F: Fn(u32) -> TString<'static>,
|
||||
{
|
||||
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.into()).into_child(),
|
||||
confirm_button: Button::with_text(TR::buttons__continue.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<F> Component for NumberInputDialog<F>
|
||||
where
|
||||
F: Fn(u32) -> TString<'static>,
|
||||
{
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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<F> crate::trace::Trace for NumberInputDialog<F>
|
||||
where
|
||||
F: Fn(u32) -> TString<'static>,
|
||||
{
|
||||
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>,
|
||||
inc: Child<Button>,
|
||||
min: u32,
|
||||
max: u32,
|
||||
value: u32,
|
||||
}
|
||||
|
||||
impl NumberInput {
|
||||
pub fn new(min: u32, max: u32, value: u32) -> Self {
|
||||
let dec = Button::with_text("-".into())
|
||||
.styled(theme::button_counter())
|
||||
.into_child();
|
||||
let inc = Button::with_text("+".into())
|
||||
.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::BASELINE_OFFSET.y;
|
||||
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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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::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)
|
||||
.with_align(Alignment::Center)
|
||||
.with_fg(theme::FG)
|
||||
.with_font(digit_font)
|
||||
.render(target);
|
||||
}
|
||||
|
||||
self.dec.render(target);
|
||||
self.inc.render(target);
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_bounds")]
|
||||
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
|
||||
self.dec.bounds(sink);
|
||||
self.inc.bounds(sink);
|
||||
sink(self.area)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl crate::trace::Trace for NumberInput {
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("NumberInput");
|
||||
t.int("value", self.value as i64);
|
||||
}
|
||||
}
|
@ -0,0 +1,846 @@
|
||||
use crate::{
|
||||
error::Error,
|
||||
strutil::TString,
|
||||
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,
|
||||
};
|
||||
|
||||
use core::cell::Cell;
|
||||
|
||||
/// Allows pagination of inner component. Shows scroll bar, confirm & cancel
|
||||
/// buttons. Optionally handles hold-to-confirm with loader.
|
||||
pub struct ButtonPage<T> {
|
||||
/// 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>,
|
||||
button_confirm: Button,
|
||||
button_prev: Button,
|
||||
button_next: Button,
|
||||
/// 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: Cell<Option<u16>>,
|
||||
}
|
||||
|
||||
impl<T> ButtonPage<T>
|
||||
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.into()).styled(theme::button_confirm());
|
||||
self.loader = Some(Loader::new());
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ButtonPage<T>
|
||||
where
|
||||
T: Paginate,
|
||||
T: Component,
|
||||
{
|
||||
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: Cell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn without_cancel(mut self) -> Self {
|
||||
self.button_cancel = None;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cancel_confirm(
|
||||
mut self,
|
||||
left: Option<TString<'static>>,
|
||||
right: Option<TString<'static>>,
|
||||
) -> Self {
|
||||
let cancel = match left {
|
||||
Some(verb) => verb.map(|s| match s {
|
||||
"^" => 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.set(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> Component for ButtonPage<T>
|
||||
where
|
||||
T: Paginate,
|
||||
T: Component,
|
||||
{
|
||||
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.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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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> crate::trace::Trace for ButtonPage<T>
|
||||
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());
|
||||
|
||||
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::new(Paragraphs::<[Paragraph<'static>; 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::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::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::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::new(
|
||||
Paragraphs::new([
|
||||
Paragraph::new(&theme::TEXT_NORMAL, "Short one.").break_after(),
|
||||
Paragraph::new(&theme::TEXT_NORMAL, "Short two.").break_after(),
|
||||
Paragraph::new(&theme::TEXT_NORMAL, "Short three.").break_after(),
|
||||
]),
|
||||
theme::BG,
|
||||
);
|
||||
page.place(SCREEN);
|
||||
|
||||
let first_page = serde_json::json!({
|
||||
"component": "ButtonPage",
|
||||
"active_page": 0,
|
||||
"page_count": 3,
|
||||
"content": {
|
||||
"component": "Paragraphs",
|
||||
"paragraphs": [
|
||||
[
|
||||
"Short one.",
|
||||
],
|
||||
],
|
||||
},
|
||||
"hold": false,
|
||||
});
|
||||
let second_page = serde_json::json!({
|
||||
"component": "ButtonPage",
|
||||
"active_page": 1,
|
||||
"page_count": 3,
|
||||
"content": {
|
||||
"component": "Paragraphs",
|
||||
"paragraphs": [
|
||||
[
|
||||
"Short two.",
|
||||
],
|
||||
],
|
||||
},
|
||||
"hold": false,
|
||||
});
|
||||
let third_page = serde_json::json!({
|
||||
"component": "ButtonPage",
|
||||
"active_page": 2,
|
||||
"page_count": 3,
|
||||
"content": {
|
||||
"component": "Paragraphs",
|
||||
"paragraphs": [
|
||||
[
|
||||
"Short three.",
|
||||
],
|
||||
],
|
||||
},
|
||||
"hold": false,
|
||||
});
|
||||
|
||||
assert_eq!(trace(&page), first_page);
|
||||
swipe_up(&mut page);
|
||||
assert_eq!(trace(&page), second_page);
|
||||
swipe_up(&mut page);
|
||||
assert_eq!(trace(&page), third_page);
|
||||
swipe_up(&mut page);
|
||||
assert_eq!(trace(&page), third_page);
|
||||
}
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
use core::mem;
|
||||
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
ui::{
|
||||
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 {
|
||||
title: Child<Label<'static>>,
|
||||
value: u16,
|
||||
loader_y_offset: i16,
|
||||
indeterminate: bool,
|
||||
description: Child<Paragraphs<Paragraph<'static>>>,
|
||||
description_pad: Pad,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
const AREA: Rect = constant::screen().inset(theme::borders());
|
||||
|
||||
pub fn new(
|
||||
title: TString<'static>,
|
||||
indeterminate: bool,
|
||||
description: TString<'static>,
|
||||
) -> 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Progress {
|
||||
type Msg = Never;
|
||||
|
||||
fn place(&mut self, _bounds: Rect) -> Rect {
|
||||
let description_lines = 1 + self
|
||||
.description
|
||||
.inner()
|
||||
.inner()
|
||||
.content()
|
||||
.map(|t| t.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() != &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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
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 * shape::PI4 as i32) / 1000) as i16;
|
||||
let end = ((end as i32 * 8 * shape::PI4 as i32) / 1000) as i16;
|
||||
(start, end)
|
||||
} else {
|
||||
let end = ((self.value as i32 * 8 * shape::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 crate::trace::Trace for Progress {
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("Progress");
|
||||
}
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
use crate::{
|
||||
time::Duration,
|
||||
ui::{
|
||||
component::{Component, Event, EventCtx},
|
||||
display::Color,
|
||||
geometry::{Alignment2D, Offset, Rect},
|
||||
shape,
|
||||
shape::Renderer,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{theme, Button, ButtonContent, ButtonMsg};
|
||||
|
||||
/// Component requesting an action from a user. Most typically embedded as a
|
||||
/// content of a Frame and promptin "Tap to confirm" or "Hold to XYZ".
|
||||
#[derive(Clone)]
|
||||
pub struct PromptScreen {
|
||||
area: Rect,
|
||||
button: Button,
|
||||
circle_color: Color,
|
||||
circle_pad_color: Color,
|
||||
circle_inner_color: Color,
|
||||
dismiss_type: DismissType,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum DismissType {
|
||||
Tap,
|
||||
Hold,
|
||||
}
|
||||
|
||||
impl PromptScreen {
|
||||
pub fn new_hold_to_confirm() -> Self {
|
||||
let icon = theme::ICON_SIGN;
|
||||
let button = Button::new(ButtonContent::Icon(icon))
|
||||
.styled(theme::button_default())
|
||||
.with_long_press(Duration::from_secs(2));
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
circle_color: theme::GREEN,
|
||||
circle_pad_color: theme::GREY_EXTRA_DARK,
|
||||
circle_inner_color: theme::GREEN_LIGHT,
|
||||
dismiss_type: DismissType::Hold,
|
||||
button,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_tap_to_confirm() -> Self {
|
||||
let icon = theme::ICON_SIMPLE_CHECKMARK;
|
||||
let button = Button::new(ButtonContent::Icon(icon)).styled(theme::button_default());
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
circle_color: theme::GREEN,
|
||||
circle_inner_color: theme::GREEN,
|
||||
circle_pad_color: theme::GREY_EXTRA_DARK,
|
||||
dismiss_type: DismissType::Tap,
|
||||
button,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_tap_to_cancel() -> Self {
|
||||
let icon = theme::ICON_SIMPLE_CHECKMARK;
|
||||
let button = Button::new(ButtonContent::Icon(icon)).styled(theme::button_default());
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
circle_color: theme::ORANGE_LIGHT,
|
||||
circle_inner_color: theme::ORANGE_LIGHT,
|
||||
circle_pad_color: theme::GREY_EXTRA_DARK,
|
||||
dismiss_type: DismissType::Tap,
|
||||
button,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PromptScreen {
|
||||
type Msg = ();
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
self.area = bounds;
|
||||
self.button.place(Rect::snap(
|
||||
self.area.center(),
|
||||
Offset::uniform(55),
|
||||
Alignment2D::CENTER,
|
||||
));
|
||||
bounds
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
let btn_msg = self.button.event(ctx, event);
|
||||
match (&self.dismiss_type, btn_msg) {
|
||||
(DismissType::Tap, Some(ButtonMsg::Clicked)) => {
|
||||
return Some(());
|
||||
}
|
||||
(DismissType::Hold, Some(ButtonMsg::LongPressed)) => {
|
||||
return Some(());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn paint(&mut self) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
shape::Circle::new(self.area.center(), 70)
|
||||
.with_fg(self.circle_pad_color)
|
||||
.with_bg(theme::BLACK)
|
||||
.with_thickness(20)
|
||||
.render(target);
|
||||
shape::Circle::new(self.area.center(), 50)
|
||||
.with_fg(self.circle_color)
|
||||
.with_bg(theme::BLACK)
|
||||
.with_thickness(2)
|
||||
.render(target);
|
||||
shape::Circle::new(self.area.center(), 48)
|
||||
.with_fg(self.circle_pad_color)
|
||||
.with_bg(theme::BLACK)
|
||||
.with_thickness(8)
|
||||
.render(target);
|
||||
matches!(self.dismiss_type, DismissType::Hold).then(|| {
|
||||
shape::Circle::new(self.area.center(), 40)
|
||||
.with_fg(self.circle_inner_color)
|
||||
.with_bg(theme::BLACK)
|
||||
.with_thickness(2)
|
||||
.render(target);
|
||||
});
|
||||
self.button.render(target);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "micropython")]
|
||||
impl crate::ui::flow::Swipable for PromptScreen {}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl crate::trace::Trace for PromptScreen {
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("StatusScreen");
|
||||
t.child("button", &self.button);
|
||||
}
|
||||
}
|
@ -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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
fn dotsize(distance: usize, nhidden: usize) -> Icon {
|
||||
match (nhidden.saturating_sub(distance)).min(2 - distance) {
|
||||
0 => theme::DOT_INACTIVE,
|
||||
1 => theme::DOT_INACTIVE_HALF,
|
||||
_ => theme::DOT_INACTIVE_QUARTER,
|
||||
}
|
||||
}
|
||||
|
||||
// Number of visible dots.
|
||||
let num_shown = self.page_count.min(Self::MAX_DOTS);
|
||||
// Page indices corresponding to the first (and last) dot.
|
||||
let first_shown = self
|
||||
.active_page
|
||||
.saturating_sub(Self::MAX_DOTS / 2)
|
||||
.min(self.page_count.saturating_sub(Self::MAX_DOTS));
|
||||
let last_shown = first_shown + num_shown - 1;
|
||||
|
||||
let mut cursor = self.area.center()
|
||||
- Offset::on_axis(
|
||||
self.layout.axis,
|
||||
Self::DOT_INTERVAL * (num_shown.saturating_sub(1) as i16) / 2,
|
||||
);
|
||||
for i in first_shown..(last_shown + 1) {
|
||||
let icon = if i == self.active_page {
|
||||
theme::DOT_ACTIVE
|
||||
} else if i <= first_shown + 1 {
|
||||
let before_first_shown = first_shown;
|
||||
dotsize(i - first_shown, before_first_shown)
|
||||
} else if i >= last_shown - 1 {
|
||||
let after_last_shown = self.page_count - 1 - last_shown;
|
||||
dotsize(last_shown - i, after_last_shown)
|
||||
} else {
|
||||
theme::DOT_INACTIVE
|
||||
};
|
||||
shape::ToifImage::new(cursor, icon.toif)
|
||||
.with_align(Alignment2D::CENTER)
|
||||
.with_fg(theme::FG)
|
||||
.render(target);
|
||||
cursor = cursor + Offset::on_axis(self.layout.axis, Self::DOT_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
self.area = bounds;
|
||||
bounds
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_bounds")]
|
||||
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
|
||||
sink(self.area);
|
||||
}
|
||||
}
|
@ -0,0 +1,158 @@
|
||||
use super::theme;
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
translations::TR,
|
||||
ui::{
|
||||
component::{Component, Event, EventCtx, PageMsg, Paginate},
|
||||
constant::SPACING,
|
||||
geometry::{Alignment, Alignment2D, Insets, Offset, Rect},
|
||||
model_mercury::component::{Footer, Swipe, SwipeDirection},
|
||||
shape,
|
||||
shape::Renderer,
|
||||
},
|
||||
};
|
||||
use heapless::{String, Vec};
|
||||
|
||||
const MAX_WORDS: usize = 33; // super-shamir has 33 words, all other have less
|
||||
|
||||
/// Component showing mnemonic/share words during backup procedure. Model T3T1
|
||||
/// contains one word per screen. A user is instructed to swipe up/down to see
|
||||
/// next/previous word.
|
||||
pub struct ShareWords<'a> {
|
||||
area: Rect,
|
||||
share_words: Vec<TString<'a>, MAX_WORDS>,
|
||||
page_index: usize,
|
||||
/// Area reserved for a shown word from mnemonic/share
|
||||
area_word: Rect,
|
||||
/// TODO: review when swipe concept done for T3T1
|
||||
swipe: Swipe,
|
||||
/// Footer component for instructions and word counting
|
||||
footer: Footer<'static>,
|
||||
}
|
||||
|
||||
impl<'a> ShareWords<'a> {
|
||||
const AREA_WORD_HEIGHT: i16 = 91;
|
||||
|
||||
pub fn new(share_words: Vec<TString<'a>, MAX_WORDS>) -> Self {
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
share_words,
|
||||
page_index: 0,
|
||||
area_word: Rect::zero(),
|
||||
swipe: Swipe::new().up().down(),
|
||||
footer: Footer::new(TR::instructions__swipe_up),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_final_page(&self) -> bool {
|
||||
self.page_index == self.share_words.len() - 1
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Component for ShareWords<'a> {
|
||||
type Msg = PageMsg<()>;
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
self.area = bounds;
|
||||
let used_area = bounds
|
||||
.inset(Insets::sides(SPACING))
|
||||
.inset(Insets::bottom(SPACING));
|
||||
|
||||
self.area_word = Rect::snap(
|
||||
used_area.center(),
|
||||
Offset::new(used_area.width(), ShareWords::AREA_WORD_HEIGHT),
|
||||
Alignment2D::CENTER,
|
||||
);
|
||||
|
||||
self.footer
|
||||
.place(used_area.split_bottom(Footer::HEIGHT_SIMPLE).1);
|
||||
|
||||
self.swipe.place(bounds); // Swipe possible on the whole screen area
|
||||
self.area
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
ctx.set_page_count(self.share_words.len());
|
||||
let swipe = self.swipe.event(ctx, event);
|
||||
match swipe {
|
||||
Some(SwipeDirection::Up) => {
|
||||
if self.is_final_page() {
|
||||
return Some(PageMsg::Confirmed);
|
||||
}
|
||||
self.change_page(self.page_index + 1);
|
||||
ctx.request_paint();
|
||||
}
|
||||
Some(SwipeDirection::Down) => {
|
||||
self.change_page(self.page_index.saturating_sub(1));
|
||||
ctx.request_paint();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn paint(&mut self) {
|
||||
// TODO: remove when ui-t3t1 done
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
// corner highlights
|
||||
let (_, top_right_shape, bot_left_shape, bot_right_shape) =
|
||||
shape::CornerHighlight::from_rect(self.area_word, theme::GREY_DARK, theme::BG);
|
||||
top_right_shape.render(target);
|
||||
bot_left_shape.render(target);
|
||||
bot_right_shape.render(target);
|
||||
|
||||
// the ordinal number of the current word
|
||||
let ordinal_val = self.page_index as u8 + 1;
|
||||
let ordinal_pos = self.area_word.top_left()
|
||||
+ Offset::y(
|
||||
theme::TEXT_SUB_GREY_LIGHT
|
||||
.text_font
|
||||
.visible_text_height("1"),
|
||||
);
|
||||
let ordinal = build_string!(3, inttostr!(ordinal_val), ".");
|
||||
shape::Text::new(ordinal_pos, &ordinal)
|
||||
.with_font(theme::TEXT_SUB_GREY_LIGHT.text_font)
|
||||
.with_fg(theme::GREY)
|
||||
.render(target);
|
||||
|
||||
// the share word
|
||||
let word = self.share_words[self.page_index];
|
||||
let word_baseline = self.area_word.center()
|
||||
+ Offset::y(theme::TEXT_SUPER.text_font.visible_text_height("A") / 2);
|
||||
word.map(|w| {
|
||||
shape::Text::new(word_baseline, w)
|
||||
.with_font(theme::TEXT_SUPER.text_font)
|
||||
.with_align(Alignment::Center)
|
||||
.render(target);
|
||||
});
|
||||
|
||||
// footer with instructions
|
||||
self.footer.render(target);
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_bounds")]
|
||||
fn bounds(&self, _sink: &mut dyn FnMut(Rect)) {}
|
||||
}
|
||||
|
||||
impl<'a> Paginate for ShareWords<'a> {
|
||||
fn page_count(&mut self) -> usize {
|
||||
self.share_words.len()
|
||||
}
|
||||
|
||||
fn change_page(&mut self, active_page: usize) {
|
||||
self.page_index = active_page;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl<'a> crate::trace::Trace for ShareWords<'a> {
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("ShareWords");
|
||||
let word = &self.share_words[self.page_index];
|
||||
let content =
|
||||
word.map(|w| build_string!(50, inttostr!(self.page_index as u8 + 1), ". ", w, "\n"));
|
||||
t.string("screen_content", content.as_str().into());
|
||||
}
|
||||
}
|
@ -0,0 +1,200 @@
|
||||
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};
|
||||
use core::cell::Cell;
|
||||
|
||||
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: Cell<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: Cell::new(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.set(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<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
self.pad.render(target);
|
||||
self.content.render(target);
|
||||
if self.scrollbar.has_pages() {
|
||||
self.scrollbar.render(target);
|
||||
}
|
||||
if let Some(val) = self.fade.take() {
|
||||
// Note that this is blocking and takes some time.
|
||||
display::fade_backlight(val);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_bounds")]
|
||||
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
|
||||
sink(self.pad.area);
|
||||
self.scrollbar.bounds(sink);
|
||||
self.content.bounds(sink);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl<T> crate::trace::Trace for SimplePage<T>
|
||||
where
|
||||
T: crate::trace::Trace,
|
||||
{
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("SimplePage");
|
||||
t.int("active_page", self.scrollbar.active_page as i64);
|
||||
t.int("page_count", self.scrollbar.page_count as i64);
|
||||
t.child("content", &self.content);
|
||||
}
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
use crate::ui::{
|
||||
component::{Component, Event, EventCtx, Timeout},
|
||||
display::{Color, Icon},
|
||||
geometry::{Alignment2D, Rect},
|
||||
shape,
|
||||
shape::Renderer,
|
||||
};
|
||||
|
||||
use super::{theme, Swipe, SwipeDirection};
|
||||
|
||||
/// Component showing status of an operation. Most typically embedded as a
|
||||
/// content of a Frame and showing success (checkmark with a circle around).
|
||||
pub struct StatusScreen {
|
||||
area: Rect,
|
||||
icon: Icon,
|
||||
icon_color: Color,
|
||||
circle_color: Color,
|
||||
dismiss_type: DismissType,
|
||||
}
|
||||
|
||||
enum DismissType {
|
||||
SwipeUp(Swipe),
|
||||
Timeout(Timeout),
|
||||
}
|
||||
|
||||
const TIMEOUT_MS: u32 = 2000;
|
||||
|
||||
impl StatusScreen {
|
||||
fn new(icon: Icon, icon_color: Color, circle_color: Color, dismiss_style: DismissType) -> Self {
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
icon,
|
||||
icon_color,
|
||||
circle_color,
|
||||
dismiss_type: dismiss_style,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_success() -> Self {
|
||||
Self::new(
|
||||
theme::ICON_SIMPLE_CHECKMARK,
|
||||
theme::GREEN_LIME,
|
||||
theme::GREEN_LIGHT,
|
||||
DismissType::SwipeUp(Swipe::new().up()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_success_timeout() -> Self {
|
||||
Self::new(
|
||||
theme::ICON_SIMPLE_CHECKMARK,
|
||||
theme::GREEN_LIME,
|
||||
theme::GREEN_LIGHT,
|
||||
DismissType::Timeout(Timeout::new(TIMEOUT_MS)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_neutral() -> Self {
|
||||
Self::new(
|
||||
theme::ICON_SIMPLE_CHECKMARK,
|
||||
theme::GREY_EXTRA_LIGHT,
|
||||
theme::GREY_DARK,
|
||||
DismissType::SwipeUp(Swipe::new().up()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for StatusScreen {
|
||||
type Msg = ();
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
self.area = bounds;
|
||||
if let DismissType::SwipeUp(swipe) = &mut self.dismiss_type {
|
||||
swipe.place(bounds);
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
match self.dismiss_type {
|
||||
DismissType::SwipeUp(ref mut swipe) => {
|
||||
let swipe_dir = swipe.event(ctx, event);
|
||||
match swipe_dir {
|
||||
Some(SwipeDirection::Up) => return Some(()),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
DismissType::Timeout(ref mut timeout) => {
|
||||
if let Some(_) = timeout.event(ctx, event) {
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn paint(&mut self) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
shape::Circle::new(self.area.center(), 40)
|
||||
.with_fg(self.circle_color)
|
||||
.with_bg(theme::BLACK)
|
||||
.with_thickness(2)
|
||||
.render(target);
|
||||
shape::ToifImage::new(self.area.center(), self.icon.toif)
|
||||
.with_align(Alignment2D::CENTER)
|
||||
.with_fg(self.icon_color)
|
||||
.render(target);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl crate::trace::Trace for StatusScreen {
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("StatusScreen");
|
||||
}
|
||||
}
|
@ -0,0 +1,160 @@
|
||||
use crate::ui::{
|
||||
component::{Component, Event, EventCtx},
|
||||
display,
|
||||
event::TouchEvent,
|
||||
geometry::{Point, Rect},
|
||||
shape::Renderer,
|
||||
};
|
||||
|
||||
use super::theme;
|
||||
|
||||
pub use crate::ui::component::SwipeDirection;
|
||||
|
||||
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<'s>(&'s self, _target: &mut impl Renderer<'s>) {}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
use crate::ui::{
|
||||
component::{Component, Event, EventCtx},
|
||||
geometry::Rect,
|
||||
shape::Renderer,
|
||||
};
|
||||
|
||||
use super::{Swipe, SwipeDirection};
|
||||
|
||||
/// Wrapper component adding "swipe up" handling to `content`.
|
||||
pub struct SwipeUpScreen<T> {
|
||||
content: T,
|
||||
swipe: Swipe,
|
||||
}
|
||||
|
||||
pub enum SwipeUpScreenMsg<T> {
|
||||
Swiped,
|
||||
Content(T),
|
||||
}
|
||||
|
||||
impl<T> SwipeUpScreen<T>
|
||||
where
|
||||
T: Component,
|
||||
{
|
||||
pub fn new(content: T) -> Self {
|
||||
Self {
|
||||
content,
|
||||
swipe: Swipe::new().up(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Component for SwipeUpScreen<T>
|
||||
where
|
||||
T: Component,
|
||||
{
|
||||
type Msg = SwipeUpScreenMsg<T::Msg>;
|
||||
|
||||
fn place(&mut self, bounds: Rect) -> Rect {
|
||||
self.swipe.place(bounds);
|
||||
self.content.place(bounds);
|
||||
bounds
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||
if let Some(SwipeDirection::Up) = self.swipe.event(ctx, event) {
|
||||
return Some(SwipeUpScreenMsg::Swiped);
|
||||
}
|
||||
self.content
|
||||
.event(ctx, event)
|
||||
.map(SwipeUpScreenMsg::Content)
|
||||
}
|
||||
|
||||
fn paint(&mut self) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||
self.content.render(target);
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_bounds")]
|
||||
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
|
||||
self.content.bounds(sink);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ui_debug")]
|
||||
impl<T> crate::trace::Trace for SwipeUpScreen<T>
|
||||
where
|
||||
T: crate::trace::Trace,
|
||||
{
|
||||
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
||||
t.component("SwipeUpScreen");
|
||||
t.child("content", &self.content);
|
||||
}
|
||||
}
|
@ -0,0 +1,156 @@
|
||||
use heapless::Vec;
|
||||
|
||||
use super::theme;
|
||||
use crate::{
|
||||
strutil::TString,
|
||||
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 = Vec<Button, N_ITEMS>;
|
||||
type AreasForSeparators = Vec<Rect, N_SEPS>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VerticalMenu {
|
||||
area: Rect,
|
||||
/// buttons placed vertically from top to bottom
|
||||
buttons: VerticalMenuButtons,
|
||||
/// areas for visual separators between buttons
|
||||
areas_sep: AreasForSeparators,
|
||||
}
|
||||
|
||||
impl VerticalMenu {
|
||||
fn new(buttons: VerticalMenuButtons) -> Self {
|
||||
Self {
|
||||
area: Rect::zero(),
|
||||
buttons,
|
||||
areas_sep: AreasForSeparators::new(),
|
||||
}
|
||||
}
|
||||
pub fn select_word(words: [TString<'static>; 3]) -> Self {
|
||||
let mut buttons_vec = VerticalMenuButtons::new();
|
||||
for word in words {
|
||||
let button = Button::with_text(word.into()).styled(theme::button_default());
|
||||
unwrap!(buttons_vec.push(button));
|
||||
}
|
||||
Self::new(buttons_vec)
|
||||
}
|
||||
|
||||
pub fn context_menu(options: Vec<(&'static str, Icon), N_ITEMS>) -> Self {
|
||||
// FIXME: args should be TString when IconText has TString
|
||||
let mut buttons_vec = VerticalMenuButtons::new();
|
||||
for opt in options {
|
||||
let button_theme;
|
||||
match opt.1 {
|
||||
// FIXME: might not be applicable everywhere
|
||||
theme::ICON_CANCEL => {
|
||||
button_theme = theme::button_warning_high();
|
||||
}
|
||||
_ => {
|
||||
button_theme = theme::button_default();
|
||||
}
|
||||
}
|
||||
unwrap!(buttons_vec.push(
|
||||
Button::with_icon_and_text(IconText::new(opt.0, opt.1)).styled(button_theme)
|
||||
));
|
||||
}
|
||||
Self::new(buttons_vec)
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for VerticalMenu {
|
||||
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;
|
||||
let n_seps = self.buttons.len() - 1;
|
||||
for (i, button) in self.buttons.iter_mut().enumerate() {
|
||||
let (area_button, new_remaining) = remaining.split_top(MENU_BUTTON_HEIGHT);
|
||||
button.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 crate::trace::Trace for VerticalMenu {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "micropython")]
|
||||
impl crate::ui::flow::Swipable for VerticalMenu {}
|
@ -0,0 +1,125 @@
|
||||
use crate::{
|
||||
error,
|
||||
micropython::qstr::Qstr,
|
||||
strutil::TString,
|
||||
translations::TR,
|
||||
ui::{
|
||||
component::{
|
||||
text::paragraphs::{Paragraph, Paragraphs},
|
||||
SwipeDirection,
|
||||
},
|
||||
flow::{base::Decision, flow_store, FlowMsg, FlowState, FlowStore, SwipeFlow, SwipePage},
|
||||
},
|
||||
};
|
||||
use heapless::Vec;
|
||||
|
||||
use super::super::{
|
||||
component::{Frame, FrameMsg, PromptScreen, VerticalMenu, VerticalMenuChoiceMsg},
|
||||
theme,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, ToPrimitive)]
|
||||
pub enum ConfirmResetDevice {
|
||||
Intro,
|
||||
Menu,
|
||||
Confirm,
|
||||
}
|
||||
|
||||
impl FlowState for ConfirmResetDevice {
|
||||
fn handle_swipe(&self, direction: SwipeDirection) -> Decision<Self> {
|
||||
match (self, direction) {
|
||||
(ConfirmResetDevice::Intro, SwipeDirection::Left) => {
|
||||
Decision::Goto(ConfirmResetDevice::Menu, direction)
|
||||
}
|
||||
(ConfirmResetDevice::Menu, SwipeDirection::Right) => {
|
||||
Decision::Goto(ConfirmResetDevice::Intro, direction)
|
||||
}
|
||||
(ConfirmResetDevice::Intro, SwipeDirection::Up) => {
|
||||
Decision::Goto(ConfirmResetDevice::Confirm, direction)
|
||||
}
|
||||
(ConfirmResetDevice::Confirm, SwipeDirection::Down) => {
|
||||
Decision::Goto(ConfirmResetDevice::Intro, direction)
|
||||
}
|
||||
_ => Decision::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(&self, msg: FlowMsg) -> Decision<Self> {
|
||||
match (self, msg) {
|
||||
(ConfirmResetDevice::Intro, FlowMsg::Info) => {
|
||||
Decision::Goto(ConfirmResetDevice::Menu, SwipeDirection::Left)
|
||||
}
|
||||
(ConfirmResetDevice::Menu, FlowMsg::Cancelled) => {
|
||||
Decision::Goto(ConfirmResetDevice::Intro, SwipeDirection::Right)
|
||||
}
|
||||
(ConfirmResetDevice::Menu, FlowMsg::Choice(0)) => Decision::Return(FlowMsg::Cancelled),
|
||||
(ConfirmResetDevice::Confirm, FlowMsg::Confirmed) => {
|
||||
Decision::Return(FlowMsg::Confirmed)
|
||||
}
|
||||
_ => Decision::Nothing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
micropython::{map::Map, obj::Obj, util},
|
||||
ui::layout::obj::LayoutObj,
|
||||
};
|
||||
|
||||
pub extern "C" fn new_confirm_reset_device(
|
||||
n_args: usize,
|
||||
args: *const Obj,
|
||||
kwargs: *mut Map,
|
||||
) -> Obj {
|
||||
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, ConfirmResetDevice::new) }
|
||||
}
|
||||
|
||||
impl ConfirmResetDevice {
|
||||
fn new(_args: &[Obj], kwargs: &Map) -> Result<Obj, error::Error> {
|
||||
let title: TString = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
|
||||
let par_array: [Paragraph<'static>; 3] = [
|
||||
Paragraph::new(&theme::TEXT_MAIN_GREY_LIGHT, TR::reset__by_continuing)
|
||||
.with_bottom_padding(17),
|
||||
Paragraph::new(&theme::TEXT_SUB_GREY, TR::reset__more_info_at),
|
||||
Paragraph::new(&theme::TEXT_SUB_GREY_LIGHT, TR::reset__tos_link),
|
||||
];
|
||||
let paragraphs = Paragraphs::new(par_array);
|
||||
let content_intro = Frame::left_aligned(title, SwipePage::vertical(paragraphs))
|
||||
.with_menu_button()
|
||||
.with_footer(TR::instructions__swipe_up.into(), None);
|
||||
|
||||
let content_menu = Frame::left_aligned(
|
||||
"".into(),
|
||||
VerticalMenu::context_menu(unwrap!(Vec::from_slice(&[(
|
||||
"Cancel", // FIXME: use TString
|
||||
theme::ICON_CANCEL
|
||||
)]))),
|
||||
)
|
||||
.with_cancel_button();
|
||||
|
||||
let content_confirm = Frame::left_aligned(
|
||||
TR::reset__title_create_wallet.into(),
|
||||
PromptScreen::new_hold_to_confirm(),
|
||||
)
|
||||
.with_footer(TR::instructions__hold_to_confirm.into(), None);
|
||||
|
||||
let store = flow_store()
|
||||
// Intro,
|
||||
.add(content_intro, |msg| {
|
||||
matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Info)
|
||||
})?
|
||||
// Context Menu,
|
||||
.add(content_menu, |msg| match msg {
|
||||
FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => Some(FlowMsg::Choice(i)),
|
||||
FrameMsg::Button(_) => Some(FlowMsg::Cancelled),
|
||||
})?
|
||||
// Confirm prompt
|
||||
.add(content_confirm, |msg| match msg {
|
||||
FrameMsg::Content(()) => Some(FlowMsg::Confirmed),
|
||||
_ => Some(FlowMsg::Cancelled),
|
||||
})?;
|
||||
|
||||
let res = SwipeFlow::new(ConfirmResetDevice::Intro, store)?;
|
||||
Ok(LayoutObj::new(res)?.into())
|
||||
}
|
||||
}
|
@ -0,0 +1,147 @@
|
||||
use crate::{
|
||||
error,
|
||||
strutil::TString,
|
||||
translations::TR,
|
||||
ui::{
|
||||
component::{
|
||||
text::paragraphs::{Paragraph, Paragraphs},
|
||||
SwipeDirection,
|
||||
},
|
||||
flow::{base::Decision, flow_store, FlowMsg, FlowState, FlowStore, SwipeFlow, SwipePage},
|
||||
},
|
||||
};
|
||||
use heapless::Vec;
|
||||
|
||||
use super::super::{
|
||||
component::{
|
||||
CancelInfoConfirmMsg, Frame, FrameMsg, PromptScreen, VerticalMenu, VerticalMenuChoiceMsg,
|
||||
},
|
||||
theme,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, ToPrimitive)]
|
||||
pub enum CreateBackup {
|
||||
Intro,
|
||||
Menu,
|
||||
SkipBackupIntro,
|
||||
SkipBackupConfirm,
|
||||
}
|
||||
|
||||
impl FlowState for CreateBackup {
|
||||
fn handle_swipe(&self, direction: SwipeDirection) -> Decision<Self> {
|
||||
match (self, direction) {
|
||||
(CreateBackup::Intro, SwipeDirection::Left) => {
|
||||
Decision::Goto(CreateBackup::Menu, direction)
|
||||
}
|
||||
(CreateBackup::SkipBackupIntro, SwipeDirection::Up) => {
|
||||
Decision::Goto(CreateBackup::SkipBackupConfirm, direction)
|
||||
}
|
||||
(CreateBackup::SkipBackupConfirm, SwipeDirection::Down) => {
|
||||
Decision::Goto(CreateBackup::SkipBackupIntro, direction)
|
||||
}
|
||||
(CreateBackup::Intro, SwipeDirection::Up) => Decision::Return(FlowMsg::Confirmed),
|
||||
_ => Decision::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(&self, msg: FlowMsg) -> Decision<Self> {
|
||||
match (self, msg) {
|
||||
(CreateBackup::Intro, FlowMsg::Info) => {
|
||||
Decision::Goto(CreateBackup::Menu, SwipeDirection::Left)
|
||||
}
|
||||
(CreateBackup::Menu, FlowMsg::Choice(0)) => {
|
||||
Decision::Goto(CreateBackup::SkipBackupIntro, SwipeDirection::Left)
|
||||
}
|
||||
(CreateBackup::Menu, FlowMsg::Cancelled) => {
|
||||
Decision::Goto(CreateBackup::Intro, SwipeDirection::Right)
|
||||
}
|
||||
(CreateBackup::SkipBackupIntro, FlowMsg::Cancelled) => {
|
||||
Decision::Goto(CreateBackup::Menu, SwipeDirection::Right)
|
||||
}
|
||||
(CreateBackup::SkipBackupConfirm, FlowMsg::Cancelled) => {
|
||||
Decision::Goto(CreateBackup::SkipBackupIntro, SwipeDirection::Right)
|
||||
}
|
||||
(CreateBackup::SkipBackupConfirm, FlowMsg::Confirmed) => {
|
||||
Decision::Return(FlowMsg::Cancelled)
|
||||
}
|
||||
_ => Decision::Nothing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
micropython::{map::Map, obj::Obj, util},
|
||||
ui::layout::obj::LayoutObj,
|
||||
};
|
||||
|
||||
pub extern "C" fn new_create_backup(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
|
||||
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, CreateBackup::new) }
|
||||
}
|
||||
|
||||
impl CreateBackup {
|
||||
fn new(_args: &[Obj], _kwargs: &Map) -> Result<Obj, error::Error> {
|
||||
let title: TString = TR::backup__title_backup_wallet.into();
|
||||
let par_array: [Paragraph<'static>; 1] = [Paragraph::new(
|
||||
&theme::TEXT_MAIN_GREY_LIGHT,
|
||||
TString::from_str("Your wallet backup contains X words in a specific order."),
|
||||
)];
|
||||
let paragraphs = Paragraphs::new(par_array);
|
||||
let content_intro = Frame::left_aligned(title, SwipePage::vertical(paragraphs))
|
||||
.with_menu_button()
|
||||
.with_footer(TR::instructions__swipe_up.into(), None);
|
||||
|
||||
let content_menu = Frame::left_aligned(
|
||||
"".into(),
|
||||
VerticalMenu::context_menu(unwrap!(Vec::from_slice(&[(
|
||||
"Skip backup", // FIXME: use TString
|
||||
theme::ICON_CANCEL
|
||||
)]))),
|
||||
)
|
||||
.with_cancel_button();
|
||||
|
||||
let par_array_skip_intro: [Paragraph<'static>; 2] = [
|
||||
Paragraph::new(&theme::TEXT_WARNING, TString::from_str("Not recommended!")),
|
||||
Paragraph::new(
|
||||
&theme::TEXT_MAIN_GREY_LIGHT,
|
||||
TString::from_str("Create a backup to avoid losing access to your funds"),
|
||||
),
|
||||
];
|
||||
let paragraphs_skip_intro = Paragraphs::new(par_array_skip_intro);
|
||||
let content_skip_intro = Frame::left_aligned(
|
||||
TR::backup__title_skip.into(),
|
||||
SwipePage::vertical(paragraphs_skip_intro),
|
||||
)
|
||||
.with_cancel_button()
|
||||
.with_footer(
|
||||
TR::instructions__swipe_up.into(),
|
||||
Some(TR::words__continue_anyway.into()),
|
||||
);
|
||||
|
||||
let content_skip_confirm = Frame::left_aligned(
|
||||
TR::backup__title_skip.into(),
|
||||
PromptScreen::new_tap_to_cancel(),
|
||||
)
|
||||
.with_footer(TR::instructions__tap_to_confirm.into(), None);
|
||||
|
||||
let store = flow_store()
|
||||
.add(content_intro, |msg| {
|
||||
matches!(msg, FrameMsg::Button(CancelInfoConfirmMsg::Info)).then_some(FlowMsg::Info)
|
||||
})?
|
||||
.add(content_menu, |msg| match msg {
|
||||
FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => Some(FlowMsg::Choice(i)),
|
||||
FrameMsg::Button(CancelInfoConfirmMsg::Cancelled) => Some(FlowMsg::Cancelled),
|
||||
FrameMsg::Button(_) => None,
|
||||
})?
|
||||
.add(content_skip_intro, |msg| match msg {
|
||||
FrameMsg::Button(CancelInfoConfirmMsg::Cancelled) => Some(FlowMsg::Cancelled),
|
||||
_ => None,
|
||||
})?
|
||||
.add(content_skip_confirm, |msg| match msg {
|
||||
FrameMsg::Content(()) => Some(FlowMsg::Confirmed),
|
||||
FrameMsg::Button(CancelInfoConfirmMsg::Cancelled) => Some(FlowMsg::Cancelled),
|
||||
_ => None,
|
||||
})?;
|
||||
let res = SwipeFlow::new(CreateBackup::Intro, store)?;
|
||||
Ok(LayoutObj::new(res)?.into())
|
||||
}
|
||||
}
|
@ -0,0 +1,188 @@
|
||||
use crate::{
|
||||
error,
|
||||
ui::{
|
||||
component::{
|
||||
image::BlendedImage,
|
||||
text::paragraphs::{Paragraph, Paragraphs},
|
||||
Qr, SwipeDirection, Timeout,
|
||||
},
|
||||
flow::{
|
||||
base::Decision, flow_store, FlowMsg, FlowState, FlowStore, IgnoreSwipe, SwipeFlow,
|
||||
SwipePage,
|
||||
},
|
||||
},
|
||||
};
|
||||
use heapless::Vec;
|
||||
|
||||
use super::super::{
|
||||
component::{Frame, FrameMsg, IconDialog, VerticalMenu, VerticalMenuChoiceMsg},
|
||||
theme,
|
||||
};
|
||||
|
||||
const LONGSTRING: &'static str = "https://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKohttps://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKo";
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, ToPrimitive)]
|
||||
pub enum GetAddress {
|
||||
Address,
|
||||
Menu,
|
||||
QrCode,
|
||||
AccountInfo,
|
||||
Cancel,
|
||||
Success,
|
||||
}
|
||||
|
||||
impl FlowState for GetAddress {
|
||||
fn handle_swipe(&self, direction: SwipeDirection) -> Decision<Self> {
|
||||
match (self, direction) {
|
||||
(GetAddress::Address, SwipeDirection::Left) => {
|
||||
Decision::Goto(GetAddress::Menu, direction)
|
||||
}
|
||||
(GetAddress::Address, SwipeDirection::Up) => {
|
||||
Decision::Goto(GetAddress::Success, direction)
|
||||
}
|
||||
(GetAddress::Menu, SwipeDirection::Right) => {
|
||||
Decision::Goto(GetAddress::Address, direction)
|
||||
}
|
||||
(GetAddress::QrCode, SwipeDirection::Right) => {
|
||||
Decision::Goto(GetAddress::Menu, direction)
|
||||
}
|
||||
(GetAddress::AccountInfo, SwipeDirection::Right) => {
|
||||
Decision::Goto(GetAddress::Menu, direction)
|
||||
}
|
||||
(GetAddress::Cancel, SwipeDirection::Up) => Decision::Return(FlowMsg::Cancelled),
|
||||
_ => Decision::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(&self, msg: FlowMsg) -> Decision<Self> {
|
||||
match (self, msg) {
|
||||
(GetAddress::Address, FlowMsg::Info) => {
|
||||
Decision::Goto(GetAddress::Menu, SwipeDirection::Left)
|
||||
}
|
||||
|
||||
(GetAddress::Menu, FlowMsg::Choice(0)) => {
|
||||
Decision::Goto(GetAddress::QrCode, SwipeDirection::Left)
|
||||
}
|
||||
|
||||
(GetAddress::Menu, FlowMsg::Choice(1)) => {
|
||||
Decision::Goto(GetAddress::AccountInfo, SwipeDirection::Left)
|
||||
}
|
||||
|
||||
(GetAddress::Menu, FlowMsg::Choice(2)) => {
|
||||
Decision::Goto(GetAddress::Cancel, SwipeDirection::Left)
|
||||
}
|
||||
|
||||
(GetAddress::Menu, FlowMsg::Cancelled) => {
|
||||
Decision::Goto(GetAddress::Address, SwipeDirection::Right)
|
||||
}
|
||||
|
||||
(GetAddress::QrCode, FlowMsg::Cancelled) => {
|
||||
Decision::Goto(GetAddress::Menu, SwipeDirection::Right)
|
||||
}
|
||||
|
||||
(GetAddress::AccountInfo, FlowMsg::Cancelled) => {
|
||||
Decision::Goto(GetAddress::Menu, SwipeDirection::Right)
|
||||
}
|
||||
|
||||
(GetAddress::Cancel, FlowMsg::Cancelled) => {
|
||||
Decision::Goto(GetAddress::Menu, SwipeDirection::Right)
|
||||
}
|
||||
|
||||
(GetAddress::Success, _) => Decision::Return(FlowMsg::Confirmed),
|
||||
_ => Decision::Nothing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
micropython::{buffer::StrBuffer, map::Map, obj::Obj, util},
|
||||
ui::layout::obj::LayoutObj,
|
||||
};
|
||||
|
||||
pub extern "C" fn new_get_address(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
|
||||
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, GetAddress::new) }
|
||||
}
|
||||
|
||||
impl GetAddress {
|
||||
fn new(_args: &[Obj], _kwargs: &Map) -> Result<Obj, error::Error> {
|
||||
let store = flow_store()
|
||||
.add(
|
||||
Frame::left_aligned(
|
||||
"Receive".into(),
|
||||
SwipePage::vertical(Paragraphs::new(Paragraph::new(
|
||||
&theme::TEXT_MONO,
|
||||
StrBuffer::from(LONGSTRING),
|
||||
))),
|
||||
)
|
||||
.with_subtitle("address".into())
|
||||
.with_menu_button(),
|
||||
|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Info),
|
||||
)?
|
||||
.add(
|
||||
Frame::left_aligned(
|
||||
"".into(),
|
||||
VerticalMenu::context_menu(unwrap!(Vec::from_slice(&[
|
||||
("Address QR code", theme::ICON_QR_CODE),
|
||||
("Account info", theme::ICON_CHEVRON_RIGHT),
|
||||
("Cancel trans.", theme::ICON_CANCEL),
|
||||
]))),
|
||||
)
|
||||
.with_cancel_button(),
|
||||
|msg| match msg {
|
||||
FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => {
|
||||
Some(FlowMsg::Choice(i))
|
||||
}
|
||||
FrameMsg::Button(_) => Some(FlowMsg::Cancelled),
|
||||
},
|
||||
)?
|
||||
.add(
|
||||
Frame::left_aligned(
|
||||
"Receive address".into(),
|
||||
IgnoreSwipe::new(Qr::new(
|
||||
"https://youtu.be/iFkEs4GNgfc?si=Jl4UZSIAYGVcLQKo",
|
||||
true,
|
||||
)?),
|
||||
)
|
||||
.with_cancel_button(),
|
||||
|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled),
|
||||
)?
|
||||
.add(
|
||||
Frame::left_aligned(
|
||||
"Account info".into(),
|
||||
SwipePage::vertical(Paragraphs::new(Paragraph::new(
|
||||
&theme::TEXT_NORMAL,
|
||||
StrBuffer::from("taproot xp"),
|
||||
))),
|
||||
)
|
||||
.with_cancel_button(),
|
||||
|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled),
|
||||
)?
|
||||
.add(
|
||||
Frame::left_aligned(
|
||||
"Cancel receive".into(),
|
||||
SwipePage::vertical(Paragraphs::new(Paragraph::new(
|
||||
&theme::TEXT_NORMAL,
|
||||
StrBuffer::from("O rly?"),
|
||||
))),
|
||||
)
|
||||
.with_cancel_button(),
|
||||
|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled),
|
||||
)?
|
||||
.add(
|
||||
IconDialog::new(
|
||||
BlendedImage::new(
|
||||
theme::IMAGE_BG_CIRCLE,
|
||||
theme::IMAGE_FG_WARN,
|
||||
theme::SUCCESS_COLOR,
|
||||
theme::FG,
|
||||
theme::BG,
|
||||
),
|
||||
StrBuffer::from("Confirmed"),
|
||||
Timeout::new(100),
|
||||
),
|
||||
|_| Some(FlowMsg::Confirmed),
|
||||
)?;
|
||||
let res = SwipeFlow::new(GetAddress::Address, store)?;
|
||||
Ok(LayoutObj::new(res)?.into())
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
pub mod confirm_reset_device;
|
||||
pub mod create_backup;
|
||||
pub mod get_address;
|
||||
|
||||
pub use confirm_reset_device::ConfirmResetDevice;
|
||||
pub use create_backup::CreateBackup;
|
||||
pub use get_address::GetAddress;
|
After Width: | Height: | Size: 542 B |
After Width: | Height: | Size: 433 B |
After Width: | Height: | Size: 409 B |
After Width: | Height: | Size: 226 B |
After Width: | Height: | Size: 367 B |
After Width: | Height: | Size: 484 B |
After Width: | Height: | Size: 390 B |
After Width: | Height: | Size: 502 B |