chore(core): Various little improvements in Rust UI

pull/1961/head
Jan Pochyla 3 years ago committed by matejcik
parent 998210d569
commit 39263144b7

@ -1,7 +1,8 @@
use core::{mem, time::Duration};
use core::mem;
use heapless::Vec;
use crate::time::Duration;
#[cfg(feature = "model_t1")]
use crate::ui::model_t1::event::ButtonEvent;
#[cfg(feature = "model_tt")]
@ -12,12 +13,20 @@ use crate::ui::model_tt::event::TouchEvent;
/// Alternative to the yet-unstable `!`-type.
pub enum Never {}
/// User interface is composed of components that can react to `Event`s through
/// the `event` method and know how to paint themselves to screen through the
/// `paint` method. Components can emit messages as a reaction to events.
pub trait Component {
type Msg;
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg>;
fn paint(&mut self);
}
/// Components should always avoid unnecessary overpaint to prevent obvious
/// tearing and flickering. `Child` wraps an inner component `T` and keeps a
/// dirty flag for it. Any mutation of `T` has to happen through the `mutate`
/// accessor, `T` can then request a paint call to be scheduled later by calling
/// `EventCtx::request_paint` in its `event` pass.
pub struct Child<T> {
component: T,
marked_for_paint: bool,
@ -39,18 +48,26 @@ impl<T> Child<T> {
self.component
}
/// Access inner component mutably, track whether a paint call has been
/// requested, and propagate the flag upwards the component tree.
pub fn mutate<F, U>(&mut self, ctx: &mut EventCtx, component_func: F) -> U
where
F: FnOnce(&mut EventCtx, &mut T) -> U,
{
let paint_was_previously_requested = mem::replace(&mut ctx.paint_requested, false);
let component_result = component_func(ctx, &mut self.component);
let prev_requested = mem::replace(&mut ctx.paint_requested, false);
let result = component_func(ctx, &mut self.component);
if ctx.paint_requested {
// If a paint was requested anywhere in the inner component tree, we need to
// mark ourselves for paint as well, and keep the `ctx` flag so it can
// propagate upwards.
self.marked_for_paint = true;
} else {
ctx.paint_requested = paint_was_previously_requested;
// Paint has not been requested in the *inner* component, so there's no need to
// paint it, but we need to preserve the previous flag carried in `ctx` so it
// properly propagates upwards (i.e. from our previous siblings).
ctx.paint_requested = prev_requested;
}
component_result
result
}
}
@ -61,7 +78,15 @@ where
type Msg = T::Msg;
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.mutate(ctx, |ctx, c| c.event(ctx, event))
self.mutate(ctx, |ctx, c| {
// Handle the internal invalidation event here, so components don't have to. We
// still pass it inside, so the event propagates correctly to all components in
// the sub-tree.
if let Event::RequestPaint = event {
ctx.request_paint();
}
c.event(ctx, event)
})
}
fn paint(&mut self) {
@ -82,13 +107,31 @@ where
}
}
pub trait ComponentExt: Sized {
fn into_child(self) -> Child<Self>;
}
impl<T> ComponentExt for T
where
T: Component,
{
fn into_child(self) -> Child<Self> {
Child::new(self)
}
}
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum Event {
#[cfg(feature = "model_t1")]
Button(ButtonEvent),
#[cfg(feature = "model_tt")]
Touch(TouchEvent),
/// Previously requested timer was triggered. This invalidates the timer
/// token (another timer has to be requested).
Timer(TimerToken),
/// Internally-handled event to inform all `Child` wrappers in a sub-tree to
/// get scheduled for painting.
RequestPaint,
}
#[derive(Copy, Clone, PartialEq, Eq)]
@ -125,6 +168,9 @@ impl EventCtx {
}
}
/// Indicate that the inner state of the component has changed, any screen
/// content it has painted before is now invalid, and it should be painted
/// again by the nearest `Child` wrapper.
pub fn request_paint(&mut self) {
self.paint_requested = true;
}
@ -133,12 +179,13 @@ impl EventCtx {
self.paint_requested = false;
}
/// Request a timer event to be delivered after `deadline` elapses.
pub fn request_timer(&mut self, deadline: Duration) -> TimerToken {
let token = self.next_timer_token();
if self.timers.push((token, deadline)).is_err() {
// The timer queue is full. Let's just ignore this request.
#[cfg(feature = "ui_debug")]
panic!("Timer queue is full");
panic!("timer queue is full");
}
token
}

@ -1,4 +1,4 @@
use crate::ui::component::{Component, Event, EventCtx, Never};
use super::{Component, Event, EventCtx, Never};
pub struct Empty;

@ -0,0 +1,28 @@
use super::{Component, Event, EventCtx};
pub struct Map<T, F> {
inner: T,
func: F,
}
impl<T, F> Map<T, F> {
pub fn new(inner: T, func: F) -> Self {
Self { inner, func }
}
}
impl<T, F, U> Component for Map<T, F>
where
T: Component,
F: Fn(T::Msg) -> U,
{
type Msg = U;
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.inner.event(ctx, event).map(&self.func)
}
fn paint(&mut self) {
self.inner.paint()
}
}

@ -1,7 +1,9 @@
mod base;
pub mod base;
pub mod empty;
pub mod label;
pub mod map;
pub mod text;
pub mod tuple;
pub use base::{Child, Component, Event, EventCtx, Never, TimerToken};
pub use empty::Empty;

@ -38,7 +38,7 @@ impl<F, T> Text<F, T> {
if self.args.insert(key, value).is_err() {
// Map is full, ignore.
#[cfg(feature = "ui_debug")]
panic!("Text args map is full");
panic!("text args map is full");
}
self
}

@ -0,0 +1,42 @@
use super::{Component, Event, EventCtx};
impl<T, A, B> Component for (A, B)
where
A: Component<Msg = T>,
B: Component<Msg = T>,
{
type Msg = T;
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.0
.event(ctx, event)
.or_else(|| self.1.event(ctx, event))
}
fn paint(&mut self) {
self.0.paint();
self.1.paint();
}
}
impl<T, A, B, C> Component for (A, B, C)
where
A: Component<Msg = T>,
B: Component<Msg = T>,
C: Component<Msg = T>,
{
type Msg = T;
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.0
.event(ctx, event)
.or_else(|| self.1.event(ctx, event))
.or_else(|| self.2.event(ctx, event))
}
fn paint(&mut self) {
self.0.paint();
self.1.paint();
self.2.paint();
}
}

@ -1,7 +1,6 @@
use core::{
cell::RefCell,
convert::{TryFrom, TryInto},
time::Duration,
};
use crate::{
@ -13,6 +12,7 @@ use crate::{
qstr::Qstr,
typ::Type,
},
time::Duration,
ui::component::{Child, Component, Event, EventCtx, Never, TimerToken},
util,
};
@ -268,7 +268,7 @@ impl TryFrom<Duration> for Obj {
type Error = Error;
fn try_from(value: Duration) -> Result<Self, Self::Error> {
let millis: usize = value.as_millis().try_into()?;
let millis: usize = value.to_millis().try_into()?;
millis.try_into()
}
}

@ -1,4 +1,4 @@
use super::event::TouchEvent;
use super::{event::TouchEvent, theme};
use crate::ui::{
component::{Component, Event, EventCtx},
display::{self, Color, Font},
@ -6,6 +6,8 @@ use crate::ui::{
};
pub enum ButtonMsg {
Pressed,
Released,
Clicked,
}
@ -17,21 +19,26 @@ pub struct Button {
}
impl Button {
pub fn new(area: Rect, content: ButtonContent, styles: ButtonStyleSheet) -> Self {
pub fn new(area: Rect, content: ButtonContent) -> Self {
Self {
area,
content,
styles,
styles: theme::button_default(),
state: State::Initial,
}
}
pub fn with_text(area: Rect, text: &'static [u8], styles: ButtonStyleSheet) -> Self {
Self::new(area, ButtonContent::Text(text), styles)
pub fn with_text(area: Rect, text: &'static [u8]) -> Self {
Self::new(area, ButtonContent::Text(text))
}
pub fn with_icon(area: Rect, image: &'static [u8], styles: ButtonStyleSheet) -> Self {
Self::new(area, ButtonContent::Icon(image), styles)
pub fn with_icon(area: Rect, image: &'static [u8]) -> Self {
Self::new(area, ButtonContent::Icon(image))
}
pub fn styled(mut self, styles: ButtonStyleSheet) -> Self {
self.styles = styles;
self
}
pub fn enable(&mut self, ctx: &mut EventCtx) {
@ -42,6 +49,14 @@ impl Button {
self.set(ctx, State::Disabled)
}
pub fn enabled(&mut self, ctx: &mut EventCtx, enabled: bool) {
if enabled {
self.enable(ctx);
} else {
self.disable(ctx);
}
}
pub fn is_enabled(&self) -> bool {
matches!(
self.state,
@ -87,6 +102,7 @@ impl Component for Button {
// Touch started in our area, transform to `Pressed` state.
if self.area.contains(pos) {
self.set(ctx, State::Pressed);
return Some(ButtonMsg::Pressed);
}
}
}
@ -96,10 +112,12 @@ impl Component for Button {
State::Released if self.area.contains(pos) => {
// Touch entered our area, transform to `Pressed` state.
self.set(ctx, State::Pressed);
return Some(ButtonMsg::Pressed);
}
State::Pressed if !self.area.contains(pos) => {
// Touch is leaving our area, transform to `Released` state.
self.set(ctx, State::Released);
return Some(ButtonMsg::Released);
}
_ => {
// Do nothing.
@ -114,7 +132,6 @@ impl Component for Button {
State::Pressed if self.area.contains(pos) => {
// Touch finished in our area, we got clicked.
self.set(ctx, State::Initial);
return Some(ButtonMsg::Clicked);
}
_ => {

@ -1,80 +1,96 @@
use crate::ui::{
component::{Child, Component, Event, EventCtx},
component::{base::ComponentExt, Child, Component, Event, EventCtx},
geometry::{Grid, Rect},
};
use super::button::{Button, ButtonMsg::Clicked};
pub enum DialogMsg<T> {
pub enum DialogMsg<T, L, R> {
Content(T),
LeftClicked,
RightClicked,
Left(L),
Right(R),
}
pub struct Dialog<T> {
pub struct Dialog<T, L, R> {
content: Child<T>,
left_btn: Option<Child<Button>>,
right_btn: Option<Child<Button>>,
left: Child<L>,
right: Child<R>,
}
impl<T: Component> Dialog<T> {
impl<T, L, R> Dialog<T, L, R>
where
T: Component,
L: Component,
R: Component,
{
pub fn new(
area: Rect,
content: impl FnOnce(Rect) -> T,
left: impl FnOnce(Rect) -> Button,
right: impl FnOnce(Rect) -> Button,
left: impl FnOnce(Rect) -> L,
right: impl FnOnce(Rect) -> R,
) -> Self {
let grid = Grid::new(area, 5, 2);
let content = Child::new(content(Rect::new(
grid.row_col(0, 0).top_left(),
grid.row_col(4, 1).bottom_right(),
)));
let left_btn = Child::new(left(grid.row_col(4, 0)));
let right_btn = Child::new(right(grid.row_col(4, 1)));
let layout = DialogLayout::middle(area);
Self {
content,
left_btn: Some(left_btn),
right_btn: Some(right_btn),
content: content(layout.content).into_child(),
left: left(layout.left).into_child(),
right: right(layout.right).into_child(),
}
}
}
impl<T: Component> Component for Dialog<T> {
type Msg = DialogMsg<T::Msg>;
impl<T, L, R> Component for Dialog<T, L, R>
where
T: Component,
L: Component,
R: Component,
{
type Msg = DialogMsg<T::Msg, L::Msg, R::Msg>;
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(msg) = self.content.event(ctx, event) {
Some(DialogMsg::Content(msg))
} else if let Some(Clicked) = self.left_btn.as_mut().and_then(|b| b.event(ctx, event)) {
Some(DialogMsg::LeftClicked)
} else if let Some(Clicked) = self.right_btn.as_mut().and_then(|b| b.event(ctx, event)) {
Some(DialogMsg::RightClicked)
} else {
None
}
self.content
.event(ctx, event)
.map(Self::Msg::Content)
.or_else(|| self.left.event(ctx, event).map(Self::Msg::Left))
.or_else(|| self.right.event(ctx, event).map(Self::Msg::Right))
}
fn paint(&mut self) {
self.content.paint();
if let Some(b) = self.left_btn.as_mut() {
b.paint();
}
if let Some(b) = self.right_btn.as_mut() {
b.paint();
self.left.paint();
self.right.paint();
}
}
struct DialogLayout {
content: Rect,
left: Rect,
right: Rect,
}
impl DialogLayout {
fn middle(area: Rect) -> Self {
let grid = Grid::new(area, 5, 2);
Self {
content: Rect::new(
grid.row_col(0, 0).top_left(),
grid.row_col(4, 1).bottom_right(),
),
left: grid.row_col(4, 0),
right: grid.row_col(4, 1),
}
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for Dialog<T>
impl<T, L, R> crate::trace::Trace for Dialog<T, L, R>
where
T: crate::trace::Trace,
L: crate::trace::Trace,
R: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.open("Dialog");
t.field("content", &self.content);
t.field("left", &self.left_btn);
t.field("right", &self.right_btn);
t.field("left", &self.left);
t.field("right", &self.right);
t.close();
}
}

@ -1,11 +1,12 @@
use core::time::Duration;
use heapless::Vec;
use crate::ui::{
component::{Child, Component, Event, EventCtx, Never, TimerToken},
display,
geometry::{Grid, Rect},
use crate::{
time::Duration,
ui::{
component::{base::ComponentExt, Child, Component, Event, EventCtx, Never, TimerToken},
display,
geometry::{Grid, Rect},
},
};
use super::{
@ -35,10 +36,18 @@ struct Pending {
timer: TimerToken,
}
const MAX_LENGTH: usize = 50;
const STARTING_PAGE: usize = 1;
const PAGES: usize = 4;
const KEYS: usize = 10;
#[rustfmt::skip]
const KEYBOARD: [[&str; KEYS]; PAGES] = [
["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 PENDING_DEADLINE: Duration = Duration::from_secs(1);
impl PassphraseKeyboard {
@ -50,17 +59,13 @@ impl PassphraseKeyboard {
let text = Vec::new();
let page_swipe = Swipe::horizontal(area);
let textbox = Child::new(TextBox::new(textbox_area, text));
let confirm_btn = Child::new(Button::with_text(
confirm_btn_area,
b"Confirm",
theme::button_confirm(),
));
let back_btn = Child::new(Button::with_text(
back_btn_area,
b"Back",
theme::button_clear(),
));
let textbox = TextBox::new(textbox_area, text).into_child();
let confirm_btn = Button::with_text(confirm_btn_area, b"Confirm")
.styled(theme::button_confirm())
.into_child();
let back_btn = Button::with_text(back_btn_area, b"Back")
.styled(theme::button_clear())
.into_child();
let key_btns = Self::generate_keyboard(&key_grid);
Self {
@ -75,38 +80,16 @@ impl PassphraseKeyboard {
}
fn generate_keyboard(grid: &Grid) -> [[Child<Button>; KEYS]; PAGES] {
[
Self::generate_key_page(grid, 0),
Self::generate_key_page(grid, 1),
Self::generate_key_page(grid, 2),
Self::generate_key_page(grid, 3),
]
// can't use a range because the result is a fixed-size array
[0, 1, 2, 3].map(|i| Self::generate_key_page(grid, i))
}
fn generate_key_page(grid: &Grid, page: usize) -> [Child<Button>; KEYS] {
[
Self::generate_key(grid, page, 0),
Self::generate_key(grid, page, 1),
Self::generate_key(grid, page, 2),
Self::generate_key(grid, page, 3),
Self::generate_key(grid, page, 4),
Self::generate_key(grid, page, 5),
Self::generate_key(grid, page, 6),
Self::generate_key(grid, page, 7),
Self::generate_key(grid, page, 8),
Self::generate_key(grid, page, 9),
]
// can't use a range because the result is a fixed-size array
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(|i| Self::generate_key(grid, page, i))
}
fn generate_key(grid: &Grid, page: usize, key: usize) -> Child<Button> {
#[rustfmt::skip]
const KEYBOARD: [[&str; KEYS]; PAGES] = [
["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", "*#"],
["_<>", ".:@", "/|\\", "!()", "+%&", "-[]", "?{}", ",'`", ";\"~", "$^="],
];
// Assign the keys in each page to buttons on a 5x3 grid, starting from the
// second row.
let area = grid.cell(if key < 9 {
@ -119,9 +102,9 @@ impl PassphraseKeyboard {
let text = KEYBOARD[page][key].as_bytes();
if text == b" " {
let icon = theme::ICON_SPACE;
Child::new(Button::with_icon(area, icon, theme::button_default()))
Child::new(Button::with_icon(area, icon))
} else {
Child::new(Button::with_text(area, text, theme::button_default()))
Child::new(Button::with_text(area, text))
}
}
@ -283,7 +266,7 @@ impl TextBox {
if self.text.push(char).is_err() {
// Should not happen unless `self.text` has zero capacity.
#[cfg(feature = "ui_debug")]
panic!("Textbox has zero capacity");
panic!("textbox has zero capacity");
}
ctx.request_paint();
}
@ -292,7 +275,7 @@ impl TextBox {
if self.text.push(char).is_err() {
// `self.text` is full, ignore this change.
#[cfg(feature = "ui_debug")]
panic!("Textbox is full");
panic!("textbox is full");
}
ctx.request_paint();
}

@ -4,6 +4,7 @@ use crate::{
trezorhal::random,
ui::{
component::{
base::ComponentExt,
label::{Label, LabelStyle},
Child, Component, Event, EventCtx, Never,
},
@ -57,29 +58,20 @@ impl PinDialog {
minor_prompt,
theme::label_default(),
);
let dots = Child::new(PinDots::new(
grid.row_col(0, 0),
digits.len(),
theme::label_default(),
));
let dots =
PinDots::new(grid.row_col(0, 0), digits.len(), theme::label_default()).into_child();
// Control buttons.
let grid = Grid::new(area, 5, 3);
let reset_btn = Child::new(Button::with_text(
grid.row_col(4, 0),
b"Reset",
theme::button_clear(),
));
let cancel_btn = Child::new(Button::with_icon(
grid.row_col(4, 0),
theme::ICON_CANCEL,
theme::button_cancel(),
));
let confirm_btn = Child::new(Button::with_icon(
grid.row_col(4, 2),
theme::ICON_CONFIRM,
theme::button_clear(),
));
let reset_btn = Button::with_text(grid.row_col(4, 0), b"Reset")
.styled(theme::button_clear())
.into_child();
let cancel_btn = Button::with_icon(grid.row_col(4, 0), theme::ICON_CANCEL)
.styled(theme::button_cancel())
.into_child();
let confirm_btn = Button::with_icon(grid.row_col(4, 2), theme::ICON_CONFIRM)
.styled(theme::button_clear())
.into_child();
// PIN digit buttons.
let digit_btns = Self::generate_digit_buttons(&grid);
@ -111,7 +103,7 @@ impl PinDialog {
i + 1 + 3
});
let text: &[u8; 1] = digits[i];
Child::new(Button::with_text(area, text, theme::button_default()))
Child::new(Button::with_text(area, text))
};
[
btn(0),
@ -128,25 +120,17 @@ impl PinDialog {
}
fn pin_modified(&mut self, ctx: &mut EventCtx) {
let is_full = self.digits.is_full();
for btn in &mut self.digit_btns {
let is_full = self.digits.is_full();
btn.mutate(ctx, |ctx, btn| {
if is_full {
btn.disable(ctx);
} else {
btn.enable(ctx);
}
});
}
if self.digits.is_empty() {
self.reset_btn.mutate(ctx, |ctx, btn| btn.disable(ctx));
self.cancel_btn.mutate(ctx, |ctx, btn| btn.enable(ctx));
self.confirm_btn.mutate(ctx, |ctx, btn| btn.disable(ctx));
} else {
self.reset_btn.mutate(ctx, |ctx, btn| btn.enable(ctx));
self.cancel_btn.mutate(ctx, |ctx, btn| btn.disable(ctx));
self.confirm_btn.mutate(ctx, |ctx, btn| btn.enable(ctx));
btn.mutate(ctx, |ctx, btn| btn.enabled(ctx, !is_full));
}
let is_empty = self.digits.is_empty();
self.reset_btn
.mutate(ctx, |ctx, btn| btn.enabled(ctx, !is_empty));
self.cancel_btn
.mutate(ctx, |ctx, btn| btn.enabled(ctx, is_empty));
self.confirm_btn
.mutate(ctx, |ctx, btn| btn.enabled(ctx, !is_empty));
let digit_count = self.digits.len();
self.dots
.mutate(ctx, |ctx, dots| dots.update(ctx, digit_count));

@ -4,8 +4,13 @@ use crate::{error, ui::geometry::Point};
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum TouchEvent {
/// A person has started touching the screen at given absolute coordinates.
/// `TouchMove` will usually follow, and `TouchEnd` should finish the
/// interaction.
TouchStart(Point),
/// Touch has moved into a different point on the screen.
TouchMove(Point),
/// Touch has ended at a point on the screen.
TouchEnd(Point),
}

@ -12,22 +12,23 @@ use crate::{
};
use super::{
component::{Button, Dialog, DialogMsg},
component::{Button, ButtonMsg, Dialog, DialogMsg},
theme,
};
impl<T> TryFrom<DialogMsg<T>> for Obj
impl<T> TryFrom<DialogMsg<T, ButtonMsg, ButtonMsg>> for Obj
where
Obj: TryFrom<T>,
Error: From<<T as TryInto<Obj>>::Error>,
Error: From<<Obj as TryFrom<T>>::Error>,
{
type Error = Error;
fn try_from(val: DialogMsg<T>) -> Result<Self, Self::Error> {
fn try_from(val: DialogMsg<T, ButtonMsg, ButtonMsg>) -> Result<Self, Self::Error> {
match val {
DialogMsg::Content(c) => Ok(c.try_into()?),
DialogMsg::LeftClicked => 1.try_into(),
DialogMsg::RightClicked => 2.try_into(),
DialogMsg::Left(ButtonMsg::Clicked) => 1.try_into(),
DialogMsg::Right(ButtonMsg::Clicked) => 2.try_into(),
_ => Ok(Obj::const_none()),
}
}
}
@ -43,8 +44,8 @@ extern "C" fn ui_layout_new_example(param: Obj) -> Obj {
.with(b"some", "a few")
.with(b"param", "xx")
},
|area| Button::with_text(area, b"Left", theme::button_default()),
|area| Button::with_text(area, b"Right", theme::button_default()),
|area| Button::with_text(area, b"Left"),
|area| Button::with_text(area, b"Right"),
)))?;
Ok(layout.into())
};
@ -105,8 +106,8 @@ mod tests {
)
.with(b"param", b"parameters!")
},
|area| Button::with_text(area, b"Left", theme::button_default()),
|area| Button::with_text(area, b"Right", theme::button_default()),
|area| Button::with_text(area, b"Left"),
|area| Button::with_text(area, b"Right"),
));
assert_eq!(
trace(&layout),

Loading…
Cancel
Save