use super::{Status, UiContext, UiEvent, UiSound, Value, Widget};
use crate::input::Key;
#[derive(Debug, Clone)]
pub struct Message {
id: String,
text: String,
lines: Vec<String>,
line: usize,
}
impl Message {
pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
let text = text.into();
let lines = text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect();
Self { id: id.into(), text, lines, line: 0 }
}
}
impl Widget for Message {
fn id(&self) -> &str {
&self.id
}
fn open(&mut self, ui: &mut UiContext<'_>) {
ui.play(UiSound::Open, None);
ui.speech.speak(&self.text);
}
fn update(&mut self, ui: &mut UiContext<'_>) -> Status {
let last = self.lines.len().saturating_sub(1);
let target = if ui.key(Key::Up) {
Some(self.line.saturating_sub(1))
} else if ui.key(Key::Down) {
Some((self.line + 1).min(last))
} else if ui.key(Key::Home) {
Some(0)
} else if ui.key(Key::End) {
Some(last)
} else {
None
};
if let Some(t) = target {
ui.play(if t == self.line { UiSound::Edge } else { UiSound::Navigate }, None);
self.line = t;
if let Some(line) = self.lines.get(t) {
ui.speech.speak(line);
}
}
if ui.key(Key::Enter) || ui.key(Key::NumpadEnter) || ui.input.pressed(Key::Space) || ui.key(Key::Escape) {
ui.play(UiSound::Close, None);
ui.emit(UiEvent::Closed { id: self.id.clone() });
return Status::Close;
}
Status::Open
}
}
#[derive(Debug, Clone)]
pub struct Confirm {
id: String,
question: String,
yes: String,
no: String,
selected_yes: bool,
}
impl Confirm {
pub fn new(id: impl Into<String>, question: impl Into<String>) -> Self {
Self { id: id.into(), question: question.into(), yes: "Yes".into(), no: "No".into(), selected_yes: true }
}
pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
self.yes = yes.into();
self.no = no.into();
self
}
pub fn default_no(mut self) -> Self {
self.selected_yes = false;
self
}
fn current(&self) -> &str {
if self.selected_yes { &self.yes } else { &self.no }
}
fn answer(&self, yes: bool, ui: &mut UiContext<'_>) -> Status {
ui.play(if yes { UiSound::Activate } else { UiSound::Close }, None);
ui.emit(UiEvent::Confirmed { id: self.id.clone(), yes });
Status::Close
}
}
fn first_lower(s: &str) -> Option<char> {
s.chars().next().and_then(|c| c.to_lowercase().next())
}
impl Widget for Confirm {
fn id(&self) -> &str {
&self.id
}
fn open(&mut self, ui: &mut UiContext<'_>) {
ui.play(UiSound::Open, None);
ui.speech.speak(format!("{} {}", self.question, self.current()));
}
fn update(&mut self, ui: &mut UiContext<'_>) -> Status {
if ui.key(Key::Up) || ui.key(Key::Down) || ui.key(Key::Left) || ui.key(Key::Right) || ui.key(Key::Tab) {
self.selected_yes = !self.selected_yes;
ui.play(UiSound::Navigate, None);
let text = self.current().to_string();
ui.speech.speak(text);
} else if ui.key(Key::Enter) || ui.key(Key::NumpadEnter) || ui.input.pressed(Key::Space) {
return self.answer(self.selected_yes, ui);
} else if ui.key(Key::Escape) {
return self.answer(false, ui);
} else if let Some(c) = ui.input.text().chars().next().and_then(|c| c.to_lowercase().next()) {
if Some(c) == first_lower(&self.yes) {
return self.answer(true, ui);
}
if Some(c) == first_lower(&self.no) {
return self.answer(false, ui);
}
}
Status::Open
}
}
#[derive(Debug, Clone)]
pub struct TextInput {
id: String,
prompt: String,
text: Vec<char>,
cursor: usize,
password: bool,
max_length: Option<usize>,
numeric: bool,
allow_empty: bool,
child: bool,
}
impl TextInput {
pub fn new(id: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
id: id.into(),
prompt: prompt.into(),
text: Vec::new(),
cursor: 0,
password: false,
max_length: None,
numeric: false,
allow_empty: true,
child: false,
}
}
pub fn initial(mut self, text: impl Into<String>) -> Self {
self.text = text.into().chars().collect();
self.cursor = self.text.len();
self
}
pub fn password(mut self) -> Self {
self.password = true;
self
}
pub fn max_length(mut self, max: usize) -> Self {
self.max_length = Some(max);
self
}
pub fn numeric(mut self) -> Self {
self.numeric = true;
self
}
pub fn required(mut self) -> Self {
self.allow_empty = false;
self
}
pub(crate) fn child(mut self) -> Self {
self.child = true;
self
}
fn content(&self) -> String {
self.text.iter().collect()
}
fn speak_char(&self, c: Option<char>, ui: &mut UiContext<'_>) {
let text = match c {
None => "blank".to_string(),
Some(_) if self.password => "star".to_string(),
Some(c) => char_name(c),
};
ui.speech.speak(text);
}
fn speak_all(&self, ui: &mut UiContext<'_>) {
let text = if self.text.is_empty() {
"blank".to_string()
} else if self.password {
format!("{} characters", self.text.len())
} else {
self.content()
};
ui.speech.speak(text);
}
fn word_left(&self) -> usize {
let mut i = self.cursor;
while i > 0 && self.text[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !self.text[i - 1].is_whitespace() {
i -= 1;
}
i
}
fn word_right(&self) -> usize {
let mut i = self.cursor;
while i < self.text.len() && !self.text[i].is_whitespace() {
i += 1;
}
while i < self.text.len() && self.text[i].is_whitespace() {
i += 1;
}
i
}
}
fn char_name(c: char) -> String {
match c {
' ' => "space",
'.' => "dot",
',' => "comma",
'!' => "exclamation",
'?' => "question",
'-' => "dash",
'_' => "underscore",
':' => "colon",
';' => "semicolon",
'\'' => "apostrophe",
'"' => "quote",
'/' => "slash",
'\\' => "backslash",
'@' => "at",
'#' => "number",
'(' => "left paren",
')' => "right paren",
_ => return c.to_string(),
}
.to_string()
}
impl Widget for TextInput {
fn id(&self) -> &str {
&self.id
}
fn open(&mut self, ui: &mut UiContext<'_>) {
ui.play(UiSound::Open, None);
let content = if self.text.is_empty() {
"blank".to_string()
} else if self.password {
format!("{} characters", self.text.len())
} else {
self.content()
};
ui.speech.speak(format!("{}, edit, {content}", self.prompt));
}
fn update(&mut self, ui: &mut UiContext<'_>) -> Status {
let word = ui.input.modifiers().control;
if ui.key(Key::Enter) || ui.key(Key::NumpadEnter) {
if self.text.is_empty() && !self.allow_empty {
ui.play(UiSound::Edge, None);
ui.speech.speak("blank");
return Status::Open;
}
ui.play(UiSound::Activate, None);
let text = self.content();
if self.child {
return Status::CloseWith(Value::Text(text));
}
ui.emit(UiEvent::TextSubmitted { id: self.id.clone(), text });
return Status::Close;
}
if ui.key(Key::Escape) {
ui.play(UiSound::Close, None);
if !self.child {
ui.emit(UiEvent::Closed { id: self.id.clone() });
}
return Status::Close;
}
if ui.key(Key::Left) {
if self.cursor == 0 {
ui.play(UiSound::Edge, None);
} else {
self.cursor = if word { self.word_left() } else { self.cursor - 1 };
}
self.speak_char(self.text.get(self.cursor).copied(), ui);
} else if ui.key(Key::Right) {
if self.cursor >= self.text.len() {
ui.play(UiSound::Edge, None);
} else {
self.cursor = if word { self.word_right() } else { self.cursor + 1 };
}
self.speak_char(self.text.get(self.cursor).copied(), ui);
} else if ui.key(Key::Home) {
self.cursor = 0;
self.speak_char(self.text.first().copied(), ui);
} else if ui.key(Key::End) {
self.cursor = self.text.len();
self.speak_char(None, ui);
} else if ui.key(Key::Up) || ui.key(Key::Down) {
self.speak_all(ui);
} else if ui.key(Key::Backspace) {
if self.cursor == 0 {
ui.play(UiSound::Edge, None);
} else {
self.cursor -= 1;
let removed = self.text.remove(self.cursor);
ui.play(UiSound::Typing, None);
self.speak_char(Some(removed), ui);
}
} else if ui.key(Key::Delete) {
if self.cursor < self.text.len() {
self.text.remove(self.cursor);
ui.play(UiSound::Typing, None);
self.speak_char(self.text.get(self.cursor).copied(), ui);
} else {
ui.play(UiSound::Edge, None);
}
}
let typed: Vec<char> = ui.input.text().chars().collect();
let mut last = None;
for c in typed {
if self.numeric && !(c.is_ascii_digit() || c == '-' || c == '.' || c == ',') {
continue;
}
if self.max_length.is_some_and(|m| self.text.len() >= m) {
ui.play(UiSound::Edge, None);
break;
}
self.text.insert(self.cursor, c);
self.cursor += 1;
last = Some(c);
}
if last.is_some() {
ui.play(UiSound::Typing, None);
self.speak_char(last, ui);
}
Status::Open
}
fn value(&self, _item: &str) -> Option<Value> {
Some(Value::Text(self.content()))
}
}