1use crate::layout::TextLayout;
6use crate::selection::Selection;
7
8fn floor_char_boundary(s: &str, i: usize) -> usize {
14 let mut pos = i.min(s.len());
15 while pos > 0 && !s.is_char_boundary(pos) {
16 pos -= 1;
17 }
18 pos
19}
20
21#[derive(Debug, Clone)]
29pub struct TextInput {
30 text: String,
31 cursor: usize,
33 selection: Selection,
35 scroll_offset: f32,
37 mask_char: Option<char>,
39 show_masked: bool,
41}
42
43impl TextInput {
44 pub fn new() -> Self {
46 Self {
47 text: String::new(),
48 cursor: 0,
49 selection: Selection::new(0),
50 scroll_offset: 0.0,
51 mask_char: None,
52 show_masked: false,
53 }
54 }
55
56 pub fn with_text(text: impl Into<String>) -> Self {
58 let text = text.into();
59 let len = text.len();
60 Self {
61 text,
62 cursor: len,
63 selection: Selection::new(len),
64 scroll_offset: 0.0,
65 mask_char: None,
66 show_masked: false,
67 }
68 }
69
70 pub fn with_password(mut self) -> Self {
72 self.mask_char = Some('\u{2022}');
73 self
74 }
75
76 pub fn text(&self) -> &str {
80 &self.text
81 }
82
83 pub fn cursor(&self) -> usize {
85 self.cursor
86 }
87
88 pub fn selection(&self) -> &Selection {
90 &self.selection
91 }
92
93 pub fn scroll_offset(&self) -> f32 {
95 self.scroll_offset
96 }
97
98 pub fn is_password(&self) -> bool {
100 self.mask_char.is_some()
101 }
102
103 pub fn is_showing_password(&self) -> bool {
105 self.show_masked
106 }
107
108 pub fn display_text(&self) -> String {
111 if let Some(mask) = self.mask_char {
112 if !self.show_masked {
113 return self.text.chars().map(|_| mask).collect();
114 }
115 }
116 self.text.clone()
117 }
118
119 pub fn toggle_show_password(&mut self) {
121 self.show_masked = !self.show_masked;
122 }
123
124 pub fn selected_text(&self) -> &str {
128 if self.selection.is_collapsed() {
129 return "";
130 }
131 let (start, end) = self.selection.normalized();
132 let start = floor_char_boundary(&self.text, start.min(self.text.len()));
133 let end = floor_char_boundary(&self.text, end.min(self.text.len()));
134 &self.text[start..end]
135 }
136
137 pub fn insert(&mut self, s: &str) {
141 self.delete_selection();
142 let pos = floor_char_boundary(&self.text, self.cursor.min(self.text.len()));
143 self.text.insert_str(pos, s);
144 self.cursor = pos + s.len();
145 self.selection = Selection::new(self.cursor);
146 }
147
148 pub fn insert_char(&mut self, c: char) {
150 let mut buf = [0u8; 4];
151 let s = c.encode_utf8(&mut buf);
152 self.insert(s);
153 }
154
155 pub fn delete_backward(&mut self) {
159 if !self.selection.is_collapsed() {
160 self.delete_selection();
161 return;
162 }
163 if self.cursor == 0 {
164 return;
165 }
166 let pos = floor_char_boundary(&self.text, self.cursor);
167 let mut prev = pos.saturating_sub(1);
168 while prev > 0 && !self.text.is_char_boundary(prev) {
169 prev -= 1;
170 }
171 self.text.replace_range(prev..pos, "");
172 self.cursor = prev;
173 self.selection = Selection::new(self.cursor);
174 }
175
176 pub fn delete_forward(&mut self) {
180 if !self.selection.is_collapsed() {
181 self.delete_selection();
182 return;
183 }
184 let pos = floor_char_boundary(&self.text, self.cursor.min(self.text.len()));
185 if pos >= self.text.len() {
186 return;
187 }
188 let mut next = pos + 1;
189 while next < self.text.len() && !self.text.is_char_boundary(next) {
190 next += 1;
191 }
192 self.text.replace_range(pos..next, "");
193 self.selection = Selection::new(self.cursor);
194 }
195
196 pub fn move_left(&mut self, shift: bool) {
203 if !shift && !self.selection.is_collapsed() {
204 let (start, _) = self.selection.normalized();
205 self.cursor = start;
206 } else if self.cursor > 0 {
207 let mut pos = self.cursor.saturating_sub(1);
208 while pos > 0 && !self.text.is_char_boundary(pos) {
209 pos -= 1;
210 }
211 self.cursor = pos;
212 }
213 if shift {
214 self.selection = Selection {
215 anchor: self.selection.anchor,
216 focus: self.cursor,
217 };
218 } else {
219 self.selection = Selection::new(self.cursor);
220 }
221 }
222
223 pub fn move_right(&mut self, shift: bool) {
228 if !shift && !self.selection.is_collapsed() {
229 let (_, end) = self.selection.normalized();
230 self.cursor = end;
231 } else {
232 let pos = floor_char_boundary(&self.text, self.cursor.min(self.text.len()));
233 if pos < self.text.len() {
234 let mut next = pos + 1;
235 while next < self.text.len() && !self.text.is_char_boundary(next) {
236 next += 1;
237 }
238 self.cursor = next;
239 }
240 }
241 if shift {
242 self.selection = Selection {
243 anchor: self.selection.anchor,
244 focus: self.cursor,
245 };
246 } else {
247 self.selection = Selection::new(self.cursor);
248 }
249 }
250
251 pub fn move_home(&mut self, shift: bool) {
253 self.cursor = 0;
254 if shift {
255 self.selection = Selection {
256 anchor: self.selection.anchor,
257 focus: 0,
258 };
259 } else {
260 self.selection = Selection::new(0);
261 }
262 }
263
264 pub fn move_end(&mut self, shift: bool) {
266 self.cursor = self.text.len();
267 if shift {
268 self.selection = Selection {
269 anchor: self.selection.anchor,
270 focus: self.text.len(),
271 };
272 } else {
273 self.selection = Selection::new(self.text.len());
274 }
275 }
276
277 pub fn move_word_left(&mut self, shift: bool) {
279 let new_focus = Selection::extend_word_backward(&self.text, self.cursor);
280 self.cursor = new_focus;
281 if shift {
282 self.selection = Selection {
283 anchor: self.selection.anchor,
284 focus: new_focus,
285 };
286 } else {
287 self.selection = Selection::new(new_focus);
288 }
289 }
290
291 pub fn move_word_right(&mut self, shift: bool) {
293 let new_focus = Selection::extend_word_forward(&self.text, self.cursor);
294 self.cursor = new_focus;
295 if shift {
296 self.selection = Selection {
297 anchor: self.selection.anchor,
298 focus: new_focus,
299 };
300 } else {
301 self.selection = Selection::new(new_focus);
302 }
303 }
304
305 pub fn move_cursor_to_x(&mut self, x: f32, layout: &TextLayout, shift: bool) {
310 let byte_offset = layout.hit_test(x, 0.0);
311 self.cursor = byte_offset;
312 if shift {
313 self.selection = Selection {
314 anchor: self.selection.anchor,
315 focus: byte_offset,
316 };
317 } else {
318 self.selection = Selection::new(byte_offset);
319 }
320 }
321
322 pub fn click(&mut self, x: f32, layout: &TextLayout) {
324 self.move_cursor_to_x(x, layout, false);
325 }
326
327 pub fn double_click(&mut self, x: f32, layout: &TextLayout) {
329 let pos = layout.hit_test(x, 0.0);
330 let word_start = Selection::extend_word_backward(&self.text, pos);
331 let word_end = Selection::extend_word_forward(&self.text, pos);
332 self.cursor = word_end;
333 self.selection = Selection {
334 anchor: word_start,
335 focus: word_end,
336 };
337 }
338
339 pub fn triple_click(&mut self) {
341 self.cursor = self.text.len();
342 self.selection = Selection {
343 anchor: 0,
344 focus: self.text.len(),
345 };
346 }
347
348 pub fn select_all(&mut self) {
350 self.triple_click();
351 }
352
353 fn delete_selection(&mut self) {
356 if self.selection.is_collapsed() {
357 return;
358 }
359 let (start, end) = self.selection.normalized();
360 let start = floor_char_boundary(&self.text, start.min(self.text.len()));
361 let end = floor_char_boundary(&self.text, end.min(self.text.len()));
362 self.text.replace_range(start..end, "");
363 self.cursor = start;
364 self.selection = Selection::new(start);
365 }
366}
367
368impl Default for TextInput {
369 fn default() -> Self {
370 Self::new()
371 }
372}
373
374#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::layout::{TextAlign, TextLayout};
380 use crate::{GlyphPosition, ShapedText};
381
382 fn fake_layout(text: &str) -> TextLayout {
384 let char_w = 8.0_f32;
385 let glyphs: Vec<GlyphPosition> = text
386 .char_indices()
387 .enumerate()
388 .map(|(i, (byte_off, _))| GlyphPosition {
389 byte_offset: byte_off,
390 x: i as f32 * char_w,
391 y: 0.0,
392 width: char_w,
393 height: 16.0,
394 })
395 .collect();
396 let total_width = glyphs.len() as f32 * char_w;
397 let shaped = ShapedText {
398 lines: vec![glyphs],
399 total_width,
400 total_height: 16.0,
401 };
402 TextLayout {
403 shaped,
404 align: TextAlign::Left,
405 bounds: (total_width, 16.0),
406 }
407 }
408
409 #[test]
410 fn insert_at_cursor() {
411 let mut input = TextInput::new();
412 input.insert("hello");
413 assert_eq!(input.text(), "hello");
414 assert_eq!(input.cursor(), 5);
415 }
416
417 #[test]
418 fn delete_backward_basic() {
419 let mut input = TextInput::with_text("hello");
420 input.delete_backward();
421 assert_eq!(input.text(), "hell");
422 assert_eq!(input.cursor(), 4);
423 }
424
425 #[test]
426 fn delete_backward_no_panic_at_zero() {
427 let mut input = TextInput::new();
428 input.delete_backward(); assert_eq!(input.text(), "");
430 }
431
432 #[test]
433 fn delete_forward_basic() {
434 let mut input = TextInput::with_text("hello");
435 input.move_home(false);
436 input.delete_forward();
437 assert_eq!(input.text(), "ello");
438 assert_eq!(input.cursor(), 0);
439 }
440
441 #[test]
442 fn move_left_right_simple() {
443 let mut input = TextInput::with_text("ab");
444 input.move_home(false);
445 input.move_right(false);
446 assert_eq!(input.cursor(), 1);
447 input.move_left(false);
448 assert_eq!(input.cursor(), 0);
449 }
450
451 #[test]
452 fn move_word_left_right() {
453 let mut input = TextInput::with_text("hello world");
454 input.move_word_left(false);
456 assert_eq!(
457 input.cursor(),
458 6,
459 "word-left should land at start of 'world'"
460 );
461 input.move_word_right(false);
462 assert_eq!(
463 input.cursor(),
464 11,
465 "word-right should land at end of 'world'"
466 );
467 }
468
469 #[test]
470 fn move_home_end() {
471 let mut input = TextInput::with_text("hello");
472 input.move_home(false);
473 assert_eq!(input.cursor(), 0);
474 input.move_end(false);
475 assert_eq!(input.cursor(), 5);
476 }
477
478 #[test]
479 fn triple_click_selects_all() {
480 let mut input = TextInput::with_text("hello world");
481 input.triple_click();
482 assert_eq!(input.selected_text(), "hello world");
483 }
484
485 #[test]
486 fn select_all() {
487 let mut input = TextInput::with_text("hello world");
488 input.select_all();
489 assert_eq!(input.selected_text(), "hello world");
490 }
491
492 #[test]
493 fn double_click_selects_word() {
494 let mut input = TextInput::with_text("hello world");
495 let layout = fake_layout("hello world");
496 input.double_click(56.0, &layout);
498 let sel = input.selected_text();
499 assert!(!sel.is_empty(), "double-click must select a word");
501 }
502
503 #[test]
504 fn password_mask_same_length() {
505 let input = TextInput::with_text("secret").with_password();
506 let display = input.display_text();
507 let orig_chars = "secret".chars().count();
508 let disp_chars = display.chars().count();
509 assert_eq!(
510 orig_chars, disp_chars,
511 "masked text must have the same char count"
512 );
513 assert!(
514 !display.contains('s'),
515 "masked text must not contain raw characters"
516 );
517 }
518
519 #[test]
520 fn password_toggle_show_hide() {
521 let mut input = TextInput::with_text("secret").with_password();
522 assert!(!input.is_showing_password());
523 let masked = input.display_text();
524 input.toggle_show_password();
525 assert!(input.is_showing_password());
526 let visible = input.display_text();
527 assert_eq!(visible, "secret");
528 assert_ne!(masked, visible);
529 }
530
531 #[test]
532 fn insert_replaces_selection() {
533 let mut input = TextInput::with_text("hello world");
534 input.select_all();
535 input.insert("replaced");
536 assert_eq!(input.text(), "replaced");
537 }
538
539 #[test]
540 fn shift_right_extends_selection() {
541 let mut input = TextInput::with_text("hello");
542 input.move_home(false);
543 input.move_right(true);
544 input.move_right(true);
545 assert_eq!(input.selected_text(), "he");
546 }
547}