git.lucas.co / cce-ui
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 }