use std::panic::Location;
use crate::cursor::Cursor;
use crate::event::{UiEvent, UiEventKind, UiKey};
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::text::text;
const CELL_WIDTH: f32 = 36.0;
const CELL_HEIGHT: f32 = 40.0;
#[track_caller]
pub fn input_otp(value: &str, key: &str, length: usize) -> El {
let caller = Location::caller();
let filled = value.chars().count().min(length);
let mut cells: Vec<El> = Vec::with_capacity(length);
for (i, ch) in cells_iter(value, length) {
let active = i == filled && filled < length;
cells.push(otp_cell(caller, ch, active));
}
row(cells)
.at_loc(caller)
.style_profile(StyleProfile::Surface)
.focusable()
.always_show_focus_ring()
.capture_keys()
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
.cursor(Cursor::Text)
.key(key.to_string())
.gap(tokens::SPACE_1)
.align(Align::Center)
.height(Size::Fixed(CELL_HEIGHT))
}
pub fn apply_event(value: &mut String, key: &str, length: usize, event: &UiEvent) -> bool {
if event.target_key() != Some(key) {
return false;
}
match event.kind {
UiEventKind::TextInput => {
let Some(text) = event.text.as_deref() else {
return false;
};
if (event.modifiers.ctrl && !event.modifiers.alt) || event.modifiers.logo {
return false;
}
let mut changed = false;
for ch in text.chars() {
if ch.is_control() {
continue;
}
if value.chars().count() >= length {
break;
}
value.push(ch);
changed = true;
}
changed
}
UiEventKind::KeyDown => {
let Some(kp) = event.key_press.as_ref() else {
return false;
};
if kp.key == UiKey::Backspace {
value.pop().is_some()
} else {
false
}
}
_ => false,
}
}
fn cells_iter(value: &str, length: usize) -> impl Iterator<Item = (usize, Option<char>)> + '_ {
let mut chars = value.chars();
(0..length).map(move |i| (i, chars.next()))
}
fn otp_cell(caller: &'static Location<'static>, ch: Option<char>, active: bool) -> El {
let stroke = if active {
tokens::PRIMARY
} else {
tokens::INPUT
};
let body: El = match ch {
Some(c) => text(c.to_string()).label(),
None => El::new(Kind::Spacer).width(Size::Fixed(0.0)),
};
El::new(Kind::Custom("input_otp_cell"))
.at_loc(caller)
.style_profile(StyleProfile::Surface)
.axis(Axis::Overlay)
.align(Align::Center)
.justify(Justify::Center)
.width(Size::Fixed(CELL_WIDTH))
.height(Size::Fixed(CELL_HEIGHT))
.fill(tokens::BACKGROUND)
.stroke(stroke)
.stroke_width(1.0)
.radius(tokens::RADIUS_MD)
.child(body)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{KeyModifiers, KeyPress, UiTarget};
use crate::tree::Rect;
fn text_input_event(key: &str, txt: &str) -> UiEvent {
UiEvent {
path: None,
key: Some(key.to_string()),
target: Some(UiTarget {
key: key.to_string(),
node_id: format!("/{key}"),
rect: Rect::new(0.0, 0.0, 100.0, 40.0),
tooltip: None,
scroll_offset_y: 0.0,
}),
pointer: None,
key_press: None,
text: Some(txt.to_string()),
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
kind: UiEventKind::TextInput,
}
}
fn backspace_event(key: &str) -> UiEvent {
UiEvent {
path: None,
key: Some(key.to_string()),
target: Some(UiTarget {
key: key.to_string(),
node_id: format!("/{key}"),
rect: Rect::new(0.0, 0.0, 100.0, 40.0),
tooltip: None,
scroll_offset_y: 0.0,
}),
pointer: None,
key_press: Some(KeyPress {
key: UiKey::Backspace,
modifiers: KeyModifiers::default(),
repeat: false,
}),
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
kind: UiEventKind::KeyDown,
}
}
#[test]
fn text_input_appends_chars() {
let mut value = String::new();
assert!(apply_event(
&mut value,
"code",
6,
&text_input_event("code", "1")
));
assert_eq!(value, "1");
assert!(apply_event(
&mut value,
"code",
6,
&text_input_event("code", "2")
));
assert_eq!(value, "12");
}
#[test]
fn text_input_caps_at_length() {
let mut value = String::from("12345");
assert!(apply_event(
&mut value,
"code",
6,
&text_input_event("code", "67")
));
assert_eq!(value, "123456");
}
#[test]
fn text_input_dropped_when_full() {
let mut value = String::from("123456");
assert!(!apply_event(
&mut value,
"code",
6,
&text_input_event("code", "7")
));
assert_eq!(value, "123456");
}
#[test]
fn backspace_pops_last_char() {
let mut value = String::from("123");
assert!(apply_event(&mut value, "code", 6, &backspace_event("code")));
assert_eq!(value, "12");
}
#[test]
fn backspace_on_empty_is_noop() {
let mut value = String::new();
assert!(!apply_event(
&mut value,
"code",
6,
&backspace_event("code")
));
assert!(value.is_empty());
}
#[test]
fn ignores_events_for_other_keys() {
let mut value = String::new();
assert!(!apply_event(
&mut value,
"code",
6,
&text_input_event("other", "x"),
));
assert_eq!(value, "");
}
#[test]
fn text_input_drops_control_chars_so_backspace_doesnt_self_insert() {
let mut value = String::from("123");
assert!(apply_event(&mut value, "code", 6, &backspace_event("code")));
assert_eq!(value, "12");
let mut bs_text = text_input_event("code", "\u{8}");
assert!(!apply_event(&mut value, "code", 6, &bs_text));
assert_eq!(value, "12", "control char should not be inserted");
for ctl in ["\r", "\n", "\t", "\u{1b}", "\u{7f}"] {
bs_text = text_input_event("code", ctl);
assert!(
!apply_event(&mut value, "code", 6, &bs_text),
"control char {ctl:?} should be dropped",
);
assert_eq!(value, "12");
}
}
#[test]
fn text_input_drops_ctrl_modified_chars() {
let mut value = String::new();
let mut ev = text_input_event("code", "c");
ev.modifiers = KeyModifiers {
ctrl: true,
..KeyModifiers::default()
};
assert!(!apply_event(&mut value, "code", 6, &ev));
assert_eq!(value, "");
}
#[test]
fn text_input_keeps_alt_gr_chars() {
let mut value = String::new();
let mut ev = text_input_event("code", "@");
ev.modifiers = KeyModifiers {
ctrl: true,
alt: true,
..KeyModifiers::default()
};
assert!(apply_event(&mut value, "code", 6, &ev));
assert_eq!(value, "@");
}
#[test]
fn paste_of_multiple_chars_fills_remaining_cells() {
let mut value = String::new();
assert!(apply_event(
&mut value,
"code",
6,
&text_input_event("code", "abcdef"),
));
assert_eq!(value, "abcdef");
}
#[test]
fn build_widget_has_one_cell_per_length_with_correct_active_marker() {
let el = input_otp("12", "code", 6);
assert_eq!(el.key.as_deref(), Some("code"));
assert_eq!(el.children.len(), 6);
assert_eq!(el.children[0].stroke, Some(tokens::INPUT));
assert_eq!(el.children[1].stroke, Some(tokens::INPUT));
assert_eq!(el.children[2].stroke, Some(tokens::PRIMARY));
assert_eq!(el.children[3].stroke, Some(tokens::INPUT));
}
#[test]
fn full_value_renders_no_active_cell() {
let el = input_otp("123456", "code", 6);
for cell in &el.children {
assert_eq!(
cell.stroke,
Some(tokens::INPUT),
"no cell should be active when value is full",
);
}
}
}