xous-core/services/gam/src/modal.rs
bunnie 6a1271e890 add persistent default text for issue #281
this adds a method field_placeholder_persist() to the
text entry box builder. When this is used (instead of
field()), the placeholder, if provided, is treated as
"persistent" data, that is, when you press a key, it is
not automatically cleared, but instead, the user starts
editing the existing data.
2022-11-30 14:58:23 +08:00

614 lines
23 KiB
Rust

//! The main API entry point is the `Modal` struct. Click into the struct for more details.
mod textentry;
pub use textentry::*;
mod radiobuttons;
pub use radiobuttons::*;
mod checkboxes;
pub use checkboxes::*;
mod notification;
pub use notification::*;
mod slider;
pub use slider::*;
mod progressbar;
pub use progressbar::*;
mod consoleinput;
pub use consoleinput::*;
#[cfg(feature="ditherpunk")]
mod image;
#[cfg(feature="ditherpunk")]
pub use image::*;
mod bip39entry;
pub use bip39entry::*;
use enum_dispatch::enum_dispatch;
use crate::api::*;
use crate::Gam;
use crate::MsgForwarder;
use graphics_server::api::*;
pub use graphics_server::api::GlyphStyle;
use xous_ipc::{String, Buffer};
use num_traits::*;
use core::fmt::Write;
pub const MAX_ITEMS: usize = 8;
#[enum_dispatch(ActionApi)]
pub enum ActionType {
TextEntry,
Bip39Entry,
RadioButtons,
CheckBoxes,
Slider,
Notification,
#[cfg(feature="ditherpunk")]
Image,
ConsoleInput
}
#[enum_dispatch]
pub trait ActionApi {
fn height(&self, glyph_height: i16, margin: i16) -> i16 {glyph_height + margin * 2}
fn redraw(&self, _at_height: i16, _modal: &Modal) { unimplemented!() }
fn close(&mut self) {}
fn is_password(&self) -> bool { false }
/// navigation is one of '∴' | '←' | '→' | '↑' | '↓'
fn key_action(&mut self, _key: char) -> Option<ValidatorErr> {None}
fn set_action_opcode(&mut self, _op: u32) {}
}
#[derive(Debug, num_derive::FromPrimitive, num_derive::ToPrimitive)]
pub enum ModalOpcode { // if changes are made here, also update MenuOpcode
Redraw = 0x4000_0000, // set the high bit so that "standard" enums don't conflict with the Modal-specific opcodes
Rawkeys,
Quit,
}
/// We use a new type for item names, so that it's easy to resize this as needed.
#[derive(Debug, Copy, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct ItemName(String::<64>);
impl ItemName {
pub fn new(name: &str) -> Self {
ItemName(String::<64>::from_str(name))
}
pub fn as_str(&self) -> &str {
self.0.as_str().expect("couldn't convert item into string")
}
}
#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, Copy, Clone, Eq, PartialEq, Default)]
pub struct Bip39EntryPayload {
// up to 32 bytes (256 bits) could be entered
pub data: [u8; 32],
// the actual length entered is reported here
pub len: u32,
}
#[derive(Debug, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, Copy, Clone, Eq, PartialEq, Default)]
pub struct TextEntryPayload {
dirty: bool,
pub content: String::<256>,
pub placeholder: Option<String::<256>>,
pub placeholder_persist: bool,
}
impl TextEntryPayload {
pub fn new() -> Self {
TextEntryPayload{
dirty: Default::default(),
content: Default::default(),
placeholder: Default::default(),
placeholder_persist: false,
}
}
pub fn new_with_fields(content: String::<256>, placeholder: Option<String::<256>>) -> Self {
TextEntryPayload { dirty: false, content: content, placeholder: placeholder, placeholder_persist: false }
}
/// Ensures that 0's are written to the storage of this struct, and not optimized out; important for password fields.
pub fn volatile_clear(&mut self) {
self.content.volatile_clear();
}
pub fn as_str(&self) -> &str {
self.content.as_str().expect("couldn't convert textentry string")
}
}
#[derive(Debug, Copy, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct SliderPayload(pub u32);
impl SliderPayload {
pub fn new(value: u32) -> Self {
SliderPayload(value)
}
}
#[derive(Debug, Copy, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct RadioButtonPayload(pub ItemName); // returns the name of the item corresponding to the radio button selection
impl RadioButtonPayload {
pub fn new(name: &str) -> Self {
RadioButtonPayload(ItemName::new(name))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn clear(&mut self) {
self.0.0.clear();
}
}
#[derive(Debug, Copy, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct CheckBoxPayload(pub [Option<ItemName>; MAX_ITEMS]); // returns a list of potential items that could be selected
impl CheckBoxPayload {
pub fn new() -> Self {
CheckBoxPayload([None; MAX_ITEMS])
}
pub fn payload(&self) -> [Option<ItemName>; MAX_ITEMS] {
self.0
}
pub fn contains(&self, name: &str) -> bool {
for maybe_item in self.0.iter() {
if let Some(item) = maybe_item {
if item.as_str() == name {
return true;
}
}
}
false
}
pub fn add(&mut self, name: &str) -> bool {
if self.contains(name) {
return true
}
for maybe_item in self.0.iter_mut() {
if maybe_item.is_none() {
*maybe_item = Some(ItemName::new(name));
return true;
}
}
false
}
pub fn remove(&mut self, name: &str) -> bool {
for maybe_item in self.0.iter_mut() {
if let Some(item) = maybe_item {
if item.as_str() == name {
*maybe_item = None;
return true;
}
}
}
false
}
}
//#[derive(Debug)]
pub struct Modal<'a> {
pub sid: xous::SID,
pub gam: Gam,
pub xns: xous_names::XousNames,
pub top_text: Option<TextView>,
pub bot_text: Option<TextView>,
pub action: ActionType,
//pub index: usize, // currently selected item
pub canvas: Gid,
pub authtoken: [u32; 4],
pub margin: i16,
pub line_height: i16,
pub canvas_width: i16,
pub inverted: bool,
pub style: GlyphStyle,
pub helper_data: Option<Buffer<'a>>,
pub name: String::<128>,
// optimize draw time
top_dirty: bool,
top_memoized_height: Option<i16>,
bot_dirty: bool,
bot_memoized_height: Option<i16>,
}
fn recompute_canvas(modal: &mut Modal, top_text: Option<&str>, bot_text: Option<&str>, style: GlyphStyle) {
// we need to set a "max" size to our modal box, so that the text computations don't fail later on
let current_bounds = modal.gam.get_canvas_bounds(modal.canvas).expect("couldn't get current bounds");
// method:
// - we assume the GAM gives us an initial modal with a "maximum" height setting
// - items are populated within this maximal canvas setting, and then the actual height needed is computed
// - the canvas is resized to this actual height
// problems:
// - there is no sanity check on the size of the text boxes. So if you give the UX element a top_text box that's
// huge, it will just overflow the canvas size and nothing else will get drawn.
let mut total_height = modal.margin;
log::trace!("step 0 total_height: {}", total_height);
// compute height of top_text, if any
if let Some(top_str) = top_text {
let mut top_tv = TextView::new(modal.canvas,
TextBounds::GrowableFromTl(
Point::new(modal.margin, modal.margin),
(modal.canvas_width - modal.margin * 2) as u16
));
top_tv.draw_border = false;
top_tv.style = style;
top_tv.margin = Point::new(0, 0,); // all margin already accounted for in the raw bounds of the text drawing
top_tv.ellipsis = false;
top_tv.invert = modal.inverted;
// specify a clip rect that's the biggest possible allowed. If we don't do this, the current canvas
// bounds are used, and the operation will fail if the text has to get bigger.
top_tv.clip_rect = Some(Rectangle::new(Point::new(0, 0), Point::new(current_bounds.x, crate::api::MODAL_Y_MAX - 2 * modal.line_height)));
write!(top_tv.text, "{}", top_str).unwrap();
log::trace!("posting top tv: {:?}", top_tv);
modal.gam.bounds_compute_textview(&mut top_tv).expect("couldn't simulate top text size");
if let Some(bounds) = top_tv.bounds_computed {
log::trace!("top_tv bounds computed {}", bounds.br.y - bounds.tl.y);
total_height += bounds.br.y - bounds.tl.y;
} else {
log::warn!("couldn't compute height for modal top_text: {:?}", top_tv);
// probably should find a better way to deal with this.
total_height += crate::api::MODAL_Y_MAX - (modal.line_height * 2);
}
modal.top_text = Some(top_tv);
}
total_height += modal.margin;
// compute height of action item
log::trace!("step 1 total_height: {}", total_height);
total_height += modal.action.height(modal.line_height, modal.margin);
total_height += modal.margin;
// compute height of bot_text, if any
log::trace!("step 2 total_height: {}", total_height);
if let Some(bot_str) = bot_text {
let mut bot_tv = TextView::new(modal.canvas,
TextBounds::GrowableFromTl(
Point::new(modal.margin, total_height),
(modal.canvas_width - modal.margin * 2) as u16
));
bot_tv.draw_border = false;
bot_tv.style = style;
bot_tv.margin = Point::new(0, 0,); // all margin already accounted for in the raw bounds of the text drawing
bot_tv.ellipsis = false;
bot_tv.invert = modal.inverted;
// specify a clip rect that's the biggest possible allowed. If we don't do this, the current canvas
// bounds are used, and the operation will fail if the text has to get bigger.
bot_tv.clip_rect = Some(Rectangle::new(Point::new(0, 0), Point::new(current_bounds.x, crate::api::MODAL_Y_MAX - 2 * modal.line_height)));
write!(bot_tv.text, "{}", bot_str).unwrap();
log::trace!("posting bot tv: {:?}", bot_tv);
modal.gam.bounds_compute_textview(&mut bot_tv).expect("couldn't simulate bot text size");
if let Some(bounds) = bot_tv.bounds_computed {
total_height += bounds.br.y - bounds.tl.y;
} else {
log::error!("couldn't compute height for modal bot_text: {:?}", bot_tv);
panic!("couldn't compute height for modal bot_text");
}
modal.bot_text = Some(bot_tv);
total_height += modal.margin;
}
log::trace!("step 3 total_height: {}", total_height);
let current_bounds = modal.gam.get_canvas_bounds(modal.canvas).expect("couldn't get current bounds");
let mut new_bounds = SetCanvasBoundsRequest {
requested: Point::new(current_bounds.x, total_height),
granted: None,
token_type: TokenType::App,
token: modal.authtoken,
};
// don't send the request if there is no change in the size of things. This is because the request is expensive -- it will
// result in a redraw of everything, plus defacement, etc.
if new_bounds.requested != current_bounds {
log::debug!("applying recomputed bounds of {:?}", new_bounds);
modal.gam.set_canvas_bounds_request(&mut new_bounds).expect("couldn't call set bounds");
}
}
impl<'a> Modal<'a> {
pub fn new(name: &str, action: ActionType, top_text: Option<&str>, bot_text: Option<&str>, style: GlyphStyle, margin: i16) -> Modal<'a> {
let xns = xous_names::XousNames::new().unwrap();
let sid = xous::create_server().expect("can't create private modal message server");
let gam = Gam::new(&xns).expect("can't connect to GAM");
let authtoken = gam.register_ux(
UxRegistration {
app_name: String::<128>::from_str(name),
ux_type: UxType::Modal,
predictor: None,
listener: sid.to_array(),
redraw_id: ModalOpcode::Redraw.to_u32().unwrap(),
gotinput_id: None,
audioframe_id: None,
focuschange_id: None, // should always be none because we're not an app
rawkeys_id: Some(ModalOpcode::Rawkeys.to_u32().unwrap()),
}
).expect("couldn't register my Ux element with GAM");
assert!(authtoken.is_some(), "Couldn't register modal. Did you remember to add the app_name to the tokens.rs expected boot contexts list?");
log::debug!("requesting content canvas for modal");
let canvas = gam.request_content_canvas(authtoken.unwrap()).expect("couldn't get my content canvas from GAM");
let line_height = if xous::LANG == "zh" {
// zh has no "small" style
gam.glyph_height_hint(GlyphStyle::Regular).expect("couldn't get glyph height hint") as i16
} else {
gam.glyph_height_hint(style).expect("couldn't get glyph height hint") as i16
};
let canvas_bounds = gam.get_canvas_bounds(canvas).expect("couldn't get starting canvas bounds");
log::trace!("initializing Modal structure");
// check to see if this is a password field or not
// note: if a modal claims it's a password field but lacks sufficient trust level, the GAM will refuse
// to render the element.
let inverted = match action {
ActionType::TextEntry(_) => action.is_password(),
_ => false
};
// we now have a canvas that is some minimal height, but with the final width as allowed by the GAM.
// compute the final height based upon the contents within.
let mut modal = Modal {
sid,
gam,
xns,
top_text: None,
bot_text: None,
action,
canvas,
authtoken: authtoken.unwrap(),
margin,
line_height,
canvas_width: canvas_bounds.x, // memoize this, it shouldn't change
inverted,
style,
helper_data: None,
name: String::<128>::from_str(name),
top_dirty: true,
bot_dirty: true,
top_memoized_height: None,
bot_memoized_height: None,
};
recompute_canvas(&mut modal, top_text, bot_text, style);
modal
}
pub fn activate(&self) {
const POLL_DELAY_MS: usize = 857;
match self.gam.raise_modal(self.name.to_str()) {
Ok(_) => (),
Err(_) => {
std::thread::spawn({
let name = self.name.clone();
move || {
let xns = xous_names::XousNames::new().unwrap();
let ticktimer = ticktimer_server::Ticktimer::new().unwrap();
ticktimer.sleep_ms(POLL_DELAY_MS).unwrap();
let gam = crate::Gam::new(&xns).unwrap();
while gam.raise_modal(name.to_str()).is_err() {
log::info!("Couldn't raise {}; retrying...", name);
ticktimer.sleep_ms(POLL_DELAY_MS).unwrap();
}
log::info!("Success! exiting polling thread.");
}
});
}
}
}
/// this function spawns a client-side thread to forward redraw and key event
/// messages on to a local server. The goal is to keep the local server's SID
/// a secret. The GAM only knows the single-use SID for redraw commands; this
/// isolates a server's private command set from the GAM.
pub fn spawn_helper(&mut self, private_sid: xous::SID, public_sid: xous::SID, redraw_op: u32, rawkeys_op: u32, drop_op: u32) {
let helper_data = MsgForwarder {
private_sid: private_sid.to_array(),
public_sid: public_sid.to_array(),
redraw_op,
rawkeys_op,
drop_op
};
let buf = Buffer::into_buf(helper_data).expect("couldn't allocate helper data for helper thread");
let (addr, size, offset) = unsafe{buf.to_raw_parts()};
self.helper_data = Some(buf);
xous::create_thread_3(crate::forwarding_thread, addr, size, offset).expect("couldn't spawn a helper thread");
}
pub fn redraw(&mut self) {
const BORDER_WIDTH: i16 = 3;
log::debug!("modal redraw");
let canvas_size = self.gam.get_canvas_bounds(self.canvas).unwrap();
let do_redraw = self.top_dirty || self.bot_dirty || self.inverted;
// draw the outer border
if do_redraw {
self.gam.draw_rounded_rectangle(self.canvas,
RoundedRectangle::new(
Rectangle::new_with_style(Point::new(0, 0), canvas_size,
DrawStyle::new(if self.inverted{PixelColor::Dark} else {PixelColor::Light}, PixelColor::Dark, BORDER_WIDTH)
), 5
)).unwrap();
}
let mut cur_height = self.margin;
if let Some(mut tv) = self.top_text {
if do_redraw {
self.gam.post_textview(&mut tv).expect("couldn't draw text");
if let Some(bounds) = tv.bounds_computed {
let y = bounds.br.y - bounds.tl.y;
let y_clip = if y > MODAL_Y_MAX - self.line_height * 3 {
log::warn!("overside text, clipping back {}", MODAL_Y_MAX - (self.line_height * 2));
MODAL_Y_MAX - (self.line_height * 2)
} else {
y
};
cur_height += y_clip;
log::trace!("top_tv height: {}", y_clip);
self.top_memoized_height = Some(y_clip);
} else {
log::warn!("text bounds didn't compute setting to max");
self.top_memoized_height = Some(MODAL_Y_MAX - (self.line_height * 2));
}
self.top_dirty = false;
} else {
cur_height += self.top_memoized_height.expect("internal error: memoization didn't work correctly");
}
} else {
self.top_dirty = false;
}
let action_height = self.action.height(self.line_height, self.margin);
if !do_redraw {
// the action area wasn't blanked, so blank it as prep for the action redraw
self.gam.draw_rectangle(self.canvas,
Rectangle::new_with_style(Point::new(BORDER_WIDTH, cur_height), Point::new(canvas_size.x - BORDER_WIDTH, cur_height + action_height),
DrawStyle::new(
if self.inverted{PixelColor::Dark} else {PixelColor::Light},
if self.inverted{PixelColor::Dark} else {PixelColor::Light}, 0)
)).unwrap();
}
self.action.redraw(cur_height, &self);
cur_height += action_height;
if let Some(mut tv) = self.bot_text {
if do_redraw {
self.gam.post_textview(&mut tv).expect("couldn't draw text");
if let Some(bounds) = tv.bounds_computed {
cur_height += bounds.br.y - bounds.tl.y;
self.bot_memoized_height = Some(bounds.br.y - bounds.tl.y);
}
self.bot_dirty = false;
} else {
cur_height += self.bot_memoized_height.expect("internal error: memoization didn't work correctly");
}
} else {
self.bot_dirty = false;
}
log::trace!("total height: {}", cur_height);
log::trace!("modal redraw##");
self.gam.redraw().unwrap();
}
pub fn key_event(&mut self, keys: [char; 4]) {
for &k in keys.iter() {
if k != '\u{0}' {
log::debug!("got key '{}'", k);
let err = self.action.key_action(k);
if let Some(err_msg) = err {
self.modify(None, None, false, Some(err_msg.to_str()), false, None);
}
}
}
self.redraw();
}
/// this function will modify UX elements if any of the arguments are Some()
/// if None, the element is unchanged.
/// If a text section is set to remove, but Some() is given for the update, the text is not removed, and instead replaced with the updated text.
pub fn modify(&mut self, update_action: Option<ActionType>,
update_top_text: Option<&str>, remove_top: bool,
update_bot_text: Option<&str>, remove_bot: bool,
update_style: Option<GlyphStyle>) {
if let Some(action) = update_action {
self.action = action;
};
if remove_top {
self.top_dirty = true;
self.top_text = None;
}
if remove_bot {
self.bot_dirty = true;
self.bot_text = None;
}
if update_top_text.is_some() {
self.top_dirty = true;
}
if update_bot_text.is_some() {
self.bot_dirty = true;
}
let mut top_tv_temp = String::<3072>::new(); // size matches that used in TextView
if let Some(top_text) = update_top_text {
write!(top_tv_temp, "{}", top_text).unwrap();
} else {
if let Some(top_text) = self.top_text {
write!(top_tv_temp, "{}", top_text).unwrap();
}
};
let top_text = if self.top_text.is_none() && update_top_text.is_none() {
None
} else {
Some(top_tv_temp.to_str())
};
let mut bot_tv_temp = String::<3072>::new(); // size matches that used in TextView
if let Some(bot_text) = update_bot_text {
write!(bot_tv_temp, "{}", bot_text).unwrap();
} else {
if let Some(bot_text) = self.bot_text {
write!(bot_tv_temp, "{}", bot_text).unwrap();
}
};
let bot_text = if self.bot_text.is_none() && update_bot_text.is_none() {
None
} else {
Some(bot_tv_temp.to_str())
};
let style = if let Some(style) = update_style {
self.top_dirty = true;
self.bot_dirty = true;
style
} else {
self.style
};
recompute_canvas(self, top_text, bot_text, style);
}
}
/*
old notes to remind myself of how I got here.
design ideas
Modal for password request:
---------------------
| Password Type: Updater
| Requester: RootKeys
| Reason: The updater modal has not been set.
| Security Level: Critical
|
| *****4f_
|
| ← 👁️ 🕶️ * →
|--------------------
Item primitives:
- text bubble
- text entry field (with confidentiality option)
- left/right radio select
- up/down radio select
Then simple menu prompt after password entry:
---------------------
| [x] Persist until reboot
| [ ] Persist until suspend
| [ ] Use once
---------------------
General form for modals:
[top text]
[action form]
[bottom text]
- "top text" is an optional TextArea
- "action form" is a mandatory field that handles interactions
- "bottom text" is an optional TextArea
Action form can be exactly one of the following:
- password text field - enter closes the form, has visibility options as left/right arrows; entered text wraps
- regular text field - enter closes the form, visibility is always visible; entered text wraps
- radio buttons - has an explicit "okay" button to close the modal; up/down arrows + select/enter pick the radio
- check boxes - has an explicit "okay" button to close the modal; up/down arrows + select/enter checks boxes
- slider - left/right moves the slider, enter/select closes the modal
*/