GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/input/text_box.rs (90.4K)
1 //! Narrow-trait `TextBox` (Phase 5q). The widest-surface leaf so far: real selection-aware
2 //! clipboard (the new `Input` cut/copy/paste/select-all/clear hooks — their defaults replicate
3 //! the whole-value `WidgetHost` defaults for everyone else), load-bearing glyph shaping through
4 //! `Paint::prepare_text` (cursor↔pixel mapping reads the measured advances), the row-hit
5 //! restoration (`Layout::hit_row_rect` — cce-files' save-name box relies on row hits), a
6 //! width/max-width clamp on both rect paths (`Layout::adjust_rect` + `adjust_row_rect`), the
7 //! ungated `Layout::rect_assigned` (scroll re-clamp on every `set_rect`, hidden or not), and
8 //! `Input::tracks_base_focus = false` (legacy `focus()` never set the base flag — the detached
9 //! label must not color as focused).
10 //!
11 //! Parity notes:
12 //! - The legacy render split is asymmetric and preserved faithfully: the non-rounded path
13 //! (`extra_quads`) draws at the full base x/width with a disabled special-case; the rounded
14 //! path (`all_rounded_quads`) has NO disabled branch.
15 //! - Releases: legacy `mouse_input` hit-gated releases too (out-of-rect releases were dropped).
16 //! The adapter delivers releases ungated, so the model re-checks containment itself against
17 //! the plain rect (the row-substituted release geometry is approximated — flagged).
18 //! - Wheel scrolling is now hit-gated by the adapter (legacy hosts called `mouse_wheel`
19 //! directly on the hovered widget, so the gate should be a no-op in practice — flagged).
20
21 use crate::widget::*;
22 use crate::scene::layout::{Rect, Size};
23 use crate::scene::paint::PaintCtx;
24 use crate::widget::model::{Adapted, EventCtx, Input, Layout, Paint};
25 use crate::history::History;
26 use std::sync::OnceLock;
27
28 static FONT_DB: OnceLock<resvg::usvg::fontdb::Database> = OnceLock::new();
29
30 pub fn get_font_db() -> &'static resvg::usvg::fontdb::Database {
31 FONT_DB.get_or_init(|| {
32 let mut db = resvg::usvg::fontdb::Database::new();
33 db.load_system_fonts();
34 db.load_fonts_dir(crate::fonts_dir());
35 db
36 })
37 }
38
39 #[derive(Debug, Clone)]
40 pub struct TextBox {
41 pub text: String,
42 pub editing: bool,
43 pub edit_buffer: String,
44 pub(crate) just_changed: bool,
45 pub disabled: bool,
46 pub all_selected: bool,
47 pub cursor_idx: usize,
48 pub select_anchor: Option<usize>,
49 pub dragging: bool,
50 pub just_focused: bool,
51 pub drag_start_idx: Option<usize>,
52 pub max_width: Option<f32>,
53 pub width: Option<f32>,
54 pub is_password: bool,
55 pub multiline: bool,
56 /// Per-widget wrap override; `None` defers to the global `textbox_line_wrap()`
57 /// style. Flowed-text consumers (an email body) set this to keep wrapping even
58 /// when the user's config turns multiline wrap off DE-wide.
59 pub line_wrap_override: Option<bool>,
60 pub draw_bg_border: bool,
61 pub text_color: Option<[u8; 3]>,
62 pub font_size: f32,
63 pub font_family: String,
64 pub placeholder: Option<String>,
65 pub editor_state: TextEditorState,
66 /// Edit history for the current editing session (cleared by
67 /// `begin_editing`): a typed run is one step, a deleted run one, a
68 /// paste/cut/selection-replacement one. Stepped by `ContextAction::Undo`
69 /// / `Redo`, which the runner routes here on the `undo` / `redo` chords
70 /// while the box is focused and editing.
71 pub history: History<TextEditorState>,
72 pub scroll_y: f32,
73 pub scroll_x: f32,
74 /// Smooth-scroll driver behind `scroll_x`/`scroll_y` (see `ScrollRegion::motion`).
75 scroll_motion: ScrollMotion,
76 /// `(max_x, max_y)` as of the last wheel — the glide's bounds, so `tick`
77 /// never re-wraps the text just to re-derive them.
78 scroll_max: (f32, f32),
79 default_font_size: f32,
80 default_font_family: String,
81 pub cursor_x_offset: f32,
82 pub glyph_positions: Vec<f32>,
83 pub total_text_width: f32,
84 /// The shaped advance of one column, cached by [`Paint::prepare_text`] from the same
85 /// cosmic-text path that draws the value text. `char_width()` prefers this over the
86 /// SVG-rasterized `measure_text_width`, which reports inked extent (and resolves generic
87 /// families through fontdb, not cosmic-text) — a per-column error that made the multiline
88 /// selection highlight drift off the glyphs. 0.0 until the first `prepare_text`.
89 shaped_char_advance: f32,
90 /// Multiline counterpart of `glyph_positions`: per WRAPPED line, per-column x
91 /// offsets of that line as drawn (`[line][col]`, one extra entry per line = its
92 /// total advance), recorded by [`Paint::prepare_text`] over the same wrap the
93 /// paint uses. The multiline caret, selection, click→column, and
94 /// scroll-to-cursor read these; the uniform `col * char_width()` grid they used
95 /// before is exact only for monospace. Empty for single-line boxes or until the
96 /// first shape (readers fall back to the grid).
97 line_glyph_positions: Vec<Vec<f32>>,
98 pub update_on_type: bool,
99 /// Synced control label ([`Paint::sync_label`]) — drives the detached strip offset.
100 label: Option<String>,
101 /// Own hover flag, maintained from `MouseEnter`/`MouseLeave` (adapter bookkeeping).
102 hovered: bool,
103 /// The laid-out base rect, cached from [`Layout::rect_assigned`] — the cursor/scroll math
104 /// reads geometry between events, which the narrow traits don't otherwise carry.
105 rect: Rect,
106 /// Recessed style: a `Recess` overlay is carved over the box's own fill —
107 /// an inset well, the input-direction counterpart of the raised controls.
108 recessed: Option<bool>,
109 }
110
111 impl TextBox {
112 /// The style in force: the per-widget override (`with_recessed`) when set, else
113 /// the DE's `control_relief`, read live so a runtime switch
114 /// (`layout::set_control_relief`) restyles every control at once.
115 fn recessed(&self) -> bool {
116 self.recessed.unwrap_or_else(crate::layout::control_relief)
117 }
118
119 pub fn new(text: String) -> Adapted<TextBox> {
120 let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
121 let editor_state = TextEditorState::new(text.clone());
122 Adapted::new(TextBox {
123 text,
124 editing: false,
125 edit_buffer: String::new(),
126 just_changed: false,
127 disabled: false,
128 all_selected: false,
129 cursor_idx: 0,
130 select_anchor: None,
131 dragging: false,
132 just_focused: false,
133 drag_start_idx: None,
134 max_width: None,
135 width: None,
136 is_password: false,
137 multiline: false,
138 line_wrap_override: None,
139 draw_bg_border: true,
140 text_color: None,
141 font_size: style_size,
142 font_family: style_family.clone(),
143 placeholder: None,
144 editor_state,
145 history: History::new(),
146 scroll_y: 0.0,
147 scroll_x: 0.0,
148 scroll_motion: ScrollMotion::new(),
149 scroll_max: (0.0, 0.0),
150 default_font_size: style_size,
151 default_font_family: style_family,
152 cursor_x_offset: 0.0,
153 glyph_positions: Vec::new(),
154 total_text_width: 0.0,
155 shaped_char_advance: 0.0,
156 line_glyph_positions: Vec::new(),
157 update_on_type: false,
158 label: None,
159 hovered: false,
160 rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
161 recessed: None,
162 })
163 }
164
165 /// The detached-label strip height above the content (zero unlabeled) — the
166 /// adapter's `Widget::label_offset` over the synced label.
167 fn label_top(&self) -> f32 {
168 crate::widget::input::slider::detached_strip(&self.label)
169 }
170
171 fn map_x_to_idx(&self, click_x: f32) -> usize {
172 let relative_x = click_x - (self.rect.x + 8.0) + self.scroll_x;
173 if self.glyph_positions.is_empty() {
174 let char_width = self.char_width();
175 return ((relative_x / char_width).round() as isize)
176 .max(0)
177 .min(self.edit_buffer.chars().count() as isize) as usize;
178 }
179
180 let mut closest_idx = 0;
181 let mut min_diff = f32::MAX;
182 for (i, &pos) in self.glyph_positions.iter().enumerate() {
183 let diff = (pos - relative_x).abs();
184 if diff < min_diff {
185 min_diff = diff;
186 closest_idx = i;
187 }
188 }
189 // When the box is empty, `prepare_text` shapes the PLACEHOLDER into
190 // `glyph_positions`, so the nearest-glyph snap above can land on a
191 // placeholder column. Clamp to the real text: the placeholder is
192 // painted, not caret-addressable.
193 let text_len = if self.editing { self.edit_buffer.chars().count() } else { self.text.chars().count() };
194 closest_idx.min(text_len)
195 }
196
197 /// The x offset of `col` on wrapped line `line`, from the shaped per-line
198 /// offsets when recorded, else the uniform-grid estimate.
199 fn line_col_x(&self, line: usize, col: usize) -> f32 {
200 self.line_glyph_positions
201 .get(line)
202 .and_then(|l| l.get(col).copied())
203 .unwrap_or_else(|| col as f32 * self.char_width())
204 }
205
206 /// An x offset (relative to the text origin) → nearest column on wrapped
207 /// line `line`, from the shaped offsets when recorded.
208 fn line_x_to_col(&self, line: usize, relative_x: f32) -> usize {
209 let Some(offsets) = self.line_glyph_positions.get(line).filter(|l| !l.is_empty()) else {
210 return ((relative_x / self.char_width()).round() as isize).max(0) as usize;
211 };
212 let mut closest = 0;
213 let mut min_diff = f32::MAX;
214 for (i, &pos) in offsets.iter().enumerate() {
215 let diff = (pos - relative_x).abs();
216 if diff < min_diff {
217 min_diff = diff;
218 closest = i;
219 }
220 }
221 closest
222 }
223
224 pub fn char_width(&self) -> f32 {
225 if self.shaped_char_advance > 0.0 {
226 self.shaped_char_advance
227 } else {
228 crate::widget::display::measure_text_width("M", &self.font_family, self.font_size)
229 }
230 }
231
232 pub fn line_height(&self) -> f32 {
233 self.font_size * 1.333
234 }
235
236 pub fn wrap_text(&self, max_chars_per_line: usize) -> (Vec<String>, Vec<(usize, usize)>) {
237 let text_src = if self.editing { &self.edit_buffer } else { &self.text };
238 let chars: Vec<char> = text_src.chars().collect();
239 let mut lines = Vec::new();
240 let mut current_line = Vec::new();
241 let mut index_map = vec![(0, 0); chars.len() + 1];
242
243 if !self.line_wrap_enabled() {
244 let mut i = 0;
245 while i < chars.len() {
246 let ch = chars[i];
247 if ch == '\n' {
248 index_map[i] = (lines.len(), current_line.len());
249 lines.push(current_line.iter().collect::<String>());
250 current_line.clear();
251 } else {
252 current_line.push(ch);
253 index_map[i] = (lines.len(), current_line.len() - 1);
254 }
255 i += 1;
256 }
257 index_map[chars.len()] = (lines.len(), current_line.len());
258 lines.push(current_line.iter().collect::<String>());
259 return (lines, index_map);
260 }
261
262 let max_chars = max_chars_per_line.max(1);
263
264 let mut i = 0;
265 while i < chars.len() {
266 let ch = chars[i];
267
268 if ch == '\n' {
269 index_map[i] = (lines.len(), current_line.len());
270 lines.push(current_line.iter().collect::<String>());
271 current_line.clear();
272 i += 1;
273 continue;
274 }
275
276 current_line.push(ch);
277 index_map[i] = (lines.len(), current_line.len() - 1);
278
279 if current_line.len() > max_chars {
280 let mut space_idx = None;
281 for (s_idx, &c) in current_line.iter().enumerate().rev() {
282 if c.is_whitespace() {
283 space_idx = Some(s_idx);
284 break;
285 }
286 }
287
288 if let Some(s_idx) = space_idx {
289 let line_to_push: Vec<char> = current_line[0..s_idx + 1].to_vec();
290 let remaining: Vec<char> = current_line[s_idx + 1..].to_vec();
291
292 let line_idx = lines.len();
293 lines.push(line_to_push.iter().collect::<String>());
294
295 current_line = remaining;
296 let start_orig = i - current_line.len() + 1;
297 for c_idx in 0..current_line.len() {
298 index_map[start_orig + c_idx] = (line_idx + 1, c_idx);
299 }
300 } else {
301 let line_to_push: Vec<char> = current_line[0..max_chars].to_vec();
302 let remaining: Vec<char> = current_line[max_chars..].to_vec();
303
304 let line_idx = lines.len();
305 lines.push(line_to_push.iter().collect::<String>());
306
307 current_line = remaining;
308 let start_orig = i - current_line.len() + 1;
309 for c_idx in 0..current_line.len() {
310 index_map[start_orig + c_idx] = (line_idx + 1, c_idx);
311 }
312 }
313 }
314 i += 1;
315 }
316
317 index_map[chars.len()] = (lines.len(), current_line.len());
318 lines.push(current_line.iter().collect::<String>());
319
320 (lines, index_map)
321 }
322
323 pub fn map_2d_to_1d(&self, index_map: &[(usize, usize)], target_line: usize, target_col: usize, max_line_idx: usize) -> usize {
324 let line = target_line.min(max_line_idx);
325 let mut best_idx = 0;
326 let mut best_dist = usize::MAX;
327
328 for (i, &(l, c)) in index_map.iter().enumerate() {
329 if l == line {
330 let dist = (c as isize - target_col as isize).abs() as usize;
331 if dist < best_dist {
332 best_dist = dist;
333 best_idx = i;
334 }
335 }
336 }
337 best_idx
338 }
339
340 fn border_width(&self) -> f32 {
341 if self.multiline {
342 crate::layout::textbox_multiline_border_width()
343 } else {
344 1.0
345 }
346 }
347
348 pub fn set_placeholder(&mut self, placeholder: &str) {
349 self.placeholder = Some(placeholder.to_string());
350 }
351
352 pub fn take_change(&mut self) -> bool {
353 if self.update_on_type {
354 if self.text != self.edit_buffer {
355 self.text = self.edit_buffer.clone();
356 self.just_changed = true;
357 }
358 }
359 let changed = self.just_changed;
360 self.just_changed = false;
361 changed
362 }
363
364 pub fn line_wrap_enabled(&self) -> bool {
365 self.multiline && self.line_wrap_override.unwrap_or_else(crate::layout::textbox_line_wrap)
366 }
367
368 pub fn set_max_width(&mut self, max_w: Option<f32>) {
369 self.max_width = max_w;
370 }
371
372 pub fn set_width(&mut self, w: f32) {
373 self.width = Some(w);
374 }
375
376 pub fn sync_editor_state(&mut self) {
377 self.editor_state = TextEditorState {
378 buffer: self.edit_buffer.clone(),
379 cursor_idx: self.cursor_idx,
380 select_anchor: self.select_anchor,
381 all_selected: self.all_selected,
382 };
383 }
384
385 /// The editing fields as one value — what the history stores.
386 fn snapshot(&self) -> TextEditorState {
387 TextEditorState {
388 buffer: self.edit_buffer.clone(),
389 cursor_idx: self.cursor_idx,
390 select_anchor: self.select_anchor,
391 all_selected: self.all_selected,
392 }
393 }
394
395 fn restore(&mut self, snap: TextEditorState) {
396 self.edit_buffer = snap.buffer;
397 self.cursor_idx = snap.cursor_idx;
398 self.select_anchor = snap.select_anchor;
399 self.all_selected = snap.all_selected;
400 self.sync_editor_state();
401 self.scroll_to_cursor();
402 }
403
404 /// Step the edit buffer back one recorded step. Only while editing —
405 /// a committed value is the app's to undo, not the box's.
406 pub fn undo_edit(&mut self) -> bool {
407 if !self.editing || self.disabled {
408 return false;
409 }
410 let current = self.snapshot();
411 match self.history.undo(current) {
412 Some(prev) => {
413 self.restore(prev);
414 self.just_changed = true;
415 true
416 }
417 None => false,
418 }
419 }
420
421 /// Step forward again — see [`undo_edit`](Self::undo_edit).
422 pub fn redo_edit(&mut self) -> bool {
423 if !self.editing || self.disabled {
424 return false;
425 }
426 let current = self.snapshot();
427 match self.history.redo(current) {
428 Some(next) => {
429 self.restore(next);
430 self.just_changed = true;
431 true
432 }
433 None => false,
434 }
435 }
436
437 pub fn copy_selection(&self) {
438 let state = TextEditorState {
439 buffer: self.edit_buffer.clone(),
440 cursor_idx: self.cursor_idx,
441 select_anchor: self.select_anchor,
442 all_selected: self.all_selected,
443 };
444 if let Some(text) = state.selected_text() {
445 clipboard::copy_to_clipboard(&text);
446 }
447 }
448
449 pub fn cut_selection(&mut self) -> bool {
450 let before = self.snapshot();
451 let mut state = TextEditorState {
452 buffer: std::mem::take(&mut self.edit_buffer),
453 cursor_idx: self.cursor_idx,
454 select_anchor: self.select_anchor,
455 all_selected: self.all_selected,
456 };
457 if let Some(text) = state.selected_text() {
458 clipboard::copy_to_clipboard(&text);
459 state.insert_text("");
460 self.history.record(before);
461 self.edit_buffer = state.buffer;
462 self.cursor_idx = state.cursor_idx;
463 self.select_anchor = state.select_anchor;
464 self.all_selected = state.all_selected;
465 self.sync_editor_state();
466 true
467 } else {
468 self.edit_buffer = state.buffer;
469 self.sync_editor_state();
470 false
471 }
472 }
473
474 pub fn paste_from_clipboard(&mut self) -> bool {
475 if let Some(text) = clipboard::read_from_clipboard() {
476 let before = self.snapshot();
477 let mut state = TextEditorState {
478 buffer: std::mem::take(&mut self.edit_buffer),
479 cursor_idx: self.cursor_idx,
480 select_anchor: self.select_anchor,
481 all_selected: self.all_selected,
482 };
483 let mut cleaned = String::new();
484 for ch in text.chars() {
485 if !ch.is_control() && ch != '\n' && ch != '\r' {
486 cleaned.push(ch);
487 }
488 }
489 state.insert_text(&cleaned);
490 self.history.record(before);
491 self.edit_buffer = state.buffer;
492 self.cursor_idx = state.cursor_idx;
493 self.select_anchor = state.select_anchor;
494 self.all_selected = state.all_selected;
495 self.sync_editor_state();
496 true
497 } else {
498 false
499 }
500 }
501
502 pub fn select_all(&mut self) {
503 let mut state = TextEditorState {
504 buffer: std::mem::take(&mut self.edit_buffer),
505 cursor_idx: self.cursor_idx,
506 select_anchor: self.select_anchor,
507 all_selected: self.all_selected,
508 };
509 state.select_all();
510 self.edit_buffer = state.buffer;
511 self.cursor_idx = state.cursor_idx;
512 self.select_anchor = state.select_anchor;
513 self.all_selected = state.all_selected;
514 self.sync_editor_state();
515 }
516
517 pub fn set_value(&mut self, val: &str) -> bool {
518 let val_str = val.to_string();
519 if self.text != val_str {
520 if self.editing {
521 let before = self.snapshot();
522 self.history.record(before);
523 } else {
524 self.history.clear();
525 }
526 self.text = val_str.clone();
527 self.edit_buffer = val_str;
528 self.just_changed = true;
529 let len = self.edit_buffer.chars().count();
530 self.cursor_idx = self.cursor_idx.min(len);
531 if let Some(anchor) = self.select_anchor {
532 self.select_anchor = Some(anchor.min(len));
533 }
534 if self.cursor_idx == 0 && self.select_anchor == Some(0) {
535 self.all_selected = false;
536 }
537 self.sync_editor_state();
538 self.clamp_scroll();
539 true
540 } else {
541 false
542 }
543 }
544
545 /// The widest line's drawn advance — shaped when recorded, else the
546 /// chars × char_width estimate (the pre-shaping formula).
547 fn content_width(&self, lines: &[String]) -> f32 {
548 let shaped = if self.multiline {
549 self.line_glyph_positions
550 .iter()
551 .filter_map(|l| l.last().copied())
552 .fold(0.0f32, f32::max)
553 } else {
554 self.total_text_width
555 };
556 if shaped > 0.0 {
557 shaped
558 } else {
559 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
560 max_line_len as f32 * self.char_width()
561 }
562 }
563
564 pub fn clamp_scroll(&mut self) {
565 let char_width = self.char_width();
566 let line_height = self.line_height();
567 let max_chars = if self.line_wrap_enabled() {
568 (((self.rect.width - 16.0) / char_width).floor() as usize).max(1)
569 } else {
570 999999
571 };
572 let (lines, _) = if self.multiline {
573 self.wrap_text(max_chars)
574 } else {
575 let buffer = if self.editing { &self.edit_buffer } else { &self.text };
576 (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
577 };
578
579 if self.multiline {
580 let content_h = lines.len() as f32 * line_height;
581 let max_scroll = (content_h - (self.rect.height - 16.0)).max(0.0);
582 self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
583 } else {
584 self.scroll_y = 0.0;
585 }
586
587 if !self.line_wrap_enabled() {
588 let content_w = self.content_width(&lines);
589 let max_scroll_x = (content_w - (self.rect.width - 16.0)).max(0.0);
590 self.scroll_x = self.scroll_x.clamp(0.0, max_scroll_x);
591 } else {
592 self.scroll_x = 0.0;
593 }
594 }
595
596 pub fn scroll_to_cursor(&mut self) {
597 let char_width = self.char_width();
598 let line_height = self.line_height();
599 let max_chars = if self.line_wrap_enabled() {
600 (((self.rect.width - 16.0) / char_width).floor() as usize).max(1)
601 } else {
602 999999
603 };
604 let (_lines, index_map) = if self.multiline {
605 self.wrap_text(max_chars)
606 } else {
607 let buffer = if self.editing { &self.edit_buffer } else { &self.text };
608 let mut m = Vec::new();
609 for i in 0..=buffer.chars().count() {
610 m.push((0, i));
611 }
612 (vec![buffer.clone()], m)
613 };
614 if index_map.is_empty() { return; }
615
616 let cursor_idx = self.cursor_idx.min(index_map.len() - 1);
617 let (line_idx, col_idx) = index_map[cursor_idx];
618
619 let top = self.label_top();
620 let viewport_w = self.rect.width - 16.0;
621 let viewport_h = self.rect.height - top - 16.0;
622
623 if self.multiline {
624 let line_y = top + 8.0 + (line_idx as f32 * line_height);
625 if line_y < self.scroll_y + 10.0 {
626 self.scroll_y = (line_y - 20.0).max(0.0);
627 } else if line_y + line_height > self.scroll_y + viewport_h - 10.0 {
628 self.scroll_y = (line_y + line_height - viewport_h + 20.0).max(0.0);
629 }
630 }
631
632 if !self.line_wrap_enabled() {
633 let cursor_x = if self.multiline {
634 self.line_col_x(line_idx, col_idx)
635 } else {
636 self.glyph_positions
637 .get(col_idx)
638 .copied()
639 .unwrap_or(col_idx as f32 * char_width)
640 };
641 if cursor_x < self.scroll_x + 10.0 {
642 self.scroll_x = (cursor_x - 20.0).max(0.0);
643 } else if cursor_x + char_width > self.scroll_x + viewport_w - 10.0 {
644 self.scroll_x = (cursor_x + char_width - viewport_w + 20.0).max(0.0);
645 }
646 }
647 self.clamp_scroll();
648 }
649
650 /// The legacy `focus()` body minus the global-focus claim (the caller's, via
651 /// `EventCtx::request_focus`).
652 fn begin_editing(&mut self) {
653 if self.disabled { return; }
654 self.editing = true;
655 self.edit_buffer = self.text.clone();
656 let len = self.edit_buffer.chars().count();
657 self.cursor_idx = len;
658 self.select_anchor = Some(0);
659 self.all_selected = len > 0;
660 self.just_focused = true;
661 self.history.clear();
662 self.sync_editor_state();
663 }
664
665 /// The legacy `unfocus()` body: leave edit mode and commit the buffer.
666 fn commit_editing(&mut self) {
667 if self.editing {
668 self.editing = false;
669 if self.text != self.edit_buffer {
670 self.text = self.edit_buffer.clone();
671 self.just_changed = true;
672 }
673 self.select_anchor = None;
674 self.all_selected = false;
675 self.sync_editor_state();
676 }
677 }
678
679 /// Map a press/drag position to a buffer index — the shared body of the legacy
680 /// `mouse_input` press arm and `drag_update`.
681 fn position_to_idx(&self, px: f32, py: f32) -> usize {
682 let char_width = self.char_width();
683 let top = self.label_top();
684 if self.multiline {
685 let line_height = self.line_height();
686 let max_chars = if self.line_wrap_enabled() {
687 (((self.rect.width - 16.0) / char_width).floor() as usize).max(1)
688 } else {
689 999999
690 };
691 let (lines, index_map) = self.wrap_text(max_chars);
692 let click_line = (((py - (self.rect.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
693 let rel_x = px - (self.rect.x + 8.0) + self.scroll_x;
694 let click_col = self.line_x_to_col(click_line.min(lines.len() - 1), rel_x);
695 self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
696 } else {
697 self.map_x_to_idx(px)
698 }
699 }
700
701 /// Extend the selection to a drag position — the shared body of the legacy
702 /// `on_cursor_moved` drag arm and `drag_update` (which used no label inset).
703 fn extend_selection_to(&mut self, px: f32, py: f32) -> bool {
704 let drag_idx = self.position_to_idx(px, py);
705 if self.cursor_idx != drag_idx {
706 self.cursor_idx = drag_idx;
707 self.just_focused = false;
708 let len = self.edit_buffer.chars().count();
709 let start = self.select_anchor.unwrap_or(0).min(self.cursor_idx);
710 let end = self.select_anchor.unwrap_or(0).max(self.cursor_idx);
711 self.all_selected = start == 0 && end == len && len > 0;
712 true
713 } else {
714 false
715 }
716 }
717
718 /// Port of the legacy `keyboard_input` body.
719 fn handle_key(&mut self, event: &KeyEvent) -> bool {
720 if !self.editing || self.disabled { return false; }
721 if event.state != ElementState::Pressed { return false; }
722
723 let control = event.ctrl;
724
725 // The undo/redo chords, for apps that hand keys to widgets without
726 // exposing a `UiContext` (the runner's routing reaches the box
727 // through `ContextAction` first when they do). Before the working
728 // copy below, since a step replaces the whole editing state.
729 if control {
730 if match_key_shortcut(event, &crate::input::widget_chord("undo", "", "ctrl+z")) {
731 return self.undo_edit();
732 }
733 if match_key_shortcut(event, &crate::input::widget_chord("redo", "", "ctrl+shift+z")) {
734 return self.redo_edit();
735 }
736 }
737
738 let before = self.snapshot();
739 let mut state = before.clone();
740
741 let handled = match &event.logical_key {
742 Key::Named(NamedKey::Backspace) => {
743 state.delete_backwards()
744 }
745 Key::Named(NamedKey::Delete) => {
746 state.delete_forwards()
747 }
748 Key::Named(NamedKey::ArrowLeft) => {
749 state.move_cursor_left(event.shift)
750 }
751 Key::Named(NamedKey::ArrowRight) => {
752 state.move_cursor_right(event.shift)
753 }
754 Key::Named(NamedKey::ArrowUp) => {
755 if state.all_selected {
756 state.clear_selection();
757 } else if event.shift {
758 if state.select_anchor.is_none() {
759 state.select_anchor = Some(state.cursor_idx);
760 }
761 } else {
762 state.clear_selection();
763 }
764 if self.multiline {
765 let char_width = self.char_width();
766 let max_chars = (((self.rect.width - 16.0) / char_width).floor() as usize).max(1);
767 let (lines, index_map) = self.wrap_text(max_chars);
768 let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
769 if cursor_l > 0 {
770 state.cursor_idx = self.map_2d_to_1d(&index_map, cursor_l - 1, cursor_c, lines.len() - 1);
771 } else {
772 state.cursor_idx = 0;
773 }
774 } else {
775 state.cursor_idx = 0;
776 }
777 true
778 }
779 Key::Named(NamedKey::ArrowDown) => {
780 if state.all_selected {
781 state.clear_selection();
782 } else if event.shift {
783 if state.select_anchor.is_none() {
784 state.select_anchor = Some(state.cursor_idx);
785 }
786 } else {
787 state.clear_selection();
788 }
789 if self.multiline {
790 let char_width = self.char_width();
791 let max_chars = (((self.rect.width - 16.0) / char_width).floor() as usize).max(1);
792 let (lines, index_map) = self.wrap_text(max_chars);
793 let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
794 if cursor_l < lines.len() - 1 {
795 state.cursor_idx = self.map_2d_to_1d(&index_map, cursor_l + 1, cursor_c, lines.len() - 1);
796 } else {
797 state.cursor_idx = state.buffer.chars().count();
798 }
799 } else {
800 state.cursor_idx = state.buffer.chars().count();
801 }
802 true
803 }
804 Key::Named(NamedKey::Home) => {
805 state.move_cursor_to_start(event.shift)
806 }
807 Key::Named(NamedKey::End) => {
808 state.move_cursor_to_end(event.shift)
809 }
810 Key::Named(NamedKey::Enter) => {
811 if self.multiline {
812 state.insert_text("\n");
813 true
814 } else {
815 self.commit_editing();
816 true
817 }
818 }
819 Key::Named(NamedKey::Escape) => {
820 self.editing = false;
821 state.buffer = self.text.clone();
822 state.clear_selection();
823 true
824 }
825 Key::Character(ref ch_str) if control && (ch_str == "a" || ch_str == "A") => {
826 state.select_all();
827 true
828 }
829 Key::Character(ref ch_str) if control && (ch_str == "c" || ch_str == "C") => {
830 if let Some(text) = state.selected_text() {
831 clipboard::copy_to_clipboard(&text);
832 }
833 true
834 }
835 Key::Character(ref ch_str) if control && (ch_str == "x" || ch_str == "X") => {
836 if let Some(text) = state.selected_text() {
837 clipboard::copy_to_clipboard(&text);
838 state.insert_text("");
839 }
840 true
841 }
842 Key::Character(ref ch_str) if control && (ch_str == "v" || ch_str == "V") => {
843 if let Some(pasted) = clipboard::read_from_clipboard() {
844 let mut cleaned = String::new();
845 for ch in pasted.chars() {
846 if !ch.is_control() && ch != '\n' && ch != '\r' {
847 cleaned.push(ch);
848 }
849 }
850 state.insert_text(&cleaned);
851 } else {
852 state.clear_selection();
853 }
854 true
855 }
856 _ => {
857 if let Some(text) = &event.text {
858 if !control {
859 state.insert_text(text);
860 true
861 } else {
862 false
863 }
864 } else {
865 false
866 }
867 }
868 };
869
870 if self.editing {
871 if state.buffer != before.buffer {
872 // One step per typed run, per deleted run; whitespace
873 // starts a new run so undo walks back a word at a time.
874 // Replacing a selection is always its own step.
875 let group = match &event.logical_key {
876 _ if before.selected_range().is_some() => None,
877 Key::Named(NamedKey::Backspace) => Some(2),
878 Key::Named(NamedKey::Delete) => Some(3),
879 Key::Character(_) if !control => {
880 let ws = event.text.as_deref().map_or(false, |t| t.chars().all(char::is_whitespace));
881 Some(if ws { 4 } else { 1 })
882 }
883 _ => None,
884 };
885 match group {
886 Some(g) => self.history.record_grouped(before, g),
887 None => self.history.record(before),
888 }
889 } else if handled {
890 // A cursor or selection move between keystrokes splits the
891 // run: "abc", move, "def" undoes as two steps.
892 self.history.break_group();
893 }
894 self.edit_buffer = state.buffer;
895 self.cursor_idx = state.cursor_idx;
896 self.select_anchor = state.select_anchor;
897 self.all_selected = state.all_selected;
898 self.sync_editor_state();
899 self.scroll_to_cursor();
900 }
901
902 handled
903 }
904
905 /// Port of the legacy `mouse_wheel` body (scroll the multiline/no-wrap viewports).
906 fn handle_wheel(&mut self, delta: &MouseScrollDelta) -> bool {
907 if self.disabled { return false; }
908 let char_width = self.char_width();
909 let line_height = self.line_height();
910
911 let max_chars = if self.line_wrap_enabled() {
912 (((self.rect.width - 16.0) / char_width).floor() as usize).max(1)
913 } else {
914 999999
915 };
916
917 let (lines, _) = if self.multiline {
918 self.wrap_text(max_chars)
919 } else {
920 let buffer = if self.editing { &self.edit_buffer } else { &self.text };
921 (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
922 };
923
924 let mut dy_px = 0.0;
925 let mut max_scroll_y = 0.0;
926 if self.multiline {
927 let content_h = lines.len() as f32 * line_height;
928 max_scroll_y = (content_h - (self.rect.height - 16.0)).max(0.0);
929 dy_px = match *delta {
930 MouseScrollDelta::LineDelta(_, dy) => -dy * line_height * 2.0,
931 MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
932 };
933 }
934
935 let mut dx_px = 0.0;
936 let mut max_scroll_x = 0.0;
937 if !self.line_wrap_enabled() {
938 let content_w = self.content_width(&lines);
939 max_scroll_x = (content_w - (self.rect.width - 16.0)).max(0.0);
940 let natural = crate::layout::touchpad_natural_scroll();
941 let scroll_amt_x = match *delta {
942 MouseScrollDelta::LineDelta(dx, dy) => {
943 if !self.multiline {
944 let scroll_val = if dy != 0.0 { -dy } else { if natural { -dx } else { dx } };
945 scroll_val * char_width * 3.0
946 } else {
947 let scroll_val = if natural { -dx } else { dx };
948 scroll_val * char_width * 3.0
949 }
950 }
951 MouseScrollDelta::PixelDelta(pos) => {
952 if !self.multiline {
953 let scroll_val = if pos.y != 0.0 { -pos.y as f32 } else { if natural { -pos.x as f32 } else { pos.x as f32 } };
954 scroll_val
955 } else {
956 if natural { -pos.x as f32 } else { pos.x as f32 }
957 }
958 }
959 };
960 dx_px = scroll_amt_x;
961 }
962
963 // Both axes through the shared motion: notches glide, finger tracks
964 // 1:1, a flick coasts. The pub offsets are the drawn values.
965 self.scroll_max = (max_scroll_x, max_scroll_y);
966 self.scroll_motion.reconcile(self.scroll_x, self.scroll_y);
967 let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
968 let changed = self.scroll_motion.apply_px(
969 dx_px,
970 dy_px,
971 discrete,
972 Bounds::max(max_scroll_x),
973 Bounds::max(max_scroll_y),
974 );
975 self.scroll_x = self.scroll_motion.x.pos();
976 self.scroll_y = self.scroll_motion.y.pos();
977 changed
978 }
979
980 /// Selection highlight + caret quads, shared by both render branches. `x`/`w` are the
981 /// (possibly label-inset) horizontal span the branch draws in — the legacy paths differed
982 /// (non-rounded and rounded alike use the full base span).
983 fn selection_quads(&self, x: f32, w: f32, out: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
984 if !(self.editing || self.select_anchor.is_some()) {
985 return;
986 }
987 let top = self.label_top();
988 let char_width = self.char_width();
989 let line_height = self.line_height();
990
991 let highlight_color = [0.20, 0.50, 0.85, 0.3];
992 let cursor_color = if self.draw_bg_border {
993 [0.80, 0.80, 0.85, 1.0]
994 } else {
995 [0.10, 0.10, 0.15, 1.0]
996 };
997
998 let start = self.select_anchor.unwrap_or(self.cursor_idx).min(self.cursor_idx);
999 let end = self.select_anchor.unwrap_or(self.cursor_idx).max(self.cursor_idx);
1000
1001 if self.multiline {
1002 let max_chars = if self.line_wrap_enabled() {
1003 (((w - 16.0) / char_width).floor() as usize).max(1)
1004 } else {
1005 999999
1006 };
1007 let (_lines, index_map) = self.wrap_text(max_chars);
1008
1009 let view_top = self.rect.y + top;
1010 let view_bottom = self.rect.y + self.rect.height;
1011
1012 if start != end {
1013 let start_pos = index_map[start.min(index_map.len() - 1)];
1014 let end_pos = index_map[end.min(index_map.len() - 1)];
1015
1016 for line_idx in start_pos.0..=end_pos.0 {
1017 let mut line_start_col = None;
1018 let mut line_end_col = None;
1019 for idx in start..end {
1020 if idx < index_map.len() {
1021 let (l, c) = index_map[idx];
1022 if l == line_idx {
1023 if line_start_col.is_none() || c < line_start_col.unwrap() {
1024 line_start_col = Some(c);
1025 }
1026 if line_end_col.is_none() || c > line_end_col.unwrap() {
1027 line_end_col = Some(c);
1028 }
1029 }
1030 }
1031 }
1032 if let (Some(sc), Some(ec)) = (line_start_col, line_end_col) {
1033 let highlight_x = x + 8.0 + self.line_col_x(line_idx, sc) - self.scroll_x;
1034 let highlight_w = self.line_col_x(line_idx, ec + 1) - self.line_col_x(line_idx, sc);
1035 let highlight_y = self.rect.y + top + 8.0 + (line_idx as f32 * line_height) - self.scroll_y;
1036 let clipped_y = highlight_y.max(view_top);
1037 let clipped_bottom = (highlight_y + line_height).min(view_bottom);
1038 let h_left = highlight_x.max(x + 8.0);
1039 let h_right = (highlight_x + highlight_w).min(x + w - 8.0);
1040 if h_left < h_right && clipped_y < clipped_bottom {
1041 out.push((h_left, clipped_y, h_right - h_left, clipped_bottom - clipped_y, highlight_color));
1042 }
1043 }
1044 }
1045 }
1046
1047 if self.editing {
1048 let caret_h = self.font_size * 1.15;
1049 let (cursor_l, cursor_c) = index_map[self.cursor_idx.min(index_map.len() - 1)];
1050 let cursor_x = x + 8.0 + self.line_col_x(cursor_l, cursor_c) - self.scroll_x;
1051 let cursor_y = self.rect.y + top + 8.0 + (cursor_l as f32 * line_height) + (line_height - caret_h) / 2.0 - self.scroll_y;
1052 let clipped_y = cursor_y.max(view_top);
1053 let clipped_bottom = (cursor_y + caret_h).min(view_bottom);
1054 if cursor_x >= x + 8.0 && cursor_x <= x + w - 8.0 {
1055 if clipped_y < clipped_bottom {
1056 out.push((cursor_x, clipped_y, 1.5, clipped_bottom - clipped_y, cursor_color));
1057 }
1058 }
1059 }
1060 } else {
1061 let caret_h = self.font_size * 1.15;
1062 if start != end {
1063 let h_left_offset = self.glyph_positions.get(start).copied().unwrap_or_else(|| start as f32 * char_width);
1064 let h_right_offset = self.glyph_positions.get(end).copied().unwrap_or_else(|| end as f32 * char_width);
1065 let highlight_x = x + 8.0 + h_left_offset - self.scroll_x;
1066 let h_left = highlight_x.max(x + 8.0);
1067 let h_right = (x + 8.0 + h_right_offset - self.scroll_x).min(x + w - 8.0);
1068 if h_left < h_right {
1069 out.push((
1070 h_left,
1071 crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
1072 h_right - h_left,
1073 crate::layout::line_height(self.font_size),
1074 highlight_color,
1075 ));
1076 }
1077 }
1078
1079 if self.editing {
1080 let offset = if self.glyph_positions.is_empty() {
1081 self.cursor_idx as f32 * char_width
1082 } else {
1083 self.cursor_x_offset
1084 };
1085 let cursor_x = x + 8.0 + offset - self.scroll_x;
1086 if cursor_x >= x + 8.0 && cursor_x <= x + w - 8.0 {
1087 let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
1088 let cursor_y = text_y + (self.font_size - caret_h) / 2.0;
1089 out.push((cursor_x, cursor_y, 1.5, caret_h, cursor_color));
1090 }
1091 }
1092 }
1093 }
1094
1095 /// The value/placeholder text lines — the legacy `text_labels` body minus the control
1096 /// label (the adapter's base-label machinery draws that).
1097 fn value_labels(&self) -> Vec<TextLabel> {
1098 let mut labels = Vec::new();
1099 let top = self.label_top();
1100 let mut val_text = if self.editing {
1101 self.edit_buffer.clone()
1102 } else {
1103 self.text.clone()
1104 };
1105 if self.is_password {
1106 val_text = "•".repeat(val_text.chars().count());
1107 }
1108
1109 let is_placeholder = val_text.is_empty() && self.placeholder.is_some();
1110 let display_text = if is_placeholder {
1111 self.placeholder.as_ref().unwrap().clone()
1112 } else {
1113 val_text
1114 };
1115
1116 let label_color = if is_placeholder {
1117 crate::colors::textbox_placeholder_text_color()
1118 } else if let Some(custom_color) = self.text_color {
1119 custom_color
1120 } else if self.disabled {
1121 [0x53, 0x53, 0x5a]
1122 } else if self.all_selected {
1123 [0xff, 0xff, 0xff]
1124 } else if self.editing {
1125 [0xee, 0xee, 0xf5]
1126 } else {
1127 [0xcc, 0xcc, 0xd4]
1128 };
1129
1130 let x = self.rect.x;
1131 let w = self.rect.width;
1132
1133 if self.multiline {
1134 let char_width = self.char_width();
1135 let line_height = self.line_height();
1136 let max_chars = if self.line_wrap_enabled() {
1137 (((w - 16.0) / char_width).floor() as usize).max(1)
1138 } else {
1139 999999
1140 };
1141 let (lines, _) = self.wrap_text(max_chars);
1142 let lines_to_draw = if is_placeholder {
1143 let placeholder_src = self.placeholder.as_ref().unwrap();
1144 let chars: Vec<char> = placeholder_src.chars().collect();
1145 let mut p_lines = Vec::new();
1146 let mut current_line = Vec::new();
1147 for ch in chars {
1148 if ch == '\n' {
1149 p_lines.push(current_line.iter().collect::<String>());
1150 current_line.clear();
1151 } else {
1152 current_line.push(ch);
1153 if self.line_wrap_enabled() && current_line.len() > max_chars {
1154 p_lines.push(current_line.iter().collect::<String>());
1155 current_line.clear();
1156 }
1157 }
1158 }
1159 p_lines.push(current_line.iter().collect::<String>());
1160 p_lines
1161 } else {
1162 lines
1163 };
1164 for (line_idx, line_text) in lines_to_draw.iter().enumerate() {
1165 labels.push(TextLabel {
1166 text: line_text.clone(),
1167 x: x + 8.0 - self.scroll_x,
1168 y: self.rect.y + top + 8.0 + (line_idx as f32 * line_height) + (line_height - self.font_size) / 2.0 - self.scroll_y,
1169 font_size: self.font_size,
1170 color: label_color,
1171 });
1172 }
1173 } else {
1174 labels.push(TextLabel {
1175 text: display_text,
1176 x: x + 8.0 - self.scroll_x,
1177 y: crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
1178 font_size: self.font_size,
1179 color: label_color,
1180 });
1181 }
1182 labels
1183 }
1184
1185 /// The well this TextBox carves, as `(rect, corner radius, depth, focus
1186 /// tint)` — `None` when it draws no relief at all (square-cornered legacy
1187 /// geometry, `control_relief` off, or a box that draws no background).
1188 ///
1189 /// The SINGLE source for that geometry: `paint` carves it here, and the
1190 /// flat-path bridge in `layout::render_widget` re-offers the same rect
1191 /// through [`crate::layout::RenderTarget::recess`] for hosts that consume
1192 /// `all_quads` and so never see the carve. A second copy of this math in
1193 /// the bridge is exactly how the two would drift apart.
1194 pub fn well(&self) -> Option<(Rect, f32, f32, Option<[f32; 3]>)> {
1195 let radius = crate::layout::textbox_corner_radius();
1196 if radius <= 0.0 || !self.recessed() || !self.draw_bg_border {
1197 return None;
1198 }
1199 let top = self.label_top();
1200 let well = Rect {
1201 x: self.rect.x,
1202 y: self.rect.y + top,
1203 width: self.rect.width,
1204 height: self.rect.height - top,
1205 };
1206 let depth = crate::layout::bevel_width().min(well.height * 0.2);
1207 let (well, radii) = crate::layout::carve_inside(well, (radius, radius, radius, radius), depth);
1208 let tint = self.editing.then(|| {
1209 let hc = crate::color::highlight_primary_color();
1210 [hc[0], hc[1], hc[2]]
1211 });
1212 Some((well, radii.0, depth, tint))
1213 }
1214 }
1215
1216 impl Adapted<TextBox> {
1217 /// Recessed style: see the `recessed` field.
1218 pub fn with_recessed(mut self, recessed: bool) -> Self {
1219 self.recessed = Some(recessed);
1220 self
1221 }
1222
1223 pub fn with_update_on_type(mut self, update: bool) -> Self {
1224 self.update_on_type = update;
1225 self
1226 }
1227
1228 pub fn with_multiline(mut self, multiline: bool) -> Self {
1229 self.multiline = multiline;
1230 self
1231 }
1232
1233 pub fn with_line_wrap(mut self, wrap: bool) -> Self {
1234 self.line_wrap_override = Some(wrap);
1235 self
1236 }
1237
1238 pub fn with_draw_bg_border(mut self, draw: bool) -> Self {
1239 self.draw_bg_border = draw;
1240 self
1241 }
1242
1243 pub fn with_text_color(mut self, color: Option<[u8; 3]>) -> Self {
1244 self.text_color = color;
1245 self
1246 }
1247
1248 pub fn with_font_size(mut self, size: f32) -> Self {
1249 self.font_size = size;
1250 self
1251 }
1252
1253 pub fn with_font_family(mut self, family: String) -> Self {
1254 self.font_family = family;
1255 self
1256 }
1257
1258 pub fn with_password(mut self, is_password: bool) -> Self {
1259 self.is_password = is_password;
1260 self
1261 }
1262
1263 pub fn with_placeholder(mut self, placeholder: &str) -> Self {
1264 self.placeholder = Some(placeholder.to_string());
1265 self
1266 }
1267
1268 pub fn with_max_width(mut self, max_w: Option<f32>) -> Self {
1269 self.max_width = max_w;
1270 self
1271 }
1272
1273 pub fn with_width(mut self, w: f32) -> Self {
1274 self.width = Some(w);
1275 self
1276 }
1277 }
1278
1279 impl Layout for TextBox {
1280
1281 /// One row for a single-line box; a multiline box has no natural height of its own —
1282 /// the host sizes it, and a layout strategy leaves its assigned rect alone.
1283 fn intrinsic_size(&self) -> Option<Size> {
1284 if self.multiline {
1285 return None;
1286 }
1287 Some(Size::new(0.0, crate::layout::textbox_height()))
1288 }
1289
1290 fn hit_row_rect(&self) -> bool {
1291 true
1292 }
1293
1294 /// The legacy `set_rect` width clamp: an explicit `width` wins, else cap at `max_width`.
1295 fn adjust_rect(&self, requested: Rect) -> Rect {
1296 let final_w = if let Some(explicit_w) = self.width {
1297 explicit_w
1298 } else if let Some(max_w) = self.max_width {
1299 requested.width.min(max_w)
1300 } else {
1301 requested.width
1302 };
1303 Rect { width: final_w, ..requested }
1304 }
1305
1306 /// The legacy `set_row_rect` applied the same clamp to the row span.
1307 fn adjust_row_rect(&self, x: f32, w: f32) -> (f32, f32) {
1308 let final_w = if let Some(explicit_w) = self.width {
1309 explicit_w
1310 } else if let Some(max_w) = self.max_width {
1311 w.min(max_w)
1312 } else {
1313 w
1314 };
1315 (x, final_w)
1316 }
1317
1318 fn rect_assigned(&mut self, rect: Rect) {
1319 self.rect = rect;
1320 self.clamp_scroll();
1321 }
1322 }
1323
1324 impl Paint for TextBox {
1325 fn color(&self) -> [f32; 4] {
1326 [0.10, 0.10, 0.16, 1.0]
1327 }
1328
1329 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
1330 let r = crate::layout::textbox_corner_radius();
1331 if r > 0.0 {
1332 Some((r, (true, true, true, true)))
1333 } else {
1334 Some((r, (false, false, false, false)))
1335 }
1336 }
1337
1338 fn widget_font(&self) -> Option<String> {
1339 Some(crate::layout::control_label_font_detached())
1340 }
1341
1342 /// Content text font for the paint walk: a TextBox whose `font_family`/`font_size` was
1343 /// deliberately customized (cce-text-editor's monospace editor) draws its value text in
1344 /// that family at the label's own size — a bare family name, so the control-font string's
1345 /// size suffix doesn't override `font_size`. Default boxes keep the `widget_font` string
1346 /// verbatim (the legacy convention, size suffix included).
1347 fn text_font(&self) -> Option<String> {
1348 if self.font_family != self.default_font_family || self.font_size != self.default_font_size {
1349 Some(self.font_family.clone())
1350 } else {
1351 self.widget_font()
1352 }
1353 }
1354
1355 fn sync_label(&mut self, label: &str) {
1356 self.label = Some(label.to_string());
1357 }
1358
1359 fn text_bounds(&self, rect: Rect) -> Option<[f32; 4]> {
1360 // Legacy bounded-text getters clipped to the full block rect.
1361 let top = self.label_top();
1362 let base_y = rect.y - top;
1363 let base_h = rect.height + top;
1364 Some([rect.x, base_y, rect.x + rect.width, base_y + base_h])
1365 }
1366
1367 /// The legacy `prepare_text`: sync font family/size with the live config defaults, then
1368 /// shape the display text and record per-glyph advances (`map_x_to_idx` reads them).
1369 fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
1370 let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
1371 if self.font_size == self.default_font_size {
1372 self.font_size = style_size;
1373 }
1374 self.default_font_size = style_size;
1375
1376 if self.font_family == self.default_font_family {
1377 self.font_family = style_family.clone();
1378 }
1379 self.default_font_family = style_family;
1380
1381 let text_src = if self.editing { &self.edit_buffer } else { &self.text };
1382 let display_text = if text_src.is_empty() && self.placeholder.is_some() {
1383 self.placeholder.as_ref().unwrap().as_str()
1384 } else {
1385 text_src.as_str()
1386 };
1387
1388 let font_fam = if self.is_password {
1389 "monospace"
1390 } else {
1391 self.font_family.as_str()
1392 };
1393
1394 let render_text = if self.is_password {
1395 "•".repeat(display_text.chars().count())
1396 } else {
1397 display_text.to_string()
1398 };
1399
1400 let buffer = crate::widget::display::text_label::make_widget_text_buffer(fs, &render_text, self.font_size, font_fam);
1401
1402 let char_count = render_text.chars().count();
1403 let mut x_offsets = vec![0.0; char_count + 1];
1404 let mut total_w: f32 = 0.0;
1405 let scale = crate::scale::scale_factor().max(1.0);
1406
1407 // One column's advance, from the same shaping path as the labels (buffer-cached,
1408 // so this is a lookup after the first frame per family/size).
1409 let probe = crate::widget::display::text_label::make_widget_text_buffer(fs, "MMMMMMMM", self.font_size, font_fam);
1410 self.shaped_char_advance = probe
1411 .layout_runs()
1412 .next()
1413 .and_then(|run| run.glyphs.last().map(|g| (g.x + g.w) / scale / 8.0))
1414 .unwrap_or(0.0);
1415
1416 for (start, gx, gw) in crate::backend::window_runner::normalized_glyph_starts(&buffer, &render_text) {
1417 let c_idx = render_text[..start.min(render_text.len())].chars().count();
1418 if c_idx < x_offsets.len() {
1419 x_offsets[c_idx] = gx / scale;
1420 }
1421 total_w = total_w.max((gx + gw) / scale);
1422 }
1423
1424 let mut current_x = 0.0;
1425 for i in 0..x_offsets.len() {
1426 if x_offsets[i] == 0.0 && i > 0 {
1427 x_offsets[i] = current_x;
1428 } else {
1429 current_x = x_offsets[i];
1430 }
1431 }
1432
1433 if !x_offsets.is_empty() {
1434 let last_idx = x_offsets.len() - 1;
1435 x_offsets[last_idx] = total_w;
1436 }
1437
1438 self.glyph_positions = x_offsets;
1439 self.total_text_width = total_w;
1440
1441 let cursor_pos = self.cursor_idx.min(self.glyph_positions.len() - 1);
1442 self.cursor_x_offset = self.glyph_positions.get(cursor_pos).copied().unwrap_or(0.0);
1443
1444 // Multiline: shape each WRAPPED line the way the paint draws it (same wrap,
1445 // same buffer path) and record per-column x offsets. `char_width()` already
1446 // returns this frame's shaped advance here, so the wrap below matches the
1447 // one `selection_quads`/`value_labels` compute at paint time.
1448 self.line_glyph_positions.clear();
1449 if self.multiline {
1450 let wrap_w = self.rect.width;
1451 let max_chars = if self.line_wrap_enabled() {
1452 (((wrap_w - 16.0) / self.char_width()).floor() as usize).max(1)
1453 } else {
1454 999999
1455 };
1456 let (lines, _) = self.wrap_text(max_chars);
1457 for line in &lines {
1458 let line_buffer = crate::widget::display::text_label::make_widget_text_buffer(fs, line, self.font_size, font_fam);
1459 let n = line.chars().count();
1460 let mut offs = vec![0.0f32; n + 1];
1461 let mut line_total: f32 = 0.0;
1462 for (start, gx, gw) in crate::backend::window_runner::normalized_glyph_starts(&line_buffer, line) {
1463 let c_idx = line[..start.min(line.len())].chars().count();
1464 if c_idx < offs.len() {
1465 offs[c_idx] = gx / scale;
1466 }
1467 line_total = line_total.max((gx + gw) / scale);
1468 }
1469 let mut current = 0.0;
1470 for o in offs.iter_mut() {
1471 if *o == 0.0 {
1472 *o = current;
1473 } else {
1474 current = *o;
1475 }
1476 }
1477 offs[n] = line_total;
1478 self.line_glyph_positions.push(offs);
1479 }
1480 }
1481 }
1482
1483 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
1484 let top = self.label_top();
1485 let base_y = rect.y - top;
1486 let base_h = rect.height + top;
1487 let visual_h = rect.height;
1488 let radius = crate::layout::textbox_corner_radius();
1489 let border_w = self.border_width();
1490
1491 // Keep the model's cached rect and the paint rect consistent: paint receives the
1492 // content rect derived from the same base the cache holds, so the bodies below read
1493 // `self.rect` (the legacy `self.base`) exactly as legacy did. `rect` is used only to
1494 // localize this frame's geometry.
1495 let _ = (base_y, base_h);
1496
1497 if radius <= 0.0 {
1498 // Legacy `extra_quads`: full base span, disabled
1499 // special-case with early return.
1500 let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
1501 if self.disabled {
1502 if self.draw_bg_border {
1503 quads.push((self.rect.x, self.rect.y + top, self.rect.width, visual_h, [0.12, 0.12, 0.16, 1.0]));
1504 quads.push((self.rect.x + border_w, self.rect.y + top + border_w, self.rect.width - 2.0 * border_w, visual_h - 2.0 * border_w, [0.06, 0.06, 0.08, 1.0]));
1505 }
1506 } else {
1507 if self.draw_bg_border {
1508 // One background regardless of focus — the focus treatment is
1509 // the tinted recess rim (rounded path) / editing border, not a
1510 // surface swap.
1511 let bg_color = crate::colors::textbox_background_color();
1512 let border_color = crate::colors::well_frame_color(self.hovered, self.editing);
1513 quads.push((self.rect.x, self.rect.y + top, self.rect.width, visual_h, border_color));
1514 quads.push((self.rect.x + border_w, self.rect.y + top + border_w, self.rect.width - 2.0 * border_w, visual_h - 2.0 * border_w, bg_color));
1515 }
1516 self.selection_quads(self.rect.x, self.rect.width, &mut quads);
1517 }
1518 for (qx, qy, qw, qh, qc) in quads {
1519 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1520 }
1521 } else {
1522 // Legacy `all_rounded_quads`: no disabled special-case.
1523 let x = self.rect.x;
1524 let w = self.rect.width;
1525
1526 // One background regardless of focus (see the flat path above).
1527 let bg_color = crate::colors::textbox_background_color();
1528 let border_color = crate::colors::well_frame_color(self.hovered, self.editing);
1529
1530 if self.draw_bg_border {
1531 let corners = (true, true, true, true);
1532 // Recessed + transparent fill: the carve alone defines the
1533 // well — the plate below is its floor, so the flat border and
1534 // bg rects are skipped entirely. An opaque fill (e.g. the edit
1535 // color while editing) draws as usual and gets carved.
1536 let bare = self.recessed() && bg_color[3] <= 0.001;
1537 if !bare {
1538 ctx.rounded_rect(Rect { x, y: self.rect.y + top, width: w, height: visual_h }, radius, corners, border_color);
1539 ctx.rounded_rect(
1540 Rect { x: x + border_w, y: self.rect.y + top + border_w, width: w - 2.0 * border_w, height: visual_h - 2.0 * border_w },
1541 (radius - border_w).max(0.0),
1542 corners,
1543 bg_color,
1544 );
1545 }
1546 if let Some((well, r, depth, tint)) = self.well() {
1547 // Focus lights the well's rim in the highlight accent (with
1548 // the shader's complementary shadow) — the TreeList treatment.
1549 let radii = (r, r, r, r);
1550 match tint {
1551 Some(t) => ctx.recess_tinted(well, radii, depth, t),
1552 None => ctx.recess(well, radii, depth),
1553 }
1554 }
1555 }
1556
1557 let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
1558 self.selection_quads(x, w, &mut quads);
1559 for (qx, qy, qw, qh, qc) in quads {
1560 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1561 }
1562
1563 // Relief scrollbar for overflowing multiline content — the shared
1564 // groove + raised-pill painter (the TreeList treatment), persistent
1565 // rather than activity-faded: an editor keeps its position
1566 // indicator. Content height mirrors clamp_scroll's math (8px pad
1567 // top and bottom), so the thumb tracks the scroll range exactly.
1568 if self.multiline {
1569 let line_height = self.line_height();
1570 let max_chars = if self.line_wrap_enabled() {
1571 (((self.rect.width - 16.0) / self.char_width()).floor() as usize).max(1)
1572 } else {
1573 999999
1574 };
1575 let content_h = self.wrap_text(max_chars).0.len() as f32 * line_height;
1576 crate::widget::container::scroll_box::paint_relief_scrollbar(
1577 ctx,
1578 Rect { x, y: self.rect.y + top, width: w, height: visual_h },
1579 content_h + 16.0,
1580 self.scroll_y,
1581 );
1582 }
1583 }
1584
1585 // The content is whatever has been typed, so a line longer than the
1586 // well is routine rather than exceptional; the well scrolls, but
1587 // nothing stopped the glyphs drawing outside it.
1588 let well = Some([
1589 self.rect.x,
1590 self.rect.y,
1591 self.rect.x + self.rect.width,
1592 self.rect.y + self.rect.height,
1593 ]);
1594 for tl in self.value_labels() {
1595 ctx.text_with(tl.text, tl.x, tl.y, tl.font_size, tl.color, None, well);
1596 }
1597 }
1598 }
1599
1600 impl Input for TextBox {
1601 fn focus_role(&self) -> crate::widget::FocusRole {
1602 crate::widget::FocusRole::Well
1603 }
1604 /// Advances the wheel glide / trackpad coast behind the scroll offsets.
1605 /// Cheap when idle (the common case); `wants_tick` is unconditional
1606 /// because it is sampled once at registration.
1607 fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
1608 self.scroll_motion.reconcile(self.scroll_x, self.scroll_y);
1609 if !self.scroll_motion.is_animating() {
1610 return false;
1611 }
1612 let (mx, my) = self.scroll_max;
1613 let moved = self.scroll_motion.tick(dt, Bounds::max(mx), Bounds::max(my));
1614 self.scroll_x = self.scroll_motion.x.pos();
1615 self.scroll_y = self.scroll_motion.y.pos();
1616 moved || self.scroll_motion.is_animating()
1617 }
1618
1619 fn wants_tick(&self) -> bool {
1620 true
1621 }
1622
1623 fn tracks_base_focus(&self) -> bool {
1624 false
1625 }
1626
1627 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
1628 match event {
1629 Event::MouseButton { button: MouseButton::Right, state: ElementState::Pressed, x: px, y: py, .. } => {
1630 if self.disabled { return false; }
1631 // Adapter hit-gates presses; legacy focused an un-editing box before opening
1632 // the menu (work-before-menu, so `opens_context_menu` can't express it).
1633 if !self.editing {
1634 self.begin_editing();
1635 ectx.request_focus();
1636 }
1637 ectx.open_context_menu(*px, *py);
1638 true
1639 }
1640 Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, .. } => {
1641 if self.disabled { return false; }
1642 // A click aims into the field: it places the caret where it landed,
1643 // whether or not it is the click that focuses. Only KEYBOARD focus
1644 // (`Event::FocusIn` -> `begin_editing`) arms select-all. That split is
1645 // the convention everywhere — tabbing selects a field, clicking points
1646 // into it — and it is what a prefilled box needs: select-all on the
1647 // focusing click meant the first keystroke wiped the whole value, which
1648 // is wrong for the ~26 prefilled single-line boxes across the fleet
1649 // (login username, reply subject, a unit's ExecStart, a config value).
1650 // This used to be carved out for multiline only; both arms now agree.
1651 let focusing = !self.editing;
1652 if focusing {
1653 self.begin_editing();
1654 ectx.request_focus();
1655 }
1656 let idx = self.position_to_idx(*px, *py);
1657 self.cursor_idx = idx;
1658 self.select_anchor = Some(idx);
1659 self.all_selected = false;
1660 if focusing {
1661 self.sync_editor_state();
1662 }
1663 true
1664 }
1665 Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x: px, y: py, .. } => {
1666 if self.disabled { return false; }
1667 // Legacy gated releases on the hit test; the adapter delivers them ungated, so
1668 // re-check containment (the plain block rect — row spans approximated).
1669 let (bx, by, bw, bh) = (self.rect.x, self.rect.y, self.rect.width, self.rect.height);
1670 if !(*px >= bx && *px <= bx + bw && *py >= by && *py <= by + bh) {
1671 return false;
1672 }
1673 if self.dragging {
1674 self.dragging = false;
1675 }
1676 if self.select_anchor == Some(self.cursor_idx) {
1677 self.select_anchor = None;
1678 }
1679 true
1680 }
1681 Event::PointerMove { x: px, y: py, .. } => {
1682 // The drag-selection half of the legacy `on_cursor_moved`; hover bookkeeping
1683 // is the adapter's (Enter/Leave below).
1684 if self.disabled {
1685 return false;
1686 }
1687 if self.dragging && self.editing {
1688 return self.extend_selection_to(*px, *py);
1689 }
1690 false
1691 }
1692 Event::MouseEnter => {
1693 self.hovered = !self.disabled;
1694 true
1695 }
1696 Event::MouseLeave => {
1697 self.hovered = false;
1698 true
1699 }
1700 Event::MouseWheel { delta, .. } => self.handle_wheel(delta),
1701 Event::KeyInput(key_event) => self.handle_key(key_event),
1702 Event::FocusIn => {
1703 // The legacy `focus()`: enter editing and claim the global slot (unless
1704 // disabled — legacy early-returned before `set_focused`). Skipped when
1705 // already editing: a press-then-set_focused sequence must not re-arm
1706 // select-all over the caret the press just placed.
1707 if !self.disabled && !self.editing {
1708 self.begin_editing();
1709 ectx.request_focus();
1710 }
1711 false
1712 }
1713 Event::FocusOut => {
1714 self.commit_editing();
1715 false
1716 }
1717 _ => false,
1718 }
1719 }
1720
1721 fn take_change(&mut self) -> bool {
1722 self.take_change()
1723 }
1724
1725 fn value_string(&self) -> Option<String> {
1726 Some(self.text.clone())
1727 }
1728
1729 fn set_value_string(&mut self, val: &str) -> bool {
1730 self.set_value(val)
1731 }
1732
1733 fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
1734 use crate::widget::ContextAction as CA;
1735 match action {
1736 CA::Cut => {
1737 let res = self.cut_selection();
1738 if res {
1739 self.just_changed = true;
1740 }
1741 res
1742 }
1743 CA::Copy => {
1744 self.copy_selection();
1745 true
1746 }
1747 CA::Paste => {
1748 let res = self.paste_from_clipboard();
1749 if res {
1750 self.just_changed = true;
1751 }
1752 res
1753 }
1754 CA::SelectAll => {
1755 self.select_all();
1756 true
1757 }
1758 CA::ClearText => {
1759 self.set_value("");
1760 true
1761 }
1762 CA::Undo => self.undo_edit(),
1763 CA::Redo => self.redo_edit(),
1764 _ => false,
1765 }
1766 }
1767
1768 fn draggable(&self, _rect: Rect) -> bool {
1769 !self.disabled
1770 }
1771
1772 fn is_dragging(&self) -> bool {
1773 self.dragging
1774 }
1775
1776 fn drag_begin(&mut self, _px: f32, _py: f32, _rect: Rect) {
1777 if self.disabled || !self.editing { return; }
1778 self.dragging = true;
1779 }
1780
1781 fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
1782 if self.disabled || !self.editing { return false; }
1783 self.extend_selection_to(px, py)
1784 }
1785
1786 fn drag_end(&mut self) {
1787 self.dragging = false;
1788 }
1789 }
1790
1791 /// Legacy `Default` (an empty box) — settings' accounts page derives `Default` over fields of
1792 /// this type.
1793 impl Default for Adapted<TextBox> {
1794 fn default() -> Self {
1795 TextBox::new(String::new())
1796 }
1797 }
1798
1799 unsafe impl Send for TextBox {}
1800 unsafe impl Sync for TextBox {}
1801
1802
1803 #[cfg(test)]
1804 mod tests {
1805 use super::*;
1806
1807 /// The multiline caret/click math reads shaped per-line offsets; a caret
1808 /// must land exactly where it is drawn, on every column of every line.
1809 #[test]
1810 fn multiline_shaped_offsets_round_trip() {
1811 let mut fs = cosmic_text::FontSystem::new();
1812 let mut tb = TextBox::new("wim wim wim\niiii WWWW mm ii".to_string())
1813 .with_multiline(true)
1814 .with_line_wrap(false);
1815 tb.set_rect(10.0, 10.0, 300.0, 100.0);
1816 tb.prepare_text(&mut fs);
1817
1818 assert_eq!(tb.line_glyph_positions.len(), 2, "one offset row per line");
1819 for line in &tb.line_glyph_positions {
1820 for w in line.windows(2) {
1821 assert!(w[1] >= w[0], "offsets must be non-decreasing: {:?}", line);
1822 }
1823 }
1824
1825 // Round-trip caret→pixel→column. Skipped in a font-less environment
1826 // (no glyphs shape, offsets stay zero — nothing to verify).
1827 for (l, line) in tb.line_glyph_positions.clone().iter().enumerate() {
1828 if line.last().copied().unwrap_or(0.0) == 0.0 {
1829 continue;
1830 }
1831 for c in 0..line.len() {
1832 assert_eq!(
1833 tb.line_x_to_col(l, tb.line_col_x(l, c)),
1834 c,
1835 "line {l} col {c} must round-trip"
1836 );
1837 }
1838 }
1839 }
1840
1841 /// Single-line offsets must be non-decreasing for MULTI-WORD text. Guards
1842 /// the `normalized_glyph_starts` workaround for cosmic-text 0.12's
1843 /// `Shaping::Basic` bug (span-relative `LayoutGlyph::start`, resetting at
1844 /// every word): without it, each word's glyphs overwrite the low columns
1845 /// and a mid-text caret lands inside the wrong word.
1846 #[test]
1847 fn single_line_multi_word_offsets_monotonic() {
1848 let mut fs = cosmic_text::FontSystem::new();
1849 let mut tb = TextBox::new("wim wim wim".to_string());
1850 tb.set_rect(10.0, 10.0, 300.0, 30.0);
1851 tb.prepare_text(&mut fs);
1852
1853 for w in tb.glyph_positions.windows(2) {
1854 assert!(w[1] >= w[0], "offsets must be non-decreasing: {:?}", tb.glyph_positions);
1855 }
1856 // In a font-bearing environment the mid-text columns are real advances:
1857 // strictly inside (0, total). Skipped font-less (all zeros).
1858 if tb.glyph_positions.last().copied().unwrap_or(0.0) > 0.0 {
1859 let total = *tb.glyph_positions.last().unwrap();
1860 for (i, &x) in tb.glyph_positions.iter().enumerate().skip(1).take(tb.glyph_positions.len() - 2) {
1861 assert!(x > 0.0 && x < total, "col {i} offset {x} must sit inside the text run");
1862 }
1863 }
1864 }
1865
1866 #[test]
1867 fn test_textbox_selection_highlight() {
1868 let mut dummy = crate::context::UiContext::new();
1869 let mut tb = TextBox::new("Initial Text".to_string());
1870 tb.set_rect(10.0, 10.0, 200.0, 30.0);
1871
1872 // 1. Initial state
1873 assert!(!tb.editing);
1874 assert!(!tb.all_selected);
1875
1876 // 2. Keyboard focus triggers highlighting. (A CLICK deliberately does not
1877 // — it places the caret; see `prefilled_single_line_click_does_not_wipe_the_value`.)
1878 tb.focus();
1879 assert!(tb.editing);
1880 assert!(tb.all_selected);
1881 assert_eq!(tb.edit_buffer, "Initial Text");
1882
1883 // 3. Typing a key replaces all text
1884 let key_ev = KeyEvent {
1885 state: ElementState::Pressed,
1886 logical_key: Key::Character("A".to_string()),
1887 text: Some("A".to_string()),
1888 repeat: false,
1889 ctrl: false,
1890 shift: false,
1891 alt: false,
1892 };
1893 let handled = tb.keyboard_input(&key_ev, &mut dummy);
1894 assert!(handled);
1895 assert!(!tb.all_selected);
1896 assert_eq!(tb.edit_buffer, "A");
1897
1898 // 4. Pressing Enter commits change
1899 let enter_ev = KeyEvent {
1900 state: ElementState::Pressed,
1901 logical_key: Key::Named(NamedKey::Enter),
1902 text: None,
1903 repeat: false,
1904 ctrl: false,
1905 shift: false,
1906 alt: false,
1907 };
1908 let handled_enter = tb.keyboard_input(&enter_ev, &mut dummy);
1909 assert!(handled_enter);
1910 assert!(!tb.editing);
1911 assert_eq!(tb.text, "A");
1912 assert!(tb.take_change());
1913 }
1914
1915 /// The other half of the click change: keyboard focus must still arm
1916 /// select-all, so tabbing into a field and typing replaces it. Losing this
1917 /// would make every form field tedious to retype.
1918 #[test]
1919 fn keyboard_focus_still_selects_all() {
1920 let mut tb = TextBox::new("imap.example.org:993".to_string());
1921 tb.set_rect(10.0, 10.0, 200.0, 30.0);
1922
1923 tb.focus();
1924
1925 assert!(tb.editing);
1926 assert!(tb.all_selected, "tabbing in selects the whole value");
1927 assert_eq!(tb.select_anchor, Some(0));
1928 assert_eq!(tb.cursor_idx, "imap.example.org:993".chars().count());
1929 }
1930
1931 /// A prefilled single-line box is the case that motivated the change: the
1932 /// focusing click must leave the value intact so the first keystroke edits
1933 /// rather than erases. Same guarantee the multiline test below asserts.
1934 #[test]
1935 fn prefilled_single_line_click_does_not_wipe_the_value() {
1936 let mut dummy = crate::context::UiContext::new();
1937 let mut tb = TextBox::new("imap.example.org:993".to_string());
1938 tb.set_rect(10.0, 10.0, 300.0, 30.0);
1939
1940 let click_x = 10.0 + 8.0 + 4.0 * tb.char_width();
1941 assert!(tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x, 20.0, &mut dummy));
1942 assert!(tb.editing);
1943 assert!(!tb.all_selected);
1944
1945 let key_ev = KeyEvent {
1946 state: ElementState::Pressed,
1947 logical_key: Key::Character("X".to_string()),
1948 text: Some("X".to_string()),
1949 repeat: false,
1950 ctrl: false,
1951 shift: false,
1952 alt: false,
1953 };
1954 assert!(tb.keyboard_input(&key_ev, &mut dummy));
1955 assert_eq!(
1956 tb.edit_buffer.chars().count(),
1957 21,
1958 "typing must insert into the prefilled value, not replace it"
1959 );
1960 assert!(tb.edit_buffer.starts_with("imap"), "the existing value survives the first keystroke");
1961 }
1962
1963 /// Undo/redo over an editing session: a typed word is one step, a
1964 /// space starts the next, a cursor move splits a run, Backspace runs
1965 /// coalesce, redo walks forward, a fresh keystroke after an undo forks,
1966 /// and the chord reaches the box both as a `ContextAction` (the runner's
1967 /// route) and as a raw key (the no-context fallback).
1968 #[test]
1969 fn typing_undoes_by_run() {
1970 let mut dummy = crate::context::UiContext::new();
1971 let mut tb = TextBox::new(String::new());
1972 tb.set_rect(10.0, 10.0, 300.0, 30.0);
1973 tb.focus();
1974 assert!(tb.editing);
1975 assert!(!tb.undo_edit(), "a fresh session has nothing to undo");
1976
1977 let key = |k: &str, ctrl: bool, shift: bool| KeyEvent {
1978 state: ElementState::Pressed,
1979 logical_key: Key::Character(k.to_string()),
1980 text: if ctrl { None } else { Some(k.to_string()) },
1981 repeat: false,
1982 ctrl,
1983 shift,
1984 alt: false,
1985 };
1986 let named = |n: NamedKey| KeyEvent {
1987 state: ElementState::Pressed,
1988 logical_key: Key::Named(n),
1989 text: None,
1990 repeat: false,
1991 ctrl: false,
1992 shift: false,
1993 alt: false,
1994 };
1995 let type_str = |tb: &mut Adapted<TextBox>, dummy: &mut crate::context::UiContext, s: &str| {
1996 for ch in s.chars() {
1997 assert!(tb.keyboard_input(&key(&ch.to_string(), false, false), dummy));
1998 }
1999 };
2000
2001 type_str(&mut tb, &mut dummy, "hello world");
2002 assert_eq!(tb.edit_buffer, "hello world");
2003 assert_eq!(tb.history.undo_len(), 3, "'hello', ' ', 'world'");
2004
2005 // A cursor move splits the next run off.
2006 assert!(tb.keyboard_input(&named(NamedKey::ArrowLeft), &mut dummy));
2007 type_str(&mut tb, &mut dummy, "XY");
2008 assert_eq!(tb.edit_buffer, "hello worlXYd");
2009 assert_eq!(tb.history.undo_len(), 4);
2010
2011 // Backspaces coalesce into one step.
2012 assert!(tb.keyboard_input(&named(NamedKey::Backspace), &mut dummy));
2013 assert!(tb.keyboard_input(&named(NamedKey::Backspace), &mut dummy));
2014 assert_eq!(tb.edit_buffer, "hello world");
2015 assert_eq!(tb.history.undo_len(), 5);
2016
2017 // Undo through the ContextAction route, then the raw-chord route.
2018 assert!(WidgetHost::context_action(&mut tb, crate::widget::ContextAction::Undo));
2019 assert_eq!(tb.edit_buffer, "hello worlXYd");
2020 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2021 assert_eq!(tb.edit_buffer, "hello world");
2022 assert!(tb.keyboard_input(&key("Z", true, true), &mut dummy), "redo via ctrl+shift+z");
2023 assert_eq!(tb.edit_buffer, "hello worlXYd");
2024 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2025 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2026 assert_eq!(tb.edit_buffer, "hello ");
2027 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2028 assert_eq!(tb.edit_buffer, "hello");
2029 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2030 assert_eq!(tb.edit_buffer, "");
2031 assert!(!tb.undo_edit(), "history exhausted");
2032
2033 // Redo forward one, then a fresh keystroke forks the branch.
2034 assert!(WidgetHost::context_action(&mut tb, crate::widget::ContextAction::Redo));
2035 assert_eq!(tb.edit_buffer, "hello");
2036 type_str(&mut tb, &mut dummy, "!");
2037 assert_eq!(tb.edit_buffer, "hello!");
2038 assert!(!tb.redo_edit(), "a new edit after an undo drops the redo branch");
2039
2040 // A committed value is not the box's to undo.
2041 tb.unfocus();
2042 assert!(!tb.editing);
2043 assert!(!WidgetHost::context_action(&mut tb, crate::widget::ContextAction::Undo));
2044 }
2045
2046 #[test]
2047 fn empty_box_click_ignores_placeholder_glyphs() {
2048 let mut dummy = crate::context::UiContext::new();
2049 let mut tb = TextBox::new(String::new()).with_placeholder("Search...");
2050 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2051
2052 // `prepare_text` shapes the placeholder when the value is empty; the
2053 // caret math must still treat the box as zero-length. Simulate the
2054 // shaped placeholder ("Search...", 9 cols) directly so the test does
2055 // not depend on a font being present.
2056 tb.glyph_positions = (0..=9).map(|i| i as f32 * 7.0).collect();
2057
2058 // Click deep into the painted placeholder.
2059 let click_x = 10.0 + 8.0 + 8.0 * 7.0;
2060 assert!(tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x, 20.0, &mut dummy));
2061 assert!(tb.editing);
2062 assert_eq!(tb.cursor_idx, 0, "empty box: the caret lands at the start, not on a placeholder column");
2063 }
2064
2065 #[test]
2066 fn multiline_focus_click_places_caret_instead_of_select_all() {
2067 let mut dummy = crate::context::UiContext::new();
2068 let mut tb = TextBox::new("abcdef".to_string()).with_multiline(true);
2069 tb.set_rect(10.0, 10.0, 200.0, 100.0);
2070
2071 // The focusing click must NOT arm select-all (a first keystroke would wipe
2072 // prefilled content, e.g. a reply quote) — it places the caret like any click.
2073 let clicked = tb.mouse_input(MouseButton::Left, ElementState::Pressed, 12.0, 20.0, &mut dummy);
2074 assert!(clicked);
2075 assert!(tb.editing);
2076 assert!(!tb.all_selected);
2077 let caret_after_click = tb.cursor_idx;
2078 let key_ev = KeyEvent {
2079 state: ElementState::Pressed,
2080 logical_key: Key::Character("X".to_string()),
2081 text: Some("X".to_string()),
2082 repeat: false,
2083 ctrl: false,
2084 shift: false,
2085 alt: false,
2086 };
2087 let handled = tb.keyboard_input(&key_ev, &mut dummy);
2088 assert!(handled);
2089 assert_eq!(tb.edit_buffer.chars().count(), 7, "typing must insert, not replace the buffer");
2090 assert!(tb.edit_buffer.contains("X"));
2091 assert_eq!(tb.cursor_idx, caret_after_click + 1);
2092 }
2093
2094 #[test]
2095 fn test_textbox_drag_and_modifier_selection() {
2096 let mut dummy = crate::context::UiContext::new();
2097 let mut tb = TextBox::new("Hello World".to_string());
2098 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2099
2100 // 1. Initial click focuses and places the caret where it landed — it does
2101 // NOT select all. Selecting all belongs to keyboard focus (see
2102 // `keyboard_focus_still_selects_all`).
2103 let click_x3 = 10.0 + 8.0 + 3.0 * tb.char_width();
2104 let pressed = tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x3, 20.0, &mut dummy);
2105 assert!(pressed);
2106 let released = tb.mouse_input(MouseButton::Left, ElementState::Released, click_x3, 20.0, &mut dummy);
2107 assert!(released);
2108 assert!(tb.editing);
2109 assert!(!tb.all_selected);
2110 assert_eq!(tb.cursor_idx, 3);
2111 // The release collapses the zero-width selection the press opened.
2112 assert_eq!(tb.select_anchor, None);
2113
2114 // 2. Click inside placed caret at index 5
2115 let click_x5 = 10.0 + 8.0 + 5.0 * tb.char_width();
2116 let pressed_inside = tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x5, 20.0, &mut dummy);
2117 assert!(pressed_inside);
2118 assert_eq!(tb.cursor_idx, 5);
2119 assert_eq!(tb.select_anchor, Some(5));
2120 assert!(!tb.all_selected);
2121
2122 // 3. Drag to index 11
2123 let drag_x11 = 10.0 + 8.0 + 11.0 * tb.char_width();
2124 tb.drag_begin(click_x5, 20.0);
2125 let updated = tb.drag_update(drag_x11, 20.0);
2126 assert!(updated);
2127 assert_eq!(tb.cursor_idx, 11);
2128 assert_eq!(tb.select_anchor, Some(5));
2129 tb.drag_end();
2130
2131 // 4. Keyboard ArrowLeft with Shift shrinks selection from 11 to 10
2132 let left_shift_ev = KeyEvent {
2133 state: ElementState::Pressed,
2134 logical_key: Key::Named(NamedKey::ArrowLeft),
2135 text: None,
2136 repeat: false,
2137 ctrl: false,
2138 shift: true,
2139 alt: false,
2140 };
2141 let handled = tb.keyboard_input(&left_shift_ev, &mut dummy);
2142 assert!(handled);
2143 assert_eq!(tb.cursor_idx, 10);
2144 assert_eq!(tb.select_anchor, Some(5));
2145
2146 // 5. Keyboard ArrowLeft without Shift collapses selection to start (index 5)
2147 let left_ev = KeyEvent {
2148 state: ElementState::Pressed,
2149 logical_key: Key::Named(NamedKey::ArrowLeft),
2150 text: None,
2151 repeat: false,
2152 ctrl: false,
2153 shift: false,
2154 alt: false,
2155 };
2156 let handled = tb.keyboard_input(&left_ev, &mut dummy);
2157 assert!(handled);
2158 assert_eq!(tb.cursor_idx, 5);
2159 assert_eq!(tb.select_anchor, None);
2160
2161 // 6. Keyboard Shift+Up highlights to beginning (cursor 0, anchor 5)
2162 let up_shift_ev = KeyEvent {
2163 state: ElementState::Pressed,
2164 logical_key: Key::Named(NamedKey::ArrowUp),
2165 text: None,
2166 repeat: false,
2167 ctrl: false,
2168 shift: true,
2169 alt: false,
2170 };
2171 let handled = tb.keyboard_input(&up_shift_ev, &mut dummy);
2172 assert!(handled);
2173 assert_eq!(tb.cursor_idx, 0);
2174 assert_eq!(tb.select_anchor, Some(5));
2175
2176 // 7. Typing a key replaces selected range "Hello" with "Rust"
2177 let rust_ev = KeyEvent {
2178 state: ElementState::Pressed,
2179 logical_key: Key::Character("Rust".to_string()),
2180 text: Some("Rust".to_string()),
2181 repeat: false,
2182 ctrl: false,
2183 shift: false,
2184 alt: false,
2185 };
2186 let handled = tb.keyboard_input(&rust_ev, &mut dummy);
2187 assert!(handled);
2188 assert_eq!(tb.edit_buffer, "Rust World");
2189 assert_eq!(tb.cursor_idx, 4);
2190 assert_eq!(tb.select_anchor, None);
2191 }
2192
2193 #[test]
2194 fn test_textbox_right_click_context_menu() {
2195 let mut dummy = crate::context::UiContext::new();
2196 let mut tb = TextBox::new("Context Menu Text".to_string());
2197 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2198
2199 // Hide context menu initially
2200 dummy.hide_context_menu();
2201 assert!(!dummy.is_context_menu_visible());
2202
2203 // Right click on textbox
2204 let clicked = tb.mouse_input(MouseButton::Right, ElementState::Pressed, 50.0, 20.0, &mut dummy);
2205 assert!(clicked);
2206 assert!(dummy.is_context_menu_visible());
2207 assert!(tb.editing);
2208 }
2209
2210 #[test]
2211 fn test_textbox_multiline_selection_highlight() {
2212 let dummy = crate::context::UiContext::new();
2213 let mut tb = TextBox::new("Line 1\nLine 2\nLine 3".to_string()).with_multiline(true);
2214 tb.set_rect(10.0, 10.0, 200.0, 100.0);
2215 tb.select_anchor = Some(7); // starts at "Line 2"
2216 tb.cursor_idx = 13; // ends at end of "Line 2"
2217
2218 let has_rounded = WidgetHost::corner_style(&tb).1 != (false, false, false, false);
2219 let has_highlight = if has_rounded {
2220 let rounded = tb.all_rounded_quads(&dummy);
2221 println!("Rounded quads: {:?}", rounded);
2222 let quads = tb.all_quads(&dummy);
2223 rounded.iter().any(|q| q.5 == [0.20, 0.50, 0.85, 0.3])
2224 || quads.iter().any(|q| q.4 == [0.20, 0.50, 0.85, 0.3])
2225 } else {
2226 let extra = tb.extra_quads();
2227 println!("Extra quads: {:?}", extra);
2228 extra.iter().any(|q| q.4 == [0.20, 0.50, 0.85, 0.3])
2229 };
2230 assert!(has_highlight, "Should have a highlight quad!");
2231 }
2232
2233 #[test]
2234 fn test_textbox_line_wrap_disabled_horizontal_scrolling() {
2235 let _dummy = crate::context::UiContext::new();
2236
2237 // Wrap is stated on the widget (`line_wrap_override`), not on the
2238 // process-global `textbox_line_wrap` — this box is single-line, which
2239 // is already unwrapped, and the override says so whatever the config
2240 // does. The global would be visible to every other test in parallel.
2241 let mut tb = TextBox::new("Very long text that should not wrap and instead scroll horizontally".to_string())
2242 .with_line_wrap(false);
2243 tb.set_rect(10.0, 10.0, 100.0, 30.0);
2244 assert!(!tb.line_wrap_enabled());
2245
2246 assert_eq!(tb.scroll_x, 0.0);
2247
2248 tb.focus();
2249 tb.cursor_idx = tb.edit_buffer.chars().count();
2250 tb.scroll_to_cursor();
2251
2252 assert!(tb.scroll_x > 0.0, "scroll_x should be scrolled horizontally to keep the cursor visible");
2253 }
2254
2255 #[test]
2256 fn test_search_textbox_clear_option() {
2257 let mut dummy = crate::context::UiContext::new();
2258 let mut tb = TextBox::new("Some Search query".to_string()).with_placeholder("Search...");
2259 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2260
2261 // Right click on textbox
2262 let clicked = tb.mouse_input(MouseButton::Right, ElementState::Pressed, 50.0, 20.0, &mut dummy);
2263 assert!(clicked);
2264 assert!(dummy.is_context_menu_visible());
2265
2266 // Verify "Clear" option is in options
2267 let opts = crate::widget::context_menu::options();
2268 assert!(opts.contains(&"Clear".to_string()));
2269
2270 // Simulate choosing the "Clear" option
2271 WidgetHost::context_action(&mut tb, crate::widget::ContextAction::ClearText);
2272 assert_eq!(tb.text, "");
2273 assert_eq!(tb.edit_buffer, "");
2274 }
2275
2276 /// A single-line box's border is always the hairline; a multiline box's
2277 /// is whatever `textbox_multiline_border_width` resolves to. Read, never
2278 /// written: that width is a process global and the suite runs in
2279 /// parallel, so setting it here would widen every other test's borders.
2280 #[test]
2281 fn test_multiline_textbox_border_width() {
2282 let _dummy = crate::context::UiContext::new();
2283 let tb_single = TextBox::new("Singleline".to_string()).with_multiline(false);
2284 let tb_multi = TextBox::new("Multiline".to_string()).with_multiline(true);
2285
2286 let configured = crate::layout::textbox_multiline_border_width();
2287 assert_eq!(tb_single.border_width(), 1.0);
2288 assert_eq!(tb_multi.border_width(), configured);
2289 }
2290 }