add textentry implementation

includes a simple on-screen keyboard element
This commit is contained in:
bunnie 2025-04-28 18:18:57 +08:00
parent 87b4b18210
commit e690da3f77
2 changed files with 255 additions and 39 deletions

View file

@ -1,8 +1,11 @@
use core::cell::Cell;
mod en;
use core::fmt::Write;
use std::cell::RefCell;
use locales::t;
use super::*;
use crate::minigfx::*;
use crate::service::api::Gid;
const MAX_FIELDS: i16 = 10;
pub const MAX_ITEMS: usize = 8;
@ -31,8 +34,9 @@ pub enum TextEntryVisibility {
#[derive(Clone)]
pub struct TextEntry {
pub is_password: bool,
pub visibility: TextEntryVisibility,
pub items: ScrollableList,
// on-screen keyboard implementation
pub osk: ScrollableList,
pub action_conn: xous::CID,
pub action_opcode: u32,
// validator borrows the text entry payload, and returns an error message if something didn't go well.
@ -40,42 +44,49 @@ pub struct TextEntry {
pub validator: Option<fn(TextEntryPayload, u32) -> Option<ValidatorErr>>,
pub action_payloads: Vec<TextEntryPayload>,
osk_active: bool,
osk_entry: String,
osk_cursor: usize,
max_field_amount: u32,
selected_field: i16,
field_height: Cell<i16>,
/// track if keys were hit since initialized: this allows us to clear the default text,
/// instead of having it re-appear every time the text area is cleared
keys_hit: [bool; MAX_FIELDS as usize],
// gam: crate::Gam, // no GAM field because this needs to be a clone-capable structure. We create a GAM
// handle when we need it.
/// Stores the allowed height of a given text line, based on the contents and the space available
/// in the box. The height of a given line may be limited to make sure there is enough space for
/// later lines to be rendered.
action_payloads_allowed_heights: RefCell<Vec<i16>>,
}
impl Default for TextEntry {
fn default() -> Self {
let mut sl = ScrollableList::default().set_margin(Point::new(4, 0));
let br = sl.pane().br();
let row_height = sl.row_height();
sl = sl.pane_size(Rectangle::new(Point::new(0, row_height as isize + 2), br));
let mut osk = ScrollableList::default();
match locales::LANG {
"en" => {
let osk_matrix = en::OSK_MATRIX;
for (col_num, col) in osk_matrix.iter().enumerate() {
for row in col {
osk.add_item(col_num, row);
}
}
}
_ => unimplemented!("OSK matrix not yet implemented for language target"),
}
Self {
is_password: Default::default(),
visibility: TextEntryVisibility::Visible,
items: sl,
osk,
osk_entry: String::new(),
osk_cursor: 0,
osk_active: false,
action_conn: Default::default(),
action_opcode: Default::default(),
validator: Default::default(),
selected_field: Default::default(),
action_payloads: Default::default(),
max_field_amount: 0,
field_height: Cell::new(0),
keys_hit: [false; MAX_FIELDS as usize],
action_payloads_allowed_heights: RefCell::new(Vec::new()),
}
}
}
impl TextEntry {
pub fn new(
is_password: bool,
visibility: TextEntryVisibility,
_visibility: TextEntryVisibility,
action_conn: xous::CID,
action_opcode: u32,
action_payloads: Vec<TextEntryPayload>,
@ -85,15 +96,13 @@ impl TextEntry {
panic!("can't have more than {} fields, found {}", MAX_FIELDS, action_payloads.len());
}
Self {
is_password,
visibility,
action_conn,
action_opcode,
action_payloads,
validator,
..Default::default()
let mut te = Self { action_conn, action_opcode, action_payloads, validator, ..Default::default() };
for payload in te.action_payloads.iter() {
te.items.add_item(0, payload.as_str());
}
te.items.add_item(0, t!("radio.select_and_close", locales::LANG));
te
}
pub fn reset_action_payloads(&mut self, fields: u32, placeholders: Option<[Option<(String, bool)>; 10]>) {
@ -114,17 +123,164 @@ impl TextEntry {
self.action_payloads = payload;
self.max_field_amount = fields;
self.keys_hit = [false; MAX_FIELDS as usize];
}
self.osk_entry.clear();
self.osk_cursor = 0;
fn get_bullet_margin(&self) -> i16 {
if self.action_payloads.len() > 1 {
17 // this is the margin for drawing the selection bullet
} else {
0 // no selection bullet
self.items.clear();
for payload in self.action_payloads.iter() {
if let Some(placeholder) = &payload.placeholder {
self.items.add_item(0, &placeholder);
} else {
self.items.add_item(0, "");
}
}
self.items.add_item(0, t!("radio.select_and_close", locales::LANG));
}
}
use crate::widgets::ActionApi;
impl ActionApi for TextEntry {}
impl ActionApi for TextEntry {
fn set_action_opcode(&mut self, op: u32) { self.action_opcode = op }
fn height(&self, _glyph_height: isize, _margin: isize, _modal: &Modal) -> isize {
((1 + self.items.len()) * self.items.row_height()) as isize
}
fn redraw(&self, at_height: isize, _modal: &Modal) {
if !self.osk_active {
self.items.draw(at_height);
} else {
let pane = self.items.pane();
// draw the active text in entry
let mut tv = TextView::new(
Gid::dummy(),
TextBounds::BoundingBox(Rectangle::new(
pane.tl(),
pane.tl() + Point::new(pane.width() as isize, self.osk.row_height() as isize),
)),
);
tv.margin = Point::new(0, 0);
tv.style = self.osk.get_style();
tv.ellipsis = true;
tv.invert = false;
tv.draw_border = false;
tv.write_str(&self.osk_entry).ok();
tv.insertion = Some(self.osk_cursor as i32);
self.osk.gfx.draw_textview(&mut tv).ok();
// now draw the OSK
self.osk.draw(at_height + self.osk.row_height() as isize);
}
}
fn key_action(&mut self, k: char) -> Option<ValidatorErr> {
if !self.osk_active {
log::trace!("key_action: {}", k);
match k {
'←' | '→' | '↑' | '↓' => {
self.items.key_action(k);
None
}
'∴' | '\u{d}' => {
if self.items.get_selected_index().1 == self.items.len() - 1 {
// return and close - validate all fields and replace with defaults if 0-length
self.items.gfx.release_modal().unwrap();
xous::yield_slice();
let mut payloads: TextEntryPayloads = Default::default();
payloads.1 = self.max_field_amount as usize;
for (i, (dst, src)) in payloads.0[..self.max_field_amount as usize]
.iter_mut()
.zip(self.items.get_column(0).unwrap().iter())
.enumerate()
{
let mut payload = self.action_payloads[i].clone();
payload.content = src.to_owned();
*dst = payload;
}
let buf = xous_ipc::Buffer::into_buf(payloads)
.expect("couldn't convert message to payload");
buf.send(self.action_conn, self.action_opcode)
.map(|_| ())
.expect("couldn't send action message");
for payload in self.action_payloads.iter_mut() {
payload.volatile_clear();
}
None
} else {
// selected a field to go into osk mode
self.osk_entry = self.items.get_selected().to_owned();
self.osk_cursor = self.osk_entry.chars().count(); // park the cursor at the end
self.osk_active = true;
None
}
}
_ => None,
}
} else {
// OSK handler
match k {
'←' | '→' | '↑' | '↓' => {
self.osk.key_action(k);
}
// select the character
'∴' => {
let action = self.osk.get_selected();
if action == "⬅" {
if self.osk_cursor == 0 {
// replace the field with the defaults if we try to backspace in an empty field
self.osk_entry = self.items.get_selected().to_owned();
self.osk_cursor = self.osk_entry.chars().count(); // park the cursor at the end
} else {
if let Some((byte_start, ch)) =
self.osk_entry.char_indices().nth(self.osk_cursor.saturating_sub(1))
{
let byte_end = byte_start + ch.len_utf8();
self.osk_entry.replace_range(byte_start..byte_end, "");
}
self.osk_cursor = self.osk_cursor.saturating_sub(1);
}
} else {
if let Some(byte_pos) =
self.osk_entry.char_indices().nth(self.osk_cursor).map(|(i, _)| i).or_else(|| {
if self.osk_cursor == self.osk_entry.chars().count() {
Some(self.osk_entry.len())
} else {
None
}
})
{
self.osk_entry.insert_str(byte_pos, action);
} else {
self.osk_entry.push_str(action);
}
// increment the cursor position
self.osk_cursor = (self.osk_cursor + 1).min(self.osk_entry.chars().count());
}
}
// end the session by pressing the middle screen button
'\u{d}' => {
self.osk_active = false;
// run the validator
let validation_copy = TextEntryPayload::new_with_fields(self.osk_entry.to_owned(), None);
if let Some(validator) = self.validator {
if let Some(err_msg) = validator(validation_copy, self.action_opcode) {
// return with error, and don't change the original fields
return Some(err_msg);
}
}
self.action_payloads[self.items.get_selected_index().1].dirty = true;
self.items.update_selected(&self.osk_entry);
}
_ => {
// ignore everything else
}
}
None
}
}
}

View file

@ -0,0 +1,60 @@
/*
ASCII charset OSK.
This is the full ASCII character set:
0 @ P ` p
! 1 A Q a q
" 2 B R b r
# 3 C S c s
$ 4 D T d t
% 5 E U e u
& 6 F V f v
' 7 G W g w
( 8 H X h x
) 9 I Y i y
* : J Z j z
+ ; K [ k {
, < L \ l |
- = M ] m }
. > N ^ n ~
/ ? O _ o DEL
Reformat them by top characters
used in English language, then
common punctuations on the Internet
(http://, base-64).
Then format with ChatGPT using this exact query:
Take this matrix of characters:
p b v k j x
e t a o i n
s h r d l c
u m w f g y
q z . , !
' " ? ( ) :
@ / & = + ⬅
1 2 3 4 5 6
7 8 9 0 ; -
P B V K J X
E T A O I N
S H R D L C
U M W F G Y
Q Z _ < > ~
* \ [ ] { }
| ^ $ # % `
and format them into a Rust structure defined as [[&'static str; 16]; 6],
suitable for use as a pub const in a header file.
*/
pub const OSK_MATRIX: [[&'static str; 16]; 6] = [
["p", "e", "s", "u", "q", "'", "@", "1", "7", "P", "E", "S", "U", "Q", "*", "|"],
["b", "t", "h", "m", "z", "\"", "/", "2", "8", "B", "T", "H", "M", "Z", "\\", "^"],
["v", "a", "r", "w", ".", "?", "&", "3", "9", "V", "A", "R", "W", "_", "[", "$"],
["k", "o", "d", "f", ",", "(", "=", "4", "0", "K", "O", "D", "F", "<", "]", "#"],
["j", "i", "l", "g", " ", ")", "+", "5", ";", "J", "I", "L", "G", ">", "{", "%"],
["x", "n", "c", "y", "!", ":", "⬅", "6", "-", "X", "N", "C", "Y", "~", "}", "`"],
];