use crate::core::{Color, Font, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MaskSegment {
Literal { ch: char },
Input { kind: MaskCharKind },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MaskCharKind {
RequiredDigit,
OptionalDigit,
RequiredLetter,
OptionalLetter,
RequiredAlphaNum,
OptionalAlphaNum,
}
pub struct MaskedEdit {
base: BaseWidget,
mask: String,
segments: Vec<MaskSegment>,
raw_text: String,
display_text: String,
cursor_pos: usize,
focused: bool,
pub text_changed: Signal1<String>,
}
impl MaskedEdit {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::MaskedEdit, geometry, "MaskedEdit"),
mask: String::new(),
segments: Vec::new(),
raw_text: String::new(),
display_text: String::new(),
cursor_pos: 0,
focused: false,
text_changed: Signal1::new(),
}
}
pub fn set_mask(&mut self, mask: &str) {
self.mask = mask.to_string();
self.segments = parse_mask(mask);
self.cursor_pos = 0;
self.update_display_text();
self.base.request_redraw();
}
pub fn mask(&self) -> &str {
&self.mask
}
pub fn raw_text(&self) -> &str {
&self.raw_text
}
pub fn set_text(&mut self, text: &str) {
self.raw_text = String::new();
let mut chars = text.chars();
for seg in &self.segments {
if let MaskSegment::Input { kind } = seg {
while let Some(ch) = chars.next() {
if mask_char_matches(*kind, ch) {
self.raw_text.push(ch);
break;
}
}
}
}
self.update_display_text();
self.cursor_pos = self.display_text.len();
self.text_changed.emit(self.raw_text.clone());
self.base.request_redraw();
}
pub fn text(&self) -> &str {
&self.display_text
}
pub fn is_valid(&self) -> bool {
let required_count = self
.segments
.iter()
.filter(|s| {
matches!(
s,
MaskSegment::Input { kind: MaskCharKind::RequiredDigit }
| MaskSegment::Input { kind: MaskCharKind::RequiredLetter }
| MaskSegment::Input { kind: MaskCharKind::RequiredAlphaNum }
)
})
.count();
self.raw_text.len() >= required_count && !self.mask.is_empty()
}
pub fn cursor_pos(&self) -> usize {
self.cursor_pos
}
pub fn set_cursor_pos(&mut self, pos: usize) {
self.cursor_pos = pos.min(self.display_text.len());
self.base.request_redraw();
}
fn insert_char(&mut self, ch: char) {
let raw_idx = self.display_to_raw_index(self.cursor_pos);
if raw_idx >= self.input_count() {
return;
}
if let Some((seg_idx, kind)) = self.find_input_at_raw_index(raw_idx) {
if mask_char_matches(kind, ch) {
self.raw_text.insert(raw_idx, ch);
self.update_display_text();
self.cursor_pos = seg_idx + 1;
self.text_changed.emit(self.raw_text.clone());
self.base.request_redraw();
}
}
}
fn backspace(&mut self) {
if self.raw_text.is_empty() || self.cursor_pos == 0 {
return;
}
let raw_idx = self.display_to_raw_index(self.cursor_pos);
if raw_idx > 0 && raw_idx <= self.raw_text.len() {
self.raw_text.remove(raw_idx - 1);
self.update_display_text();
self.cursor_pos = self.segment_before_raw_index(raw_idx - 1);
self.text_changed.emit(self.raw_text.clone());
self.base.request_redraw();
}
}
fn delete(&mut self) {
let raw_idx = self.display_to_raw_index(self.cursor_pos);
if raw_idx < self.raw_text.len() {
self.raw_text.remove(raw_idx);
self.update_display_text();
self.text_changed.emit(self.raw_text.clone());
self.base.request_redraw();
}
}
fn update_display_text(&mut self) {
self.display_text = build_display_text(&self.segments, &self.raw_text);
}
fn input_count(&self) -> usize {
self.segments.iter().filter(|s| matches!(s, MaskSegment::Input { .. })).count()
}
fn find_input_at_raw_index(&self, raw_idx: usize) -> Option<(usize, MaskCharKind)> {
let mut input_count = 0;
for (seg_idx, seg) in self.segments.iter().enumerate() {
if let MaskSegment::Input { kind } = seg {
if input_count == raw_idx {
return Some((seg_idx, *kind));
}
input_count += 1;
}
}
None
}
fn display_to_raw_index(&self, display_pos: usize) -> usize {
let mut raw_count = 0;
let mut display_idx = 0;
for seg in &self.segments {
if display_idx >= display_pos {
break;
}
if matches!(seg, MaskSegment::Input { .. }) {
raw_count += 1;
}
display_idx += 1;
}
raw_count
}
fn segment_before_raw_index(&self, raw_idx: usize) -> usize {
let mut input_count = 0;
for (seg_idx, seg) in self.segments.iter().enumerate() {
if matches!(seg, MaskSegment::Input { .. }) {
if input_count == raw_idx {
return seg_idx;
}
input_count += 1;
}
}
self.segments.len()
}
}
impl Widget for MaskedEdit {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
}
impl Draw for MaskedEdit {
fn draw(&mut self, context: &mut RenderContext) {
let geom = self.geometry();
let is_enabled = self.base.is_enabled();
let font = Font::simple("monospace", 13.0);
let bg_color = if !is_enabled {
Color::rgba(240, 240, 240, 180)
} else if self.focused {
Color::WHITE
} else {
Color::rgba(248, 248, 250, 200)
};
context.fill_rounded_rect(geom, 4, bg_color);
let border_color = if self.focused && is_enabled {
Color::from_rgb(25, 118, 210)
} else {
Color::rgba(190, 190, 200, 200)
};
context.draw_rounded_rect_stroke(geom, 4, border_color, if self.focused { 2 } else { 1 });
let padding = 6i32;
let text_x = geom.x + padding;
let text_y = geom.y + geom.height as i32 / 2;
if self.mask.is_empty() {
let text_color = if !is_enabled {
Color::rgba(150, 150, 150, 200)
} else {
Color::from_rgb(33, 33, 33)
};
context.draw_text(Point::new(text_x, text_y), &self.raw_text, &font, text_color);
return;
}
let mut raw_idx = 0;
let mut display_x = text_x;
let char_width = 8u32;
for (seg_idx, seg) in self.segments.iter().enumerate() {
if display_x - text_x > geom.width as i32 - padding * 2 {
break;
}
match seg {
MaskSegment::Literal { ch } => {
let lit_color = Color::rgba(160, 160, 160, 200);
context.draw_text(
Point::new(display_x, text_y),
&ch.to_string(),
&font,
lit_color,
);
display_x += char_width as i32;
}
MaskSegment::Input { kind } => {
let has_input = raw_idx < self.raw_text.len();
let ch = if has_input {
self.raw_text.as_bytes()[raw_idx] as char
} else {
placeholder_char(*kind)
};
let char_color = if !is_enabled {
Color::rgba(150, 150, 150, 200)
} else if has_input {
Color::from_rgb(33, 33, 33)
} else {
Color::rgba(180, 180, 180, 200)
};
let metrics = context.measure_text(&ch.to_string(), &font);
let ch_width = metrics.width as i32;
if self.focused && is_enabled && seg_idx == self.cursor_pos {
context.fill_rect(
Rect::new(display_x, geom.y + 2, char_width, geom.height - 4),
Color::from_rgb(25, 118, 210),
);
context.draw_text(
Point::new(display_x, text_y),
&ch.to_string(),
&font,
Color::WHITE,
);
} else {
context.draw_text(
Point::new(display_x, text_y),
&ch.to_string(),
&font,
char_color,
);
}
display_x += ch_width.max(char_width as i32);
if has_input {
raw_idx += 1;
}
}
}
}
}
}
impl EventHandler for MaskedEdit {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::FocusGained => {
self.focused = true;
self.base.request_redraw();
}
Event::FocusLost => {
self.focused = false;
self.base.request_redraw();
}
Event::MousePress { .. } => {
self.focused = true;
self.base.request_redraw();
}
Event::KeyPress { key, modifiers: _ } => {
if !self.focused {
return;
}
match *key {
8 => {
self.backspace();
}
127 => {
self.delete();
}
13 => {
}
27 => {
self.focused = false;
self.base.request_redraw();
}
37 => {
if self.cursor_pos > 0 {
self.cursor_pos -= 1;
self.base.request_redraw();
}
}
39 => {
if self.cursor_pos < self.display_text.len() {
self.cursor_pos += 1;
self.base.request_redraw();
}
}
_ => {
if *key >= 32 && *key < 127 {
if let Some(ch) = char::from_u32(*key) {
self.insert_char(ch);
}
}
}
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
fn placeholder_char(kind: MaskCharKind) -> char {
match kind {
MaskCharKind::RequiredDigit | MaskCharKind::OptionalDigit => '_',
MaskCharKind::RequiredLetter | MaskCharKind::OptionalLetter => '_',
MaskCharKind::RequiredAlphaNum | MaskCharKind::OptionalAlphaNum => '_',
}
}
fn mask_char_matches(kind: MaskCharKind, ch: char) -> bool {
match kind {
MaskCharKind::RequiredDigit | MaskCharKind::OptionalDigit => ch.is_ascii_digit(),
MaskCharKind::RequiredLetter | MaskCharKind::OptionalLetter => ch.is_ascii_alphabetic(),
MaskCharKind::RequiredAlphaNum | MaskCharKind::OptionalAlphaNum => {
ch.is_ascii_alphanumeric()
}
}
}
fn parse_mask(mask: &str) -> Vec<MaskSegment> {
let mut segments = Vec::new();
for ch in mask.chars() {
let seg = match ch {
'0' => MaskSegment::Input { kind: MaskCharKind::RequiredDigit },
'9' => MaskSegment::Input { kind: MaskCharKind::OptionalDigit },
'A' => MaskSegment::Input { kind: MaskCharKind::RequiredLetter },
'a' => MaskSegment::Input { kind: MaskCharKind::OptionalLetter },
'X' => MaskSegment::Input { kind: MaskCharKind::RequiredAlphaNum },
'x' => MaskSegment::Input { kind: MaskCharKind::OptionalAlphaNum },
_ => MaskSegment::Literal { ch },
};
segments.push(seg);
}
segments
}
fn build_display_text(segments: &[MaskSegment], raw_text: &str) -> String {
let mut result = String::new();
let mut raw_idx = 0;
let raw_chars: Vec<char> = raw_text.chars().collect();
for seg in segments {
match seg {
MaskSegment::Literal { ch } => {
result.push(*ch);
}
MaskSegment::Input { .. } => {
if raw_idx < raw_chars.len() {
result.push(raw_chars[raw_idx]);
raw_idx += 1;
} else {
result.push('_');
}
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
use std::sync::{Arc, Mutex};
#[test]
fn masked_edit_default_creation() {
let me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
assert_eq!(me.mask(), "");
assert_eq!(me.raw_text(), "");
assert_eq!(me.text(), "");
assert!(!me.is_valid());
assert_eq!(me.cursor_pos(), 0);
}
#[test]
fn masked_edit_set_mask() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("(000) 000-0000");
assert_eq!(me.mask(), "(000) 000-0000");
}
#[test]
fn masked_edit_set_text_validates_against_mask() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("000-0000");
me.set_text("5551234");
assert_eq!(me.raw_text(), "5551234");
assert_eq!(me.text(), "555-1234");
assert!(me.is_valid());
}
#[test]
fn masked_edit_validity() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("0000");
assert!(!me.is_valid());
me.set_text("123");
assert!(!me.is_valid());
me.set_text("1234");
assert!(me.is_valid());
}
#[test]
fn masked_edit_insert_char() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("(000) 000-0000");
me.insert_char('5');
assert_eq!(me.raw_text(), "5");
assert_eq!(me.text(), "(5__) ___-____");
me.insert_char('5');
me.insert_char('5');
me.insert_char('1');
me.insert_char('2');
me.insert_char('3');
me.insert_char('4');
me.insert_char('5');
me.insert_char('6');
me.insert_char('7');
assert_eq!(me.raw_text(), "5551234567");
assert_eq!(me.text(), "(555) 123-4567");
}
#[test]
fn masked_edit_invalid_char_rejected() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("000");
me.insert_char('A');
assert_eq!(me.raw_text(), "");
assert_eq!(me.text(), "___");
me.insert_char('1');
assert_eq!(me.raw_text(), "1");
}
#[test]
fn masked_edit_cursor_position() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("000-0000");
assert_eq!(me.cursor_pos(), 0);
me.insert_char('1');
assert!(me.cursor_pos() > 0);
me.set_cursor_pos(0);
assert_eq!(me.cursor_pos(), 0);
}
#[test]
fn masked_edit_text_changed_signal() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("0000");
let captured = Arc::new(Mutex::new(None));
let cap = captured.clone();
me.text_changed.connect(move |val| {
*cap.lock().unwrap() = Some(val.to_string());
});
me.insert_char('1');
assert_eq!(captured.lock().unwrap().as_deref(), Some("1"));
me.insert_char('2');
assert_eq!(captured.lock().unwrap().as_deref(), Some("12"));
}
#[test]
fn masked_edit_backspace() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("0000");
me.set_text("1234");
assert_eq!(me.raw_text(), "1234");
me.set_cursor_pos(4);
me.backspace();
assert_eq!(me.raw_text(), "123");
}
#[test]
fn masked_edit_letter_mask() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("AAA");
me.insert_char('H');
me.insert_char('i');
me.insert_char('!'); assert_eq!(me.raw_text(), "Hi");
}
#[test]
fn masked_edit_svg_output() {
let mut me = MaskedEdit::new(Rect::new(0, 0, 200, 30));
me.set_mask("000-0000");
me.set_text("5551234");
let svg = render_to_svg(&mut me);
assert!(svg.starts_with("<svg"), "SVG should start with <svg, got: {svg:.60}");
assert!(svg.ends_with("</svg>"), "SVG should end with </svg>");
}
}