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 (126.1K)

   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         #[cfg(not(target_arch = "wasm32"))]
  34         {
  35             db.load_system_fonts();
  36             db.load_fonts_dir(crate::fonts_dir());
  37         }
  38         // A page has neither: the fonts it handed the browser shell.
  39         #[cfg(target_arch = "wasm32")]
  40         crate::page_fonts::load_into(&mut db);
  41         db
  42     })
  43 }
  44 
  45 /// Everything [`TextBox`]'s `prepare_text` output depends on.
  46 #[derive(Debug, Clone, PartialEq)]
  47 struct PrepKey {
  48     text: String,
  49     placeholder: bool,
  50     password: bool,
  51     font_size_bits: u32,
  52     font: Option<String>,
  53     attrs: crate::scene::paint::TextAttrs,
  54     scale_bits: u32,
  55     vertical: bool,
  56     /// `Some(the wrap width's bits)` for a multiline box ([`TextBox::wrap_width`]).
  57     wrap: Option<u32>,
  58     /// The width a right-to-left paragraph is set against the right of (the box less its
  59     /// padding), as bits.
  60     room_bits: u32,
  61 }
  62 
  63 /// What a [`TextBox`]'s cached advances were measured for.
  64 #[derive(Debug, Clone, PartialEq)]
  65 struct AdvanceKey {
  66     text: String,
  67     font_size_bits: u32,
  68     font: Option<String>,
  69     attrs: crate::scene::paint::TextAttrs,
  70     scale_bits: u32,
  71 }
  72 
  73 #[derive(Debug, Clone)]
  74 pub struct TextBox {
  75     pub text: String,
  76     pub editing: bool,
  77     pub edit_buffer: String,
  78     pub(crate) just_changed: bool,
  79     pub disabled: bool,
  80     pub all_selected: bool,
  81     pub cursor_idx: usize,
  82     pub select_anchor: Option<usize>,
  83     pub dragging: bool,
  84     pub just_focused: bool,
  85     pub drag_start_idx: Option<usize>,
  86     pub max_width: Option<f32>,
  87     pub width: Option<f32>,
  88     pub is_password: bool,
  89     pub multiline: bool,
  90     /// Per-widget wrap override; `None` defers to the global `textbox_line_wrap()`
  91     /// style. Flowed-text consumers (an email body) set this to keep wrapping even
  92     /// when the user's config turns multiline wrap off DE-wide.
  93     pub line_wrap_override: Option<bool>,
  94     pub draw_bg_border: bool,
  95     /// The box's right end meets another control (a parameter pane's
  96     /// completion picker): its well's right corners are square, so the two
  97     /// read as one field with a seam between them.
  98     pub joined_right: bool,
  99     pub text_color: Option<[u8; 3]>,
 100     pub font_size: f32,
 101     pub font_family: String,
 102     /// Italic / weight the value text is drawn AND measured with (see
 103     /// `value_font`) — default for every box but one showing a particular
 104     /// face of `font_family`.
 105     pub font_attrs: crate::scene::paint::TextAttrs,
 106     pub placeholder: Option<String>,
 107     /// A search box (`with_search`): its menu offers Clear. Known by this flag, never by
 108     /// its placeholder's text, which is translated.
 109     pub is_search: bool,
 110     pub editor_state: TextEditorState,
 111     /// Edit history for the current editing session (cleared by
 112     /// `begin_editing`): a typed run is one step, a deleted run one, a
 113     /// paste/cut/selection-replacement one. Stepped by `ContextAction::Undo`
 114     /// / `Redo`, which the runner routes here on the `undo` / `redo` chords
 115     /// while the box is focused and editing.
 116     pub history: History<TextEditorState>,
 117     pub scroll_y: f32,
 118     pub scroll_x: f32,
 119     /// Smooth-scroll driver behind `scroll_x`/`scroll_y` (see `ScrollRegion::motion`).
 120     scroll_motion: ScrollMotion,
 121     /// `(max_x, max_y)` as of the last wheel — the glide's bounds, so `tick`
 122     /// never re-wraps the text just to re-derive them.
 123     scroll_max: (f32, f32),
 124     default_font_size: f32,
 125     default_font_family: String,
 126     pub cursor_x_offset: f32,
 127     pub glyph_positions: Vec<f32>,
 128     pub total_text_width: f32,
 129     /// The shaped advance of one column, cached by [`Paint::prepare_text`] from the same
 130     /// cosmic-text path that draws the value text. `char_width()` prefers this over the
 131     /// SVG-rasterized `measure_text_width`, which reports inked extent (and resolves generic
 132     /// families through fontdb, not cosmic-text) — a per-column error that made the multiline
 133     /// selection highlight drift off the glyphs. 0.0 until the first `prepare_text`.
 134     shaped_char_advance: f32,
 135     /// Each char's shaped advance in the text being wrapped (logical px; a cluster's
 136     /// whole width on its first char, 0 on the rest and on a newline), for the key it was
 137     /// measured for. Wrapping sums these against the width, so a proportional face and
 138     /// two-column CJK wrap where they are drawn. Filled from the paint's font system in
 139     /// `prepare_text`, else from `geometry_font_system` when an edit outruns the frame.
 140     advances: std::cell::RefCell<Option<(AdvanceKey, Vec<f32>)>>,
 141     /// How far right a right-to-left paragraph is set so its right edge meets the box's
 142     /// (0 for left-to-right text, and for a line too wide to fit): the single line's, and
 143     /// each wrapped line's. Already in `glyph_positions` / `line_glyph_positions`, so
 144     /// carets, clicks and selections follow; the drawing adds it to the text's x.
 145     glyph_shift: f32,
 146     line_shift: Vec<f32>,
 147     /// The shaped runs those positions came from, for selections drawn as the boxes of
 148     /// the clusters they cover (two pieces where one crosses a change of direction).
 149     glyph_run: Option<crate::backend::text::ShapedRun>,
 150     line_runs: Vec<crate::backend::text::ShapedRun>,
 151     /// Multiline counterpart of `glyph_positions`: per WRAPPED line, per-column x
 152     /// offsets of that line as drawn (`[line][col]`, one extra entry per line = its
 153     /// total advance), recorded by [`Paint::prepare_text`] over the same wrap the
 154     /// paint uses. The multiline caret, selection, click→column, and
 155     /// scroll-to-cursor read these; the uniform `col * char_width()` grid they used
 156     /// before is exact only for monospace. Empty for single-line boxes or until the
 157     /// first shape (readers fall back to the grid).
 158     line_glyph_positions: Vec<Vec<f32>>,
 159     /// What the last `prepare_text` shaped. The runner calls `prepare_text` on
 160     /// every registered box before every frame; when none of these inputs moved,
 161     /// the offsets above are still right and only the caret is re-read.
 162     prep_key: Option<PrepKey>,
 163     pub update_on_type: bool,
 164     /// Synced control label ([`Paint::sync_label`]) — drives the detached strip offset.
 165     label: Option<String>,
 166     /// Own hover flag, maintained from `MouseEnter`/`MouseLeave` (adapter bookkeeping).
 167     hovered: bool,
 168     /// The laid-out base rect, cached from [`Layout::rect_assigned`] — the cursor/scroll math
 169     /// reads geometry between events, which the narrow traits don't otherwise carry.
 170     rect: Rect,
 171     /// An input method's composition, shown in `edit_buffer` as a PROVISIONAL run:
 172     /// its start and length in chars. Everything that draws the buffer — wrap,
 173     /// scroll, caret, the glyph advances — draws it as it draws typed text, and
 174     /// the selection quads underline it. It is never committed, recorded in the
 175     /// history or reported as a change: `committed_buffer` is the buffer without
 176     /// it. See `crate::ime`.
 177     composing: Option<(usize, usize)>,
 178     /// The `ime::generation` the run shows.
 179     ime_seen: u64,
 180     /// Recessed style: a `Recess` overlay is carved over the box's own fill —
 181     /// an inset well, the input-direction counterpart of the raised controls.
 182     recessed: Option<bool>,
 183 }
 184 
 185 impl TextBox {
 186     /// The style in force: the per-widget override (`with_recessed`) when set, else
 187     /// the DE's `control_relief`, read live so a runtime switch
 188     /// (`layout::set_control_relief`) restyles every control at once.
 189     fn recessed(&self) -> bool {
 190         self.recessed.unwrap_or_else(crate::layout::control_relief)
 191     }
 192 
 193     pub fn new(text: String) -> Adapted<TextBox> {
 194         let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
 195         let editor_state = TextEditorState::new(text.clone());
 196         Adapted::new(TextBox {
 197             text,
 198             editing: false,
 199             edit_buffer: String::new(),
 200             just_changed: false,
 201             disabled: false,
 202             all_selected: false,
 203             cursor_idx: 0,
 204             select_anchor: None,
 205             dragging: false,
 206             just_focused: false,
 207             drag_start_idx: None,
 208             max_width: None,
 209             width: None,
 210             is_password: false,
 211             multiline: false,
 212             line_wrap_override: None,
 213             draw_bg_border: true,
 214             joined_right: false,
 215             text_color: None,
 216             font_size: style_size,
 217             font_family: style_family.clone(),
 218             font_attrs: crate::scene::paint::TextAttrs::default(),
 219             placeholder: None,
 220             is_search: false,
 221             editor_state,
 222             history: History::new(),
 223             scroll_y: 0.0,
 224             scroll_x: 0.0,
 225             scroll_motion: ScrollMotion::new(),
 226             scroll_max: (0.0, 0.0),
 227             default_font_size: style_size,
 228             default_font_family: style_family,
 229             cursor_x_offset: 0.0,
 230             glyph_positions: Vec::new(),
 231             total_text_width: 0.0,
 232             shaped_char_advance: 0.0,
 233             advances: std::cell::RefCell::new(None),
 234             glyph_shift: 0.0,
 235             line_shift: Vec::new(),
 236             glyph_run: None,
 237             line_runs: Vec::new(),
 238             line_glyph_positions: Vec::new(),
 239             prep_key: None,
 240             update_on_type: false,
 241             label: None,
 242             hovered: false,
 243             rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
 244             recessed: None,
 245             composing: None,
 246             ime_seen: 0,
 247         })
 248     }
 249 
 250     /// The detached-label strip height above the content (zero unlabeled) — the
 251     /// adapter's `Widget::label_offset` over the synced label.
 252     fn label_top(&self) -> f32 {
 253         crate::widget::input::slider::detached_strip(&self.label)
 254     }
 255 
 256     fn map_x_to_idx(&self, click_x: f32) -> usize {
 257         let pad = self.pad();
 258         let relative_x = click_x - (self.rect.x + pad) + self.scroll_x;
 259         if self.glyph_positions.is_empty() {
 260             let char_width = self.char_width();
 261             return ((relative_x / char_width).round() as isize)
 262                 .max(0)
 263                 .min(self.edit_buffer.chars().count() as isize) as usize;
 264         }
 265 
 266         let mut closest_idx = 0;
 267         let mut min_diff = f32::MAX;
 268         for (i, &pos) in self.glyph_positions.iter().enumerate() {
 269             let diff = (pos - relative_x).abs();
 270             if diff < min_diff {
 271                 min_diff = diff;
 272                 closest_idx = i;
 273             }
 274         }
 275         // When the box is empty, `prepare_text` shapes the PLACEHOLDER into
 276         // `glyph_positions`, so the nearest-glyph snap above can land on a
 277         // placeholder column. Clamp to the real text: the placeholder is
 278         // painted, not caret-addressable.
 279         let text_len = if self.editing { self.edit_buffer.chars().count() } else { self.text.chars().count() };
 280         closest_idx.min(text_len)
 281     }
 282 
 283     /// The x offset of `col` on wrapped line `line`, from the shaped per-line
 284     /// offsets when recorded, else the uniform-grid estimate.
 285     fn line_col_x(&self, line: usize, col: usize) -> f32 {
 286         self.line_glyph_positions
 287             .get(line)
 288             .and_then(|l| l.get(col).copied())
 289             .unwrap_or_else(|| col as f32 * self.char_width())
 290     }
 291 
 292     /// An x offset (relative to the text origin) → nearest column on wrapped
 293     /// line `line`, from the shaped offsets when recorded.
 294     fn line_x_to_col(&self, line: usize, relative_x: f32) -> usize {
 295         let Some(offsets) = self.line_glyph_positions.get(line).filter(|l| !l.is_empty()) else {
 296             return ((relative_x / self.char_width()).round() as isize).max(0) as usize;
 297         };
 298         let mut closest = 0;
 299         let mut min_diff = f32::MAX;
 300         for (i, &pos) in offsets.iter().enumerate() {
 301             let diff = (pos - relative_x).abs();
 302             if diff < min_diff {
 303                 min_diff = diff;
 304                 closest = i;
 305             }
 306         }
 307         closest
 308     }
 309 
 310     /// The font the value text is drawn in — AND measured in: `prepare_text`
 311     /// (whose per-glyph positions place the caret, the selection and
 312     /// click-to-column) shapes with exactly this, and `paint` emits it. It is
 313     /// [`Paint::text_font`], because that is what actually draws the text:
 314     /// `Adapted::paint_self` drops the Text prims `paint` emits and redraws
 315     /// the value through its own-labels bridge in `text_font()` — the box's
 316     /// family when customized (cce-text-editor's monospace editor), else the
 317     /// configured control font string.
 318     ///
 319     /// Until 2026-09-25 the measurement shaped `font_family` instead (or
 320     /// `monospace` for a password), a different face from the drawn one, so
 321     /// the caret drifted off the text a little more with every character —
 322     /// two dots short after twelve in the login greeter's password box.
 323     pub fn value_font(&self) -> Option<String> {
 324         <Self as Paint>::text_font(self)
 325     }
 326 
 327     /// The shaping half of `prepare_text`: `glyph_positions` and
 328     /// `total_text_width` for the whole display text, and, multiline,
 329     /// `line_glyph_positions` per wrapped line as the paint draws them.
 330     fn shape_columns(&mut self, fs: &mut cosmic_text::FontSystem, key: &PrepKey) {
 331         let scale = f32::from_bits(key.scale_bits);
 332         let font_fam = key.font.as_deref();
 333         let attrs = key.attrs;
 334         let shared = crate::backend::text::shared_text_buffer;
 335 
 336         let render_text = if key.password {
 337             "•".repeat(key.text.chars().count())
 338         } else {
 339             key.text.clone()
 340         };
 341 
 342         // A multiline box reads only the per-line offsets; shaping the whole
 343         // document as one buffer would be its most expensive and least used step.
 344         self.line_glyph_positions.clear();
 345         if let Some(bits) = key.wrap {
 346             // Measure the wrapped text's advances with the paint's font system first, so
 347             // the wrap below never reaches for the shared one (which may be this one).
 348             let src = if self.editing { self.edit_buffer.clone() } else { self.text.clone() };
 349             self.cache_advances(fs, &src);
 350             let (lines, map) = self.wrap_text(f32::from_bits(bits));
 351             // Each wrapped line's paragraph, and whether that paragraph is right to left: a
 352             // paragraph's direction is its first strong character's, for all its lines.
 353             let para_rtl: Vec<bool> = src.split('\n').map(crate::backend::text::paragraph_rtl).collect();
 354             let mut para_of_line = vec![0usize; lines.len()];
 355             let mut para = 0usize;
 356             let mut seen = vec![false; lines.len()];
 357             for (ci, ch) in src.chars().enumerate() {
 358                 let line = map[ci].0.min(lines.len() - 1);
 359                 if !seen[line] {
 360                     seen[line] = true;
 361                     para_of_line[line] = para;
 362                 }
 363                 if ch == '\n' {
 364                     para += 1;
 365                 }
 366             }
 367             for (line, s) in seen.iter().enumerate() {
 368                 if !s {
 369                     para_of_line[line] = para;
 370                 }
 371             }
 372             let room = f32::from_bits(key.room_bits);
 373             self.line_shift.clear();
 374             self.line_runs.clear();
 375             for (li, line) in lines.iter().enumerate() {
 376                 let line_buffer = shared(fs, line, self.font_size, font_fam, attrs);
 377                 let run = crate::backend::text::shaped_run(&line_buffer, line, scale);
 378                 let rtl = para_rtl.get(para_of_line[li]).copied().unwrap_or(false);
 379                 let shift = if rtl { (room - run.width).max(0.0) } else { 0.0 };
 380                 self.line_glyph_positions.push(run.stops.iter().map(|s| s.1 + shift).collect());
 381                 self.line_shift.push(shift);
 382                 self.line_runs.push(run);
 383             }
 384             self.glyph_positions = vec![0.0; render_text.chars().count() + 1];
 385             self.total_text_width = 0.0;
 386             return;
 387         }
 388 
 389         let buffer = shared(fs, &render_text, self.font_size, font_fam, attrs);
 390         let run = crate::backend::text::shaped_run(&buffer, &render_text, scale);
 391         let total_w = run.width;
 392         // A right-to-left line that fits is set against the right. One that does not
 393         // overflows to the LEFT, its start being at the right: shown and not being edited,
 394         // it is scrolled to its right end, where a left-to-right line shows its left (until
 395         // 2026-10-08 it showed its left end too, which is the END of a right-to-left line).
 396         // While editing, the caret is followed (`scroll_to_cursor`) as for any line.
 397         let room = f32::from_bits(key.room_bits);
 398         let rtl = crate::backend::text::paragraph_rtl(&render_text);
 399         let shift = if rtl && total_w < room { room - total_w } else { 0.0 };
 400         self.glyph_positions = run.stops.iter().map(|s| s.1 + shift).collect();
 401         self.glyph_shift = shift;
 402         self.glyph_run = Some(run);
 403         self.total_text_width = total_w;
 404         if rtl && total_w > room && !self.editing && key.wrap.is_none() && !self.multiline {
 405             self.scroll_x = total_w - room;
 406         }
 407     }
 408 
 409     pub fn char_width(&self) -> f32 {
 410         if self.shaped_char_advance > 0.0 {
 411             self.shaped_char_advance
 412         } else {
 413             crate::widget::display::measure_text_width("M", &self.font_family, self.font_size)
 414         }
 415     }
 416 
 417     pub fn line_height(&self) -> f32 {
 418         self.font_size * 1.333
 419     }
 420 
 421     /// The width a multiline box wraps its lines at, for a box `outer_w` wide: its width
 422     /// less the padding, or no limit with wrapping off.
 423     pub fn wrap_width(&self, outer_w: f32) -> f32 {
 424         if self.line_wrap_enabled() {
 425             (outer_w - 2.0 * self.pad()).max(1.0)
 426         } else {
 427             f32::INFINITY
 428         }
 429     }
 430 
 431     fn advance_key(&self, text: &str) -> AdvanceKey {
 432         AdvanceKey {
 433             text: text.to_string(),
 434             font_size_bits: self.font_size.to_bits(),
 435             font: self.value_font(),
 436             attrs: self.font_attrs,
 437             scale_bits: crate::scale::scale_factor().to_bits(),
 438         }
 439     }
 440 
 441     /// Each char's shaped advance in `text` (see the `advances` field), shaped with `fs`.
 442     fn measure_advances(&self, fs: &mut cosmic_text::FontSystem, text: &str) -> Vec<f32> {
 443         let scale = crate::scale::scale_factor().max(0.01);
 444         let font = self.value_font();
 445         let mut out = Vec::with_capacity(text.chars().count());
 446         for (pi, para) in text.split('\n').enumerate() {
 447             if pi > 0 {
 448                 out.push(0.0); // the newline
 449             }
 450             let shown = if self.is_password { "•".repeat(para.chars().count()) } else { para.to_string() };
 451             let buf = crate::backend::text::shared_text_buffer(fs, &shown, self.font_size, font.as_deref(), self.font_attrs);
 452             let run = crate::backend::text::shaped_run(&buf, &shown, scale);
 453             let mut adv = vec![0.0f32; shown.chars().count()];
 454             let char_at: std::collections::HashMap<usize, usize> =
 455                 shown.char_indices().enumerate().map(|(ci, (b, _))| (b, ci)).collect();
 456             for c in &run.clusters {
 457                 if let Some(&ci) = char_at.get(&c.start) {
 458                     adv[ci] += c.x1 - c.x0;
 459                 }
 460             }
 461             out.extend(adv);
 462         }
 463         out
 464     }
 465 
 466     /// Measure `text`'s advances with `fs` into the cache, unless it already holds them.
 467     fn cache_advances(&self, fs: &mut cosmic_text::FontSystem, text: &str) {
 468         let key = self.advance_key(text);
 469         if self.advances.borrow().as_ref().is_some_and(|(k, _)| *k == key) {
 470             return;
 471         }
 472         let adv = self.measure_advances(fs, text);
 473         *self.advances.borrow_mut() = Some((key, adv));
 474     }
 475 
 476     /// `text`'s char advances: the cache, else measured with the shared geometry font
 477     /// system, else — that one held, by this thread further up the stack or by another —
 478     /// with a font system of this thread's own, from the same font set. Always measured,
 479     /// never a grid: two wraps of one text must agree.
 480     fn char_advances(&self, text: &str) -> Vec<f32> {
 481         thread_local! {
 482             static OWN_FS: std::cell::RefCell<Option<cosmic_text::FontSystem>> = const { std::cell::RefCell::new(None) };
 483         }
 484         let key = self.advance_key(text);
 485         if let Some((k, adv)) = self.advances.borrow().as_ref() {
 486             if *k == key {
 487                 return adv.clone();
 488             }
 489         }
 490         let adv = match crate::geometry_font_system().try_lock() {
 491             Ok(mut fs) => self.measure_advances(&mut fs, text),
 492             Err(_) => OWN_FS.with(|own| {
 493                 let mut own = own.borrow_mut();
 494                 let fs = own.get_or_insert_with(crate::create_font_system);
 495                 self.measure_advances(fs, text)
 496             }),
 497         };
 498         *self.advances.borrow_mut() = Some((key, adv.clone()));
 499         adv
 500     }
 501 
 502     /// The box's text wrapped at `max_width` px ([`TextBox::wrap_width`]): its lines, and
 503     /// where each char lands as (line, column). Words move whole to the next line; a word
 504     /// wider than the line breaks where it overflows. Widths are the shaped advances, so a
 505     /// proportional face and two-column CJK wrap where they are drawn (until 2026-10-08 it
 506     /// counted chars against one monospace advance).
 507     pub fn wrap_text(&self, max_width: f32) -> (Vec<String>, Vec<(usize, usize)>) {
 508         let text_src = if self.editing { &self.edit_buffer } else { &self.text };
 509         self.wrap_str(text_src, max_width)
 510     }
 511 
 512     /// [`TextBox::wrap_text`] for any text in the box's face (its placeholder).
 513     pub fn wrap_str(&self, text_src: &str, max_width: f32) -> (Vec<String>, Vec<(usize, usize)>) {
 514         let chars: Vec<char> = text_src.chars().collect();
 515         let mut lines = Vec::new();
 516         let mut current_line = Vec::new();
 517         let mut index_map = vec![(0, 0); chars.len() + 1];
 518 
 519         if !self.line_wrap_enabled() || !max_width.is_finite() {
 520             let mut i = 0;
 521             while i < chars.len() {
 522                 let ch = chars[i];
 523                 if ch == '\n' {
 524                     index_map[i] = (lines.len(), current_line.len());
 525                     lines.push(current_line.iter().collect::<String>());
 526                     current_line.clear();
 527                 } else {
 528                     current_line.push(ch);
 529                     index_map[i] = (lines.len(), current_line.len() - 1);
 530                 }
 531                 i += 1;
 532             }
 533             index_map[chars.len()] = (lines.len(), current_line.len());
 534             lines.push(current_line.iter().collect::<String>());
 535             return (lines, index_map);
 536         }
 537 
 538         let adv = self.char_advances(text_src);
 539         let max_width = max_width.max(1.0);
 540         // The current line's width: the advances of the chars it holds.
 541         let mut line_w = 0.0f32;
 542 
 543         let mut i = 0;
 544         while i < chars.len() {
 545             let ch = chars[i];
 546 
 547             if ch == '\n' {
 548                 index_map[i] = (lines.len(), current_line.len());
 549                 lines.push(current_line.iter().collect::<String>());
 550                 current_line.clear();
 551                 line_w = 0.0;
 552                 i += 1;
 553                 continue;
 554             }
 555 
 556             current_line.push(ch);
 557             line_w += adv.get(i).copied().unwrap_or(0.0);
 558             index_map[i] = (lines.len(), current_line.len() - 1);
 559 
 560             // A space may hang past the edge, as it is drawn there; anything else that
 561             // overflows breaks the line after its last space, else before itself.
 562             if line_w > max_width + 0.5 && current_line.len() > 1 && !ch.is_whitespace() {
 563                 let split = match current_line.iter().rposition(|c| c.is_whitespace()) {
 564                     Some(s_idx) => s_idx + 1,
 565                     None => current_line.len() - 1,
 566                 };
 567                 let line_to_push: Vec<char> = current_line[..split].to_vec();
 568                 let remaining: Vec<char> = current_line[split..].to_vec();
 569 
 570                 let line_idx = lines.len();
 571                 lines.push(line_to_push.iter().collect::<String>());
 572 
 573                 current_line = remaining;
 574                 let start_orig = i + 1 - current_line.len();
 575                 line_w = 0.0;
 576                 for c_idx in 0..current_line.len() {
 577                     index_map[start_orig + c_idx] = (line_idx + 1, c_idx);
 578                     line_w += adv.get(start_orig + c_idx).copied().unwrap_or(0.0);
 579                 }
 580             }
 581             i += 1;
 582         }
 583 
 584         index_map[chars.len()] = (lines.len(), current_line.len());
 585         lines.push(current_line.iter().collect::<String>());
 586 
 587         (lines, index_map)
 588     }
 589 
 590     pub fn map_2d_to_1d(&self, index_map: &[(usize, usize)], target_line: usize, target_col: usize, max_line_idx: usize) -> usize {
 591         let line = target_line.min(max_line_idx);
 592         let mut best_idx = 0;
 593         let mut best_dist = usize::MAX;
 594 
 595         for (i, &(l, c)) in index_map.iter().enumerate() {
 596             if l == line {
 597                 let dist = (c as isize - target_col as isize).unsigned_abs();
 598                 if dist < best_dist {
 599                     best_dist = dist;
 600                     best_idx = i;
 601                 }
 602             }
 603         }
 604         best_idx
 605     }
 606 
 607     fn border_width(&self) -> f32 {
 608         if self.multiline {
 609             crate::layout::textbox_multiline_border_width()
 610         } else {
 611             1.0
 612         }
 613     }
 614 
 615     /// The inset from the box's edge to its text — the caret, selection,
 616     /// hit-testing, wrap and scroll range all measure from it. 8px, or the
 617     /// well's floor when the relief wall reaches further in: the wall is
 618     /// `bevel_width` deep once the box is tall enough (`well`'s 20% cap), so a
 619     /// fixed 8px sat a multiline box's text on its wall.
 620     fn pad(&self) -> f32 {
 621         8.0f32.max(self.wall_inset())
 622     }
 623 
 624     /// How far in from the box's edge its relief wall ends (the outline's own
 625     /// inset from the carve plus the wall's depth); zero with no relief.
 626     fn wall_inset(&self) -> f32 {
 627         self.well().map_or(0.0, |f| f.rect.x - self.rect.x + f.depth)
 628     }
 629 
 630     /// The value text's clip, `[x1, y1, x2, y2]`, from the content rect (the
 631     /// box below its label strip): the well's floor, so scrolled text slides
 632     /// under the relief wall rather than over it. Only a multiline box is
 633     /// clipped in y: a single line is centred, never scrolls vertically, and a
 634     /// short box's floor is shallower than its line.
 635     fn text_clip(&self, content: Rect) -> [f32; 4] {
 636         let inset = self.wall_inset();
 637         let inset_y = if self.multiline { inset } else { 0.0 };
 638         [
 639             content.x + inset,
 640             content.y + inset_y,
 641             content.x + content.width - inset,
 642             content.y + content.height - inset_y,
 643         ]
 644     }
 645 
 646     pub fn set_placeholder(&mut self, placeholder: &str) {
 647         self.placeholder = Some(placeholder.to_string());
 648     }
 649 
 650     pub fn take_change(&mut self) -> bool {
 651         if self.update_on_type {
 652             let held = self.committed_buffer();
 653             if self.text != held {
 654                 self.text = held;
 655                 self.just_changed = true;
 656             }
 657         }
 658         let changed = self.just_changed;
 659         self.just_changed = false;
 660         changed
 661     }
 662 
 663     pub fn line_wrap_enabled(&self) -> bool {
 664         self.multiline && self.line_wrap_override.unwrap_or_else(crate::layout::textbox_line_wrap)
 665     }
 666 
 667     pub fn set_max_width(&mut self, max_w: Option<f32>) {
 668         self.max_width = max_w;
 669     }
 670 
 671     pub fn set_width(&mut self, w: f32) {
 672         self.width = Some(w);
 673     }
 674 
 675     pub fn sync_editor_state(&mut self) {
 676         self.editor_state = TextEditorState {
 677             buffer: self.edit_buffer.clone(),
 678             cursor_idx: self.cursor_idx,
 679             select_anchor: self.select_anchor,
 680             all_selected: self.all_selected,
 681         };
 682     }
 683 
 684     /// The editing fields as one value — what the history stores.
 685     fn snapshot(&self) -> TextEditorState {
 686         TextEditorState {
 687             buffer: self.edit_buffer.clone(),
 688             cursor_idx: self.cursor_idx,
 689             select_anchor: self.select_anchor,
 690             all_selected: self.all_selected,
 691         }
 692     }
 693 
 694     fn restore(&mut self, snap: TextEditorState) {
 695         self.edit_buffer = snap.buffer;
 696         self.cursor_idx = snap.cursor_idx;
 697         self.select_anchor = snap.select_anchor;
 698         self.all_selected = snap.all_selected;
 699         self.sync_editor_state();
 700         self.scroll_to_cursor();
 701     }
 702 
 703     /// Step the edit buffer back one recorded step. Only while editing —
 704     /// a committed value is the app's to undo, not the box's.
 705     pub fn undo_edit(&mut self) -> bool {
 706         if !self.editing || self.disabled {
 707             return false;
 708         }
 709         let current = self.snapshot();
 710         match self.history.undo(current) {
 711             Some(prev) => {
 712                 self.restore(prev);
 713                 self.just_changed = true;
 714                 true
 715             }
 716             None => false,
 717         }
 718     }
 719 
 720     /// Step forward again — see [`undo_edit`](Self::undo_edit).
 721     pub fn redo_edit(&mut self) -> bool {
 722         if !self.editing || self.disabled {
 723             return false;
 724         }
 725         let current = self.snapshot();
 726         match self.history.redo(current) {
 727             Some(next) => {
 728                 self.restore(next);
 729                 self.just_changed = true;
 730                 true
 731             }
 732             None => false,
 733         }
 734     }
 735 
 736     /// The selection as it may go to the clipboard: never a password box's.
 737     /// Every copy and cut goes through here -- Ctrl+C / Ctrl+X, the context
 738     /// menu's rows, [`copy_selection`](Self::copy_selection) and
 739     /// [`cut_selection`](Self::cut_selection) -- because each of them used to
 740     /// put a password on the clipboard in plain text, readable by any client,
 741     /// from the login greeter's and the polkit dialog's boxes alike.
 742     fn clipboard_text(&self, state: &TextEditorState) -> Option<String> {
 743         if self.is_password {
 744             return None;
 745         }
 746         state.selected_text()
 747     }
 748 
 749     pub fn copy_selection(&self) {
 750         let state = TextEditorState {
 751             buffer: self.edit_buffer.clone(),
 752             cursor_idx: self.cursor_idx,
 753             select_anchor: self.select_anchor,
 754             all_selected: self.all_selected,
 755         };
 756         if let Some(text) = self.clipboard_text(&state) {
 757             clipboard::copy_to_clipboard(&text);
 758         }
 759     }
 760 
 761     /// Cut the selection to the clipboard. A password box's selection can
 762     /// not go there (`clipboard_text`), so it is left in place, not deleted.
 763     pub fn cut_selection(&mut self) -> bool {
 764         if self.is_password {
 765             return false;
 766         }
 767         let before = self.snapshot();
 768         let mut state = TextEditorState {
 769             buffer: std::mem::take(&mut self.edit_buffer),
 770             cursor_idx: self.cursor_idx,
 771             select_anchor: self.select_anchor,
 772             all_selected: self.all_selected,
 773         };
 774         if let Some(text) = state.selected_text() {
 775             clipboard::copy_to_clipboard(&text);
 776             state.insert_text("");
 777             self.history.record(before);
 778             self.edit_buffer = state.buffer;
 779             self.cursor_idx = state.cursor_idx;
 780             self.select_anchor = state.select_anchor;
 781             self.all_selected = state.all_selected;
 782             self.sync_editor_state();
 783             true
 784         } else {
 785             self.edit_buffer = state.buffer;
 786             self.sync_editor_state();
 787             false
 788         }
 789     }
 790 
 791     pub fn paste_from_clipboard(&mut self) -> bool {
 792         if let Some(text) = clipboard::read_from_clipboard() {
 793             let before = self.snapshot();
 794             let mut state = TextEditorState {
 795                 buffer: std::mem::take(&mut self.edit_buffer),
 796                 cursor_idx: self.cursor_idx,
 797                 select_anchor: self.select_anchor,
 798                 all_selected: self.all_selected,
 799             };
 800             let mut cleaned = String::new();
 801             for ch in text.chars() {
 802                 if !ch.is_control() && ch != '\n' && ch != '\r' {
 803                     cleaned.push(ch);
 804                 }
 805             }
 806             state.insert_text(&cleaned);
 807             self.history.record(before);
 808             self.edit_buffer = state.buffer;
 809             self.cursor_idx = state.cursor_idx;
 810             self.select_anchor = state.select_anchor;
 811             self.all_selected = state.all_selected;
 812             self.sync_editor_state();
 813             true
 814         } else {
 815             false
 816         }
 817     }
 818 
 819     pub fn select_all(&mut self) {
 820         let mut state = TextEditorState {
 821             buffer: std::mem::take(&mut self.edit_buffer),
 822             cursor_idx: self.cursor_idx,
 823             select_anchor: self.select_anchor,
 824             all_selected: self.all_selected,
 825         };
 826         state.select_all();
 827         self.edit_buffer = state.buffer;
 828         self.cursor_idx = state.cursor_idx;
 829         self.select_anchor = state.select_anchor;
 830         self.all_selected = state.all_selected;
 831         self.sync_editor_state();
 832     }
 833 
 834     pub fn set_value(&mut self, val: &str) -> bool {
 835         let val_str = val.to_string();
 836         if self.text != val_str {
 837             if self.editing {
 838                 let before = self.snapshot();
 839                 self.history.record(before);
 840             } else {
 841                 self.history.clear();
 842             }
 843             self.text = val_str.clone();
 844             self.edit_buffer = val_str;
 845             self.just_changed = true;
 846             let len = self.edit_buffer.chars().count();
 847             self.cursor_idx = self.cursor_idx.min(len);
 848             if let Some(anchor) = self.select_anchor {
 849                 self.select_anchor = Some(anchor.min(len));
 850             }
 851             if self.cursor_idx == 0 && self.select_anchor == Some(0) {
 852                 self.all_selected = false;
 853             }
 854             self.sync_editor_state();
 855             self.clamp_scroll();
 856             true
 857         } else {
 858             false
 859         }
 860     }
 861 
 862     /// The widest line's drawn advance — shaped when recorded, else the
 863     /// chars × char_width estimate (the pre-shaping formula).
 864     fn content_width(&self, lines: &[String]) -> f32 {
 865         let shaped = if self.multiline {
 866             self.line_glyph_positions
 867                 .iter()
 868                 // A line's width is its widest stop: the last in left-to-right text, the
 869                 // first in right-to-left.
 870                 .map(|l| l.iter().copied().fold(0.0f32, f32::max))
 871                 .fold(0.0f32, f32::max)
 872         } else {
 873             self.total_text_width
 874         };
 875         if shaped > 0.0 {
 876             shaped
 877         } else {
 878             let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
 879             max_line_len as f32 * self.char_width()
 880         }
 881     }
 882 
 883     pub fn clamp_scroll(&mut self) {
 884         let pad = self.pad();
 885         let line_height = self.line_height();
 886         let max_w = self.wrap_width(self.rect.width);
 887         let (lines, _) = if self.multiline {
 888             self.wrap_text(max_w)
 889         } else {
 890             let buffer = if self.editing { &self.edit_buffer } else { &self.text };
 891             (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
 892         };
 893 
 894         if self.multiline {
 895             let content_h = lines.len() as f32 * line_height;
 896             let max_scroll = (content_h - (self.rect.height - 2.0 * pad)).max(0.0);
 897             self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
 898         } else {
 899             self.scroll_y = 0.0;
 900         }
 901 
 902         if !self.line_wrap_enabled() {
 903             let content_w = self.content_width(&lines);
 904             let max_scroll_x = (content_w - (self.rect.width - 2.0 * pad)).max(0.0);
 905             self.scroll_x = self.scroll_x.clamp(0.0, max_scroll_x);
 906         } else {
 907             self.scroll_x = 0.0;
 908         }
 909     }
 910 
 911     pub fn scroll_to_cursor(&mut self) {
 912         let pad = self.pad();
 913         let char_width = self.char_width();
 914         let line_height = self.line_height();
 915         let max_w = self.wrap_width(self.rect.width);
 916         let (_lines, index_map) = if self.multiline {
 917             self.wrap_text(max_w)
 918         } else {
 919             let buffer = if self.editing { &self.edit_buffer } else { &self.text };
 920             let mut m = Vec::new();
 921             for i in 0..=buffer.chars().count() {
 922                 m.push((0, i));
 923             }
 924             (vec![buffer.clone()], m)
 925         };
 926         if index_map.is_empty() { return; }
 927 
 928         let cursor_idx = self.cursor_idx.min(index_map.len() - 1);
 929         let (line_idx, col_idx) = index_map[cursor_idx];
 930 
 931         let top = self.label_top();
 932         let viewport_w = self.rect.width - 2.0 * pad;
 933         let viewport_h = self.rect.height - top - 2.0 * pad;
 934 
 935         if self.multiline {
 936             let line_y = top + pad + (line_idx as f32 * line_height);
 937             if line_y < self.scroll_y + 10.0 {
 938                 self.scroll_y = (line_y - 20.0).max(0.0);
 939             } else if line_y + line_height > self.scroll_y + viewport_h - 10.0 {
 940                 self.scroll_y = (line_y + line_height - viewport_h + 20.0).max(0.0);
 941             }
 942         }
 943 
 944         if !self.line_wrap_enabled() {
 945             let cursor_x = if self.multiline {
 946                 self.line_col_x(line_idx, col_idx)
 947             } else {
 948                 self.glyph_positions
 949                     .get(col_idx)
 950                     .copied()
 951                     .unwrap_or(col_idx as f32 * char_width)
 952             };
 953             if cursor_x < self.scroll_x + 10.0 {
 954                 self.scroll_x = (cursor_x - 20.0).max(0.0);
 955             } else if cursor_x + char_width > self.scroll_x + viewport_w - 10.0 {
 956                 self.scroll_x = (cursor_x + char_width - viewport_w + 20.0).max(0.0);
 957             }
 958         }
 959         self.clamp_scroll();
 960     }
 961 
 962     /// `edit_buffer` without an input method's provisional run: what the box
 963     /// holds, as opposed to what it shows.
 964     pub fn committed_buffer(&self) -> String {
 965         match self.composing {
 966             Some((start, len)) => self.edit_buffer.chars().enumerate().filter(|(i, _)| *i < start || *i >= start + len).map(|(_, c)| c).collect(),
 967             None => self.edit_buffer.clone(),
 968         }
 969     }
 970 
 971     /// Take the provisional run out of the buffer, the caret back where it
 972     /// began. Whether there was one.
 973     fn strip_composition(&mut self) -> bool {
 974         let Some((start, _)) = self.composing else { return false };
 975         self.edit_buffer = self.committed_buffer();
 976         self.composing = None;
 977         self.cursor_idx = start.min(self.edit_buffer.chars().count());
 978         self.select_anchor = None;
 979         self.all_selected = false;
 980         true
 981     }
 982 
 983     /// Drop a composition this box is showing, and have the input method
 984     /// cancel it: the box is no longer where it is going.
 985     fn abandon_composition(&mut self) {
 986         if self.strip_composition() {
 987             crate::ime::request_reset();
 988             self.sync_editor_state();
 989         }
 990         self.ime_seen = crate::ime::generation();
 991     }
 992 
 993     /// Show the input method's current composition, if it has moved since
 994     /// this box last showed one: the old run out, the new one in at the
 995     /// caret, the caret where the input method has its cursor. A composition
 996     /// begun over a selection replaces it, as typing would.
 997     fn sync_preedit(&mut self) {
 998         if !self.editing {
 999             return;
1000         }
1001         let generation = crate::ime::generation();
1002         if generation == self.ime_seen {
1003             return;
1004         }
1005         self.ime_seen = generation;
1006         self.strip_composition();
1007         if let Some(p) = crate::ime::preedit() {
1008             let before = self.snapshot();
1009             if before.all_selected || before.selected_range().is_some() {
1010                 let mut state = before.clone();
1011                 state.insert_text("");
1012                 self.history.record(before);
1013                 self.edit_buffer = state.buffer;
1014                 self.cursor_idx = state.cursor_idx;
1015             }
1016             let start = self.cursor_idx.min(self.edit_buffer.chars().count());
1017             let at = self.edit_buffer.char_indices().nth(start).map_or(self.edit_buffer.len(), |(b, _)| b);
1018             self.edit_buffer.insert_str(at, &p.text);
1019             self.composing = Some((start, p.text.chars().count()));
1020             self.cursor_idx = start + p.caret_chars();
1021             self.select_anchor = None;
1022             self.all_selected = false;
1023         }
1024         self.sync_editor_state();
1025         self.scroll_to_cursor();
1026     }
1027 
1028     /// The legacy `focus()` body minus the global-focus claim (the caller's, via
1029     /// `EventCtx::request_focus`).
1030     fn begin_editing(&mut self) {
1031         if self.disabled { return; }
1032         // A composition still going is the input method's for wherever the
1033         // caret was; this box starts with none.
1034         self.composing = None;
1035         self.ime_seen = crate::ime::generation();
1036         self.editing = true;
1037         self.edit_buffer = self.text.clone();
1038         let len = self.edit_buffer.chars().count();
1039         self.cursor_idx = len;
1040         self.select_anchor = Some(0);
1041         self.all_selected = len > 0;
1042         self.just_focused = true;
1043         self.history.clear();
1044         self.sync_editor_state();
1045     }
1046 
1047     /// The legacy `unfocus()` body: leave edit mode and commit the buffer.
1048     fn commit_editing(&mut self) {
1049         if self.editing {
1050             self.abandon_composition();
1051             self.editing = false;
1052             if self.text != self.edit_buffer {
1053                 self.text = self.edit_buffer.clone();
1054                 self.just_changed = true;
1055             }
1056             self.select_anchor = None;
1057             self.all_selected = false;
1058             self.sync_editor_state();
1059         }
1060     }
1061 
1062     /// Map a press/drag position to a buffer index — the shared body of the legacy
1063     /// `mouse_input` press arm and `drag_update`.
1064     fn position_to_idx(&self, px: f32, py: f32) -> usize {
1065         let pad = self.pad();
1066         let top = self.label_top();
1067         if self.multiline {
1068             let line_height = self.line_height();
1069             let max_w = self.wrap_width(self.rect.width);
1070             let (lines, index_map) = self.wrap_text(max_w);
1071             let click_line = (((py - (self.rect.y + top + pad) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
1072             let rel_x = px - (self.rect.x + pad) + self.scroll_x;
1073             let click_col = self.line_x_to_col(click_line.min(lines.len() - 1), rel_x);
1074             self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
1075         } else {
1076             self.map_x_to_idx(px)
1077         }
1078     }
1079 
1080     /// Extend the selection to a drag position — the shared body of the legacy
1081     /// `on_cursor_moved` drag arm and `drag_update` (which used no label inset).
1082     fn extend_selection_to(&mut self, px: f32, py: f32) -> bool {
1083         let drag_idx = self.position_to_idx(px, py);
1084         if self.cursor_idx != drag_idx {
1085             self.cursor_idx = drag_idx;
1086             self.just_focused = false;
1087             let len = self.edit_buffer.chars().count();
1088             let start = self.select_anchor.unwrap_or(0).min(self.cursor_idx);
1089             let end = self.select_anchor.unwrap_or(0).max(self.cursor_idx);
1090             self.all_selected = start == 0 && end == len && len > 0;
1091             true
1092         } else {
1093             false
1094         }
1095     }
1096 
1097     /// Port of the legacy `keyboard_input` body.
1098     fn handle_key(&mut self, event: &KeyEvent) -> bool {
1099         if !self.editing || self.disabled { return false; }
1100         if event.state != ElementState::Pressed { return false; }
1101         // While an input method composes, its keys are its own: a shell does
1102         // not deliver them, and one that does is not editing this text.
1103         self.sync_preedit();
1104         if self.composing.is_some() {
1105             return true;
1106         }
1107 
1108         let control = event.ctrl;
1109 
1110         // The undo/redo chords, for apps that hand keys to widgets without
1111         // exposing a `UiContext` (the runner's routing reaches the box
1112         // through `ContextAction` first when they do). Before the working
1113         // copy below, since a step replaces the whole editing state.
1114         if control {
1115             if match_key_shortcut(event, &crate::input::widget_chord("undo", "", "ctrl+z")) {
1116                 return self.undo_edit();
1117             }
1118             if match_key_shortcut(event, &crate::input::widget_chord("redo", "", "ctrl+shift+z")) {
1119                 return self.redo_edit();
1120             }
1121         }
1122 
1123         let before = self.snapshot();
1124         let mut state = before.clone();
1125 
1126         let handled = match &event.logical_key {
1127             Key::Named(NamedKey::Backspace) => {
1128                 state.delete_backwards()
1129             }
1130             Key::Named(NamedKey::Delete) => {
1131                 state.delete_forwards()
1132             }
1133             Key::Named(NamedKey::ArrowLeft) => {
1134                 state.move_cursor_left(event.shift)
1135             }
1136             Key::Named(NamedKey::ArrowRight) => {
1137                 state.move_cursor_right(event.shift)
1138             }
1139             Key::Named(NamedKey::ArrowUp) => {
1140                 if state.all_selected {
1141                     state.clear_selection();
1142                 } else if event.shift {
1143                     if state.select_anchor.is_none() {
1144                         state.select_anchor = Some(state.cursor_idx);
1145                     }
1146                 } else {
1147                     state.clear_selection();
1148                 }
1149                 if self.multiline {
1150                     let max_w = self.wrap_width(self.rect.width);
1151                     let (lines, index_map) = self.wrap_text(max_w);
1152                     let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
1153                     if cursor_l > 0 {
1154                         state.cursor_idx = self.map_2d_to_1d(&index_map, cursor_l - 1, cursor_c, lines.len() - 1);
1155                     } else {
1156                         state.cursor_idx = 0;
1157                     }
1158                 } else {
1159                     state.cursor_idx = 0;
1160                 }
1161                 true
1162             }
1163             Key::Named(NamedKey::ArrowDown) => {
1164                 if state.all_selected {
1165                     state.clear_selection();
1166                 } else if event.shift {
1167                     if state.select_anchor.is_none() {
1168                         state.select_anchor = Some(state.cursor_idx);
1169                     }
1170                 } else {
1171                     state.clear_selection();
1172                 }
1173                 if self.multiline {
1174                     let max_w = self.wrap_width(self.rect.width);
1175                     let (lines, index_map) = self.wrap_text(max_w);
1176                     let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
1177                     if cursor_l < lines.len() - 1 {
1178                         state.cursor_idx = self.map_2d_to_1d(&index_map, cursor_l + 1, cursor_c, lines.len() - 1);
1179                     } else {
1180                         state.cursor_idx = state.buffer.chars().count();
1181                     }
1182                 } else {
1183                     state.cursor_idx = state.buffer.chars().count();
1184                 }
1185                 true
1186             }
1187             Key::Named(NamedKey::Home) => {
1188                 state.move_cursor_to_start(event.shift)
1189             }
1190             Key::Named(NamedKey::End) => {
1191                 state.move_cursor_to_end(event.shift)
1192             }
1193             Key::Named(NamedKey::Enter) => {
1194                 if self.multiline {
1195                     state.insert_text("\n");
1196                     true
1197                 } else {
1198                     self.commit_editing();
1199                     true
1200                 }
1201             }
1202             Key::Named(NamedKey::Escape) => {
1203                 self.editing = false;
1204                 state.buffer = self.text.clone();
1205                 state.clear_selection();
1206                 true
1207             }
1208             Key::Character(ref ch_str) if control && (ch_str == "a" || ch_str == "A") => {
1209                 state.select_all();
1210                 true
1211             }
1212             Key::Character(ref ch_str) if control && (ch_str == "c" || ch_str == "C") => {
1213                 if let Some(text) = self.clipboard_text(&state) {
1214                     clipboard::copy_to_clipboard(&text);
1215                 }
1216                 true
1217             }
1218             // A password box's selection stays put: it can not be cut to the
1219             // clipboard, and deleting it alone would not be a cut.
1220             Key::Character(ref ch_str) if control && (ch_str == "x" || ch_str == "X") => {
1221                 if let Some(text) = self.clipboard_text(&state) {
1222                     clipboard::copy_to_clipboard(&text);
1223                     state.insert_text("");
1224                 }
1225                 true
1226             }
1227             Key::Character(ref ch_str) if control && (ch_str == "v" || ch_str == "V") => {
1228                 if let Some(pasted) = clipboard::read_from_clipboard() {
1229                     let mut cleaned = String::new();
1230                     for ch in pasted.chars() {
1231                         if !ch.is_control() && ch != '\n' && ch != '\r' {
1232                             cleaned.push(ch);
1233                         }
1234                     }
1235                     state.insert_text(&cleaned);
1236                 } else {
1237                     state.clear_selection();
1238                 }
1239                 true
1240             }
1241             _ => {
1242                 if let Some(text) = &event.text {
1243                     if !control {
1244                         state.insert_text(text);
1245                         true
1246                     } else {
1247                         false
1248                     }
1249                 } else {
1250                     false
1251                 }
1252             }
1253         };
1254 
1255         if self.editing {
1256             if state.buffer != before.buffer {
1257                 // One step per typed run, per deleted run; whitespace
1258                 // starts a new run so undo walks back a word at a time.
1259                 // Replacing a selection is always its own step.
1260                 let group = match &event.logical_key {
1261                     _ if before.selected_range().is_some() => None,
1262                     Key::Named(NamedKey::Backspace) => Some(2),
1263                     Key::Named(NamedKey::Delete) => Some(3),
1264                     Key::Character(_) if !control => {
1265                         let ws = event.text.as_deref().is_some_and(|t| t.chars().all(char::is_whitespace));
1266                         Some(if ws { 4 } else { 1 })
1267                     }
1268                     _ => None,
1269                 };
1270                 match group {
1271                     Some(g) => self.history.record_grouped(before, g),
1272                     None => self.history.record(before),
1273                 }
1274             } else if handled {
1275                 // A cursor or selection move between keystrokes splits the
1276                 // run: "abc", move, "def" undoes as two steps.
1277                 self.history.break_group();
1278             }
1279             self.edit_buffer = state.buffer;
1280             self.cursor_idx = state.cursor_idx;
1281             self.select_anchor = state.select_anchor;
1282             self.all_selected = state.all_selected;
1283             self.sync_editor_state();
1284             self.scroll_to_cursor();
1285         }
1286 
1287         handled
1288     }
1289 
1290     /// Port of the legacy `mouse_wheel` body (scroll the multiline/no-wrap viewports).
1291     fn handle_wheel(&mut self, delta: &MouseScrollDelta) -> bool {
1292         let pad = self.pad();
1293         if self.disabled { return false; }
1294         let char_width = self.char_width();
1295         let line_height = self.line_height();
1296 
1297         let max_w = self.wrap_width(self.rect.width);
1298 
1299         let (lines, _) = if self.multiline {
1300             self.wrap_text(max_w)
1301         } else {
1302             let buffer = if self.editing { &self.edit_buffer } else { &self.text };
1303             (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
1304         };
1305 
1306         let mut dy_px = 0.0;
1307         let mut max_scroll_y = 0.0;
1308         if self.multiline {
1309             let content_h = lines.len() as f32 * line_height;
1310             max_scroll_y = (content_h - (self.rect.height - 2.0 * pad)).max(0.0);
1311             dy_px = match *delta {
1312                 MouseScrollDelta::LineDelta(_, dy) => -dy * line_height * 2.0,
1313                 MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
1314             };
1315         }
1316 
1317         let mut dx_px = 0.0;
1318         let mut max_scroll_x = 0.0;
1319         if !self.line_wrap_enabled() {
1320             let content_w = self.content_width(&lines);
1321             max_scroll_x = (content_w - (self.rect.width - 2.0 * pad)).max(0.0);
1322             let natural = crate::layout::touchpad_natural_scroll();
1323             let scroll_amt_x = match *delta {
1324                 MouseScrollDelta::LineDelta(dx, dy) => {
1325                     if !self.multiline {
1326                         let scroll_val = if dy != 0.0 { -dy } else { if natural { -dx } else { dx } };
1327                         scroll_val * char_width * 3.0
1328                     } else {
1329                         let scroll_val = if natural { -dx } else { dx };
1330                         scroll_val * char_width * 3.0
1331                     }
1332                 }
1333                 MouseScrollDelta::PixelDelta(pos) => {
1334                     if !self.multiline {
1335                         
1336                         if pos.y != 0.0 { -pos.y as f32 } else { if natural { -pos.x as f32 } else { pos.x as f32 } }
1337                     } else {
1338                         if natural { -pos.x as f32 } else { pos.x as f32 }
1339                     }
1340                 }
1341             };
1342             dx_px = scroll_amt_x;
1343         }
1344 
1345         // Both axes through the shared motion: notches glide, finger tracks
1346         // 1:1, a flick coasts. The pub offsets are the drawn values.
1347         self.scroll_max = (max_scroll_x, max_scroll_y);
1348         self.scroll_motion.reconcile(self.scroll_x, self.scroll_y);
1349         let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
1350         let changed = self.scroll_motion.apply_px(
1351             dx_px,
1352             dy_px,
1353             discrete,
1354             Bounds::max(max_scroll_x),
1355             Bounds::max(max_scroll_y),
1356         );
1357         self.scroll_x = self.scroll_motion.x.pos();
1358         self.scroll_y = self.scroll_motion.y.pos();
1359         changed
1360     }
1361 
1362     /// Selection highlight + caret quads, shared by both render branches. `x`/`w` are the
1363     /// (possibly label-inset) horizontal span the branch draws in — the legacy paths differed
1364     /// (non-rounded and rounded alike use the full base span).
1365     /// The selection highlight, an input method's composition underlined, and
1366     /// the caret, as quads. Returns the caret's rect while editing (unclipped,
1367     /// in the box's coordinates), which the painter reports to the input method.
1368     fn selection_quads(&self, x: f32, w: f32, out: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) -> Option<[f32; 4]> {
1369         let pad = self.pad();
1370         if !(self.editing || self.select_anchor.is_some()) {
1371             return None;
1372         }
1373         let mut caret = None;
1374         let top = self.label_top();
1375         let char_width = self.char_width();
1376         let line_height = self.line_height();
1377 
1378         let highlight_color = [0.20, 0.50, 0.85, 0.3];
1379         let cursor_color = if self.draw_bg_border {
1380             [0.80, 0.80, 0.85, 1.0]
1381         } else {
1382             [0.10, 0.10, 0.15, 1.0]
1383         };
1384 
1385         let start = self.select_anchor.unwrap_or(self.cursor_idx).min(self.cursor_idx);
1386         let end = self.select_anchor.unwrap_or(self.cursor_idx).max(self.cursor_idx);
1387 
1388         if self.multiline {
1389             let max_w = self.wrap_width(w);
1390             let (_lines, index_map) = self.wrap_text(max_w);
1391 
1392             let view_top = self.rect.y + top;
1393             let view_bottom = self.rect.y + self.rect.height;
1394 
1395             if start != end {
1396                 let start_pos = index_map[start.min(index_map.len() - 1)];
1397                 let end_pos = index_map[end.min(index_map.len() - 1)];
1398 
1399                 for line_idx in start_pos.0..=end_pos.0 {
1400                     let mut line_start_col = None;
1401                     let mut line_end_col = None;
1402                     for idx in start..end {
1403                         if idx < index_map.len() {
1404                             let (l, c) = index_map[idx];
1405                             if l == line_idx {
1406                                 if line_start_col.is_none() || c < line_start_col.unwrap() {
1407                                     line_start_col = Some(c);
1408                                 }
1409                                 if line_end_col.is_none() || c > line_end_col.unwrap() {
1410                                     line_end_col = Some(c);
1411                                 }
1412                             }
1413                         }
1414                     }
1415                     if let (Some(sc), Some(ec)) = (line_start_col, line_end_col) {
1416                         let highlight_y = self.rect.y + top + pad + (line_idx as f32 * line_height) - self.scroll_y;
1417                         let clipped_y = highlight_y.max(view_top);
1418                         let clipped_bottom = (highlight_y + line_height).min(view_bottom);
1419                         let shift = self.line_shift.get(line_idx).copied().unwrap_or(0.0);
1420                         // The boxes of the selected clusters: two pieces where the selection
1421                         // crosses a change of direction. Without a shaped run, the column span.
1422                         let spans = match (self.line_runs.get(line_idx), _lines.get(line_idx)) {
1423                             (Some(run), Some(line)) => {
1424                                 let byte = |col: usize| line.char_indices().nth(col).map_or(line.len(), |(b, _)| b);
1425                                 run.spans(byte(sc), byte(ec + 1)).into_iter().map(|(a, b)| (a + shift, b + shift)).collect()
1426                             }
1427                             _ => {
1428                                 let (a, b) = (self.line_col_x(line_idx, sc), self.line_col_x(line_idx, ec + 1));
1429                                 vec![(a.min(b), a.max(b))]
1430                             }
1431                         };
1432                         for (a, b) in spans {
1433                             let h_left = (x + pad + a - self.scroll_x).max(x + pad);
1434                             let h_right = (x + pad + b - self.scroll_x).min(x + w - pad);
1435                             if h_left < h_right && clipped_y < clipped_bottom {
1436                                 out.push((h_left, clipped_y, h_right - h_left, clipped_bottom - clipped_y, highlight_color));
1437                             }
1438                         }
1439                     }
1440                 }
1441             }
1442 
1443             if let Some((cs, cl)) = self.composing {
1444                 // The composition's underline, a line at a time, under the
1445                 // glyphs it covers.
1446                 let last_line = index_map.get((cs + cl).saturating_sub(1).min(index_map.len() - 1)).map_or(0, |p| p.0);
1447                 let first_line = index_map.get(cs.min(index_map.len() - 1)).map_or(0, |p| p.0);
1448                 for line_idx in first_line..=last_line {
1449                     let cols: Vec<usize> = (cs..cs + cl)
1450                         .filter_map(|i| index_map.get(i).filter(|p| p.0 == line_idx).map(|p| p.1))
1451                         .collect();
1452                     if let (Some(&a), Some(&b)) = (cols.iter().min(), cols.iter().max()) {
1453                         let (xa, xb) = (self.line_col_x(line_idx, a), self.line_col_x(line_idx, b + 1));
1454                         let ux = x + pad + xa.min(xb) - self.scroll_x;
1455                         let uw = (xb - xa).abs();
1456                         let uy = self.rect.y + top + pad + ((line_idx + 1) as f32 * line_height) - 2.0 - self.scroll_y;
1457                         let left = ux.max(x + pad);
1458                         let right = (ux + uw).min(x + w - pad);
1459                         if left < right && uy >= view_top && uy + 1.5 <= view_bottom {
1460                             out.push((left, uy, right - left, 1.5, cursor_color));
1461                         }
1462                     }
1463                 }
1464             }
1465 
1466             if self.editing {
1467                 let caret_h = self.font_size * 1.15;
1468                 let (cursor_l, cursor_c) = index_map[self.cursor_idx.min(index_map.len() - 1)];
1469                 let cursor_x = x + pad + self.line_col_x(cursor_l, cursor_c) - self.scroll_x;
1470                 let cursor_y = self.rect.y + top + pad + (cursor_l as f32 * line_height) + (line_height - caret_h) / 2.0 - self.scroll_y;
1471                 caret = Some([cursor_x, cursor_y, 1.5, caret_h]);
1472                 let clipped_y = cursor_y.max(view_top);
1473                 let clipped_bottom = (cursor_y + caret_h).min(view_bottom);
1474                 if cursor_x >= x + pad && cursor_x <= x + w - pad
1475                     && clipped_y < clipped_bottom {
1476                         out.push((cursor_x, clipped_y, 1.5, clipped_bottom - clipped_y, cursor_color));
1477                     }
1478             }
1479         } else {
1480             let caret_h = self.font_size * 1.15;
1481             if start != end {
1482                 // The boxes of the selected clusters: two pieces where the selection crosses
1483                 // a change of direction. Without a shaped run, the column span.
1484                 let shown = if self.is_password { "•".repeat(self.edit_buffer.chars().count()) } else { self.edit_buffer.clone() };
1485                 let spans: Vec<(f32, f32)> = match &self.glyph_run {
1486                     Some(run) => {
1487                         let byte = |col: usize| shown.char_indices().nth(col).map_or(shown.len(), |(b, _)| b);
1488                         run.spans(byte(start), byte(end)).into_iter().map(|(a, b)| (a + self.glyph_shift, b + self.glyph_shift)).collect()
1489                     }
1490                     None => {
1491                         let at = |i: usize| self.glyph_positions.get(i).copied().unwrap_or(i as f32 * char_width);
1492                         vec![(at(start).min(at(end)), at(start).max(at(end)))]
1493                     }
1494                 };
1495                 for (a, b) in spans {
1496                     let h_left = (x + pad + a - self.scroll_x).max(x + pad);
1497                     let h_right = (x + pad + b - self.scroll_x).min(x + w - pad);
1498                     if h_left < h_right {
1499                         out.push((
1500                             h_left,
1501                             crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
1502                             h_right - h_left,
1503                             crate::layout::line_height(self.font_size),
1504                             highlight_color,
1505                         ));
1506                     }
1507                 }
1508             }
1509 
1510             if let Some((cs, cl)) = self.composing {
1511                 let at = |i: usize| self.glyph_positions.get(i).copied().unwrap_or(i as f32 * char_width);
1512                 let (ca, cb) = (at(cs).min(at(cs + cl)), at(cs).max(at(cs + cl)));
1513                 let left = (x + pad + ca - self.scroll_x).max(x + pad);
1514                 let right = (x + pad + cb - self.scroll_x).min(x + w - pad);
1515                 if left < right {
1516                     let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
1517                     out.push((left, text_y + self.font_size + 1.0, right - left, 1.5, cursor_color));
1518                 }
1519             }
1520 
1521             if self.editing {
1522                 let offset = if self.glyph_positions.is_empty() {
1523                     self.cursor_idx as f32 * char_width
1524                 } else {
1525                     self.cursor_x_offset
1526                 };
1527                 let cursor_x = x + pad + offset - self.scroll_x;
1528                 let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
1529                 let cursor_y = text_y + (self.font_size - caret_h) / 2.0;
1530                 caret = Some([cursor_x, cursor_y, 1.5, caret_h]);
1531                 if cursor_x >= x + pad && cursor_x <= x + w - pad {
1532                     out.push((cursor_x, cursor_y, 1.5, caret_h, cursor_color));
1533                 }
1534             }
1535         }
1536         caret
1537     }
1538 
1539     /// The value/placeholder text lines — the legacy `text_labels` body minus the control
1540     /// label (the adapter's base-label machinery draws that).
1541     fn value_labels(&self) -> Vec<TextLabel> {
1542         let pad = self.pad();
1543         let mut labels = Vec::new();
1544         let top = self.label_top();
1545         let mut val_text = if self.editing {
1546             self.edit_buffer.clone()
1547         } else {
1548             self.text.clone()
1549         };
1550         if self.is_password {
1551             val_text = "•".repeat(val_text.chars().count());
1552         }
1553 
1554         let is_placeholder = val_text.is_empty() && self.placeholder.is_some();
1555         let display_text = if is_placeholder {
1556             self.placeholder.as_ref().unwrap().clone()
1557         } else {
1558             val_text
1559         };
1560 
1561         let label_color = if is_placeholder {
1562             crate::colors::textbox_placeholder_text_color()
1563         } else if let Some(custom_color) = self.text_color {
1564             custom_color
1565         } else if self.disabled {
1566             [0x53, 0x53, 0x5a]
1567         } else if self.all_selected {
1568             [0xff, 0xff, 0xff]
1569         } else if self.editing {
1570             [0xee, 0xee, 0xf5]
1571         } else {
1572             [0xcc, 0xcc, 0xd4]
1573         };
1574 
1575         let x = self.rect.x;
1576         let w = self.rect.width;
1577 
1578         if self.multiline {
1579             let line_height = self.line_height();
1580             let max_w = self.wrap_width(w);
1581             let (lines, _) = self.wrap_text(max_w);
1582             let lines_to_draw = if is_placeholder {
1583                 self.wrap_str(self.placeholder.as_deref().unwrap_or(""), max_w).0
1584             } else {
1585                 lines
1586             };
1587             for (line_idx, line_text) in lines_to_draw.iter().enumerate() {
1588                 let shift = if is_placeholder { 0.0 } else { self.line_shift.get(line_idx).copied().unwrap_or(0.0) };
1589                 labels.push(TextLabel {
1590                     text: line_text.clone(),
1591                     x: x + pad + shift - self.scroll_x,
1592                     y: self.rect.y + top + pad + (line_idx as f32 * line_height) + (line_height - self.font_size) / 2.0 - self.scroll_y,
1593                     font_size: self.font_size,
1594                     color: label_color,
1595                 });
1596             }
1597         } else {
1598             labels.push(TextLabel {
1599                 text: display_text,
1600                 x: x + pad + self.glyph_shift - self.scroll_x,
1601                 y: crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
1602                 font_size: self.font_size,
1603                 color: label_color,
1604             });
1605         }
1606         labels
1607     }
1608 
1609     /// The well this TextBox carves: a [`crate::scene::paint::Field`] that is
1610     /// all well, lit while editing — its radii top-left, top-right,
1611     /// bottom-right, bottom-left, the right two square when the box is
1612     /// [`Self::joined_right`] (its host draws the field that joins it to a
1613     /// run, `ParametersBg::fields`). `None` when it draws no relief at all
1614     /// (square-cornered legacy geometry, `control_relief` off, or a box that
1615     /// draws no background).
1616     ///
1617     /// The SINGLE source for that geometry: `paint` carves it here, and the
1618     /// flat-path bridge in `layout::render_widget` re-offers the same rect
1619     /// through [`crate::layout::RenderTarget::recess`] for hosts that consume
1620     /// `all_quads` and so never see the carve. A second copy of this math in
1621     /// the bridge is exactly how the two would drift apart.
1622     pub fn well(&self) -> Option<crate::scene::paint::Field> {
1623         let radius = crate::layout::textbox_corner_radius();
1624         if radius <= 0.0 || !self.recessed() || !self.draw_bg_border {
1625             return None;
1626         }
1627         let top = self.label_top();
1628         let well = Rect {
1629             x: self.rect.x,
1630             y: self.rect.y + top,
1631             width: self.rect.width,
1632             height: self.rect.height - top,
1633         };
1634         let depth = crate::layout::bevel_width().min(well.height * 0.2);
1635         let right = if self.joined_right { 0.0 } else { radius };
1636         let (well, radii) = crate::layout::carve_inside(well, (radius, right, right, radius), depth);
1637         let tint = self.editing.then(|| {
1638             let hc = crate::color::highlight_primary_color();
1639             [hc[0], hc[1], hc[2]]
1640         });
1641         Some(crate::scene::paint::Field::well(well, radii, depth).with_tint(tint))
1642     }
1643 }
1644 
1645 impl Adapted<TextBox> {
1646     /// Recessed style: see the `recessed` field.
1647     pub fn with_recessed(mut self, recessed: bool) -> Self {
1648         self.recessed = Some(recessed);
1649         self
1650     }
1651 
1652     pub fn with_update_on_type(mut self, update: bool) -> Self {
1653         self.update_on_type = update;
1654         self
1655     }
1656 
1657     pub fn with_multiline(mut self, multiline: bool) -> Self {
1658         self.multiline = multiline;
1659         self
1660     }
1661 
1662     pub fn with_line_wrap(mut self, wrap: bool) -> Self {
1663         self.line_wrap_override = Some(wrap);
1664         self
1665     }
1666 
1667     pub fn with_draw_bg_border(mut self, draw: bool) -> Self {
1668         self.draw_bg_border = draw;
1669         self
1670     }
1671 
1672     pub fn with_text_color(mut self, color: Option<[u8; 3]>) -> Self {
1673         self.text_color = color;
1674         self
1675     }
1676 
1677     pub fn with_font_size(mut self, size: f32) -> Self {
1678         self.font_size = size;
1679         self
1680     }
1681 
1682     pub fn with_font_family(mut self, family: String) -> Self {
1683         self.font_family = family;
1684         self
1685     }
1686 
1687     pub fn with_password(mut self, is_password: bool) -> Self {
1688         self.is_password = is_password;
1689         self
1690     }
1691 
1692     /// A search box: the toolkit's "Search..." placeholder, in the user's language, and a
1693     /// menu with Clear.
1694     pub fn with_search(mut self) -> Self {
1695         self.is_search = true;
1696         self.placeholder = Some(crate::l10n::tr("search-placeholder"));
1697         self
1698     }
1699 
1700     pub fn with_placeholder(mut self, placeholder: &str) -> Self {
1701         self.placeholder = Some(placeholder.to_string());
1702         self
1703     }
1704 
1705     pub fn with_max_width(mut self, max_w: Option<f32>) -> Self {
1706         self.max_width = max_w;
1707         self
1708     }
1709 
1710     pub fn with_width(mut self, w: f32) -> Self {
1711         self.width = Some(w);
1712         self
1713     }
1714 }
1715 
1716 impl Layout for TextBox {
1717 
1718     /// One row for a single-line box; a multiline box has no natural height of its own —
1719     /// the host sizes it, and a layout strategy leaves its assigned rect alone.
1720     fn intrinsic_size(&self) -> Option<Size> {
1721         if self.multiline {
1722             return None;
1723         }
1724         Some(Size::new(0.0, crate::layout::textbox_height()))
1725     }
1726 
1727     fn hit_row_rect(&self) -> bool {
1728         true
1729     }
1730 
1731     /// The legacy `set_rect` width clamp: an explicit `width` wins, else cap at `max_width`.
1732     fn adjust_rect(&self, requested: Rect) -> Rect {
1733         let final_w = if let Some(explicit_w) = self.width {
1734             explicit_w
1735         } else if let Some(max_w) = self.max_width {
1736             requested.width.min(max_w)
1737         } else {
1738             requested.width
1739         };
1740         Rect { width: final_w, ..requested }
1741     }
1742 
1743     /// The legacy `set_row_rect` applied the same clamp to the row span.
1744     fn adjust_row_rect(&self, x: f32, w: f32) -> (f32, f32) {
1745         let final_w = if let Some(explicit_w) = self.width {
1746             explicit_w
1747         } else if let Some(max_w) = self.max_width {
1748             w.min(max_w)
1749         } else {
1750             w
1751         };
1752         (x, final_w)
1753     }
1754 
1755     fn rect_assigned(&mut self, rect: Rect) {
1756         self.rect = rect;
1757         self.clamp_scroll();
1758     }
1759 }
1760 
1761 impl Paint for TextBox {
1762     fn color(&self) -> [f32; 4] {
1763         [0.10, 0.10, 0.16, 1.0]
1764     }
1765 
1766     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
1767         let r = crate::layout::textbox_corner_radius();
1768         if r > 0.0 {
1769             Some((r, (true, true, true, true)))
1770         } else {
1771             Some((r, (false, false, false, false)))
1772         }
1773     }
1774 
1775     fn widget_font(&self) -> Option<String> {
1776         Some(crate::layout::control_label_font_detached())
1777     }
1778 
1779     /// Content text font for the paint walk: a TextBox whose `font_family`/`font_size` was
1780     /// deliberately customized (cce-text-editor's monospace editor) draws its value text in
1781     /// that family at the label's own size — a bare family name, so the control-font string's
1782     /// size suffix doesn't override `font_size`. Default boxes keep the `widget_font` string
1783     /// verbatim (the legacy convention, size suffix included).
1784     fn text_font(&self) -> Option<String> {
1785         if self.font_family != self.default_font_family || self.font_size != self.default_font_size {
1786             Some(self.font_family.clone())
1787         } else {
1788             self.widget_font()
1789         }
1790     }
1791 
1792     fn text_attrs(&self) -> crate::scene::paint::TextAttrs {
1793         self.font_attrs
1794     }
1795 
1796     fn sync_label(&mut self, label: &str) {
1797         self.label = Some(label.to_string());
1798     }
1799 
1800     fn text_bounds(&self, rect: Rect) -> Option<[f32; 4]> {
1801         Some(self.text_clip(rect))
1802     }
1803 
1804     /// The legacy `prepare_text`: sync font family/size with the live config defaults, then
1805     /// shape the display text and record per-glyph advances (`map_x_to_idx` reads them).
1806     fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
1807         // The input method's composition, shown before the buffer is shaped.
1808         self.sync_preedit();
1809         let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
1810         if self.font_size == self.default_font_size {
1811             self.font_size = style_size;
1812         }
1813         self.default_font_size = style_size;
1814 
1815         if self.font_family == self.default_font_family {
1816             self.font_family = style_family.clone();
1817         }
1818         self.default_font_family = style_family;
1819 
1820         let text_src = if self.editing { &self.edit_buffer } else { &self.text };
1821         let showing_placeholder = text_src.is_empty() && self.placeholder.is_some();
1822         let display_text = if showing_placeholder {
1823             self.placeholder.as_ref().unwrap().as_str()
1824         } else {
1825             text_src.as_str()
1826         };
1827 
1828         // Measured in the family the value text is DRAWN in — see `value_font`.
1829         let font_fam = self.value_font();
1830         let scale = crate::scale::scale_factor().max(1.0);
1831 
1832         // One column's advance, from the same shaping path as the labels (buffer-cached,
1833         // so this is a lookup after the first frame per family/size).
1834         let probe = crate::backend::text::shared_text_buffer(
1835             fs,
1836             "MMMMMMMM",
1837             self.font_size,
1838             font_fam.as_deref(),
1839             self.font_attrs,
1840         );
1841         self.shaped_char_advance = probe
1842             .layout_runs()
1843             .next()
1844             .and_then(|run| run.glyphs.last().map(|g| (g.x + g.w) / scale / 8.0))
1845             .unwrap_or(0.0);
1846 
1847         // `char_width()` returns this frame's shaped advance from here on, so the
1848         // wrap below matches the one `selection_quads`/`value_labels` compute at
1849         // paint time.
1850         let wrap = self.multiline.then(|| self.wrap_width(self.rect.width).to_bits());
1851 
1852         let key = PrepKey {
1853             text: display_text.to_string(),
1854             placeholder: showing_placeholder,
1855             password: self.is_password,
1856             font_size_bits: self.font_size.to_bits(),
1857             font: font_fam.clone(),
1858             attrs: self.font_attrs,
1859             scale_bits: scale.to_bits(),
1860             vertical: crate::backend::text::vertical_text().is_some(),
1861             wrap,
1862             room_bits: (self.rect.width - 2.0 * self.pad()).max(0.0).to_bits(),
1863         };
1864         if self.prep_key.as_ref() != Some(&key) {
1865             self.shape_columns(fs, &key);
1866             self.prep_key = Some(key);
1867         }
1868 
1869         let cursor_pos = self.cursor_idx.min(self.glyph_positions.len().saturating_sub(1));
1870         self.cursor_x_offset = self.glyph_positions.get(cursor_pos).copied().unwrap_or(0.0);
1871     }
1872 
1873     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
1874         let pad = self.pad();
1875         let top = self.label_top();
1876         let base_y = rect.y - top;
1877         let base_h = rect.height + top;
1878         let visual_h = rect.height;
1879         let radius = crate::layout::textbox_corner_radius();
1880         let border_w = self.border_width();
1881 
1882         // Open for typing: say so to the compositor this frame (the
1883         // on-screen keyboard follows it). The field stands in for the caret
1884         // until the caret is drawn below, which reports itself.
1885         if self.editing && !self.disabled {
1886             let (ox, oy) = ctx.offset();
1887             crate::text_input::claim(self.rect.x + ox, self.rect.y + top + oy, self.rect.width, visual_h);
1888         }
1889 
1890         // Keep the model's cached rect and the paint rect consistent: paint receives the
1891         // content rect derived from the same base the cache holds, so the bodies below read
1892         // `self.rect` (the legacy `self.base`) exactly as legacy did. `rect` is used only to
1893         // localize this frame's geometry.
1894         let _ = (base_y, base_h);
1895 
1896         if radius <= 0.0 {
1897             // Legacy `extra_quads`: full base span, disabled
1898             // special-case with early return.
1899             let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
1900             if self.disabled {
1901                 if self.draw_bg_border {
1902                     quads.push((self.rect.x, self.rect.y + top, self.rect.width, visual_h, [0.12, 0.12, 0.16, 1.0]));
1903                     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]));
1904                 }
1905             } else {
1906                 if self.draw_bg_border {
1907                     // One background regardless of focus — the focus treatment is
1908                     // the tinted recess rim (rounded path) / editing border, not a
1909                     // surface swap.
1910                     let bg_color = crate::colors::textbox_background_color();
1911                     let border_color = crate::colors::well_frame_color(self.hovered, self.editing);
1912                     quads.push((self.rect.x, self.rect.y + top, self.rect.width, visual_h, border_color));
1913                     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));
1914                 }
1915                 if let Some([cx, cy, cw, ch]) = self.selection_quads(self.rect.x, self.rect.width, &mut quads) {
1916                     let (ox, oy) = ctx.offset();
1917                     crate::ime::report_caret(cx + ox, cy + oy, cw, ch);
1918                 }
1919             }
1920             for (qx, qy, qw, qh, qc) in quads {
1921                 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1922             }
1923         } else {
1924             // Legacy `all_rounded_quads`: no disabled special-case.
1925             let x = self.rect.x;
1926             let w = self.rect.width;
1927 
1928             // One background regardless of focus (see the flat path above).
1929             let bg_color = crate::colors::textbox_background_color();
1930             let border_color = crate::colors::well_frame_color(self.hovered, self.editing);
1931 
1932             if self.draw_bg_border {
1933                 let corners = (true, true, true, true);
1934                 // Recessed + transparent fill: the carve alone defines the
1935                 // well — the plate below is its floor, so the flat border and
1936                 // bg rects are skipped entirely. An opaque fill (e.g. the edit
1937                 // color while editing) draws as usual and gets carved.
1938                 let bare = self.recessed() && bg_color[3] <= 0.001;
1939                 if !bare {
1940                     ctx.rounded_rect(Rect { x, y: self.rect.y + top, width: w, height: visual_h }, radius, corners, border_color);
1941                     ctx.rounded_rect(
1942                         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 },
1943                         (radius - border_w).max(0.0),
1944                         corners,
1945                         bg_color,
1946                     );
1947                 }
1948                 if let Some(field) = self.well() {
1949                     // Focus lights the well's rim in the highlight accent (with
1950                     // the shader's complementary shadow) — the TreeList treatment.
1951                     ctx.field(&field);
1952                 }
1953             }
1954 
1955             let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
1956             if let Some([cx, cy, cw, ch]) = self.selection_quads(x, w, &mut quads) {
1957                 let (ox, oy) = ctx.offset();
1958                 crate::ime::report_caret(cx + ox, cy + oy, cw, ch);
1959             }
1960             for (qx, qy, qw, qh, qc) in quads {
1961                 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1962             }
1963 
1964             // Relief scrollbar for overflowing multiline content — the shared
1965             // groove + raised-pill painter (the TreeList treatment), persistent
1966             // rather than activity-faded: an editor keeps its position
1967             // indicator. Content height mirrors clamp_scroll's math (`pad`
1968             // top and bottom), so the thumb tracks the scroll range exactly.
1969             if self.multiline {
1970                 let line_height = self.line_height();
1971                 let content_h = self.wrap_text(self.wrap_width(self.rect.width)).0.len() as f32 * line_height;
1972                 crate::widget::container::scroll_box::paint_relief_scrollbar(
1973                     ctx,
1974                     Rect { x, y: self.rect.y + top, width: w, height: visual_h },
1975                     content_h + 2.0 * pad,
1976                     self.scroll_y,
1977                 );
1978             }
1979         }
1980 
1981         // The content is whatever has been typed, so a line longer than the
1982         // well is routine rather than exceptional; the well scrolls, but
1983         // nothing stopped the glyphs drawing outside it. (The adapter's label
1984         // bridge swaps this for `text_bounds` — the same clip.)
1985         let well = Some(self.text_clip(Rect { x: self.rect.x, y: self.rect.y + top, width: self.rect.width, height: visual_h }));
1986         let font = self.value_font();
1987         for tl in self.value_labels() {
1988             ctx.text_with(tl.text, tl.x, tl.y, tl.font_size, tl.color, font.clone(), well);
1989         }
1990     }
1991 }
1992 
1993 impl Input for TextBox {
1994     fn focus_role(&self) -> crate::widget::FocusRole {
1995         crate::widget::FocusRole::Well
1996     }
1997 
1998     /// A multi-line box that is editing types Tab, as an editor does; a one-line field lets
1999     /// the Tab walk take it (Tab leaves a field).
2000     fn keeps_tab(&self) -> bool {
2001         self.multiline && self.editing
2002     }
2003     /// Advances the wheel glide / trackpad coast behind the scroll offsets.
2004     /// Cheap when idle (the common case); `wants_tick` is unconditional
2005     /// because it is sampled once at registration.
2006     fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
2007         self.scroll_motion.reconcile(self.scroll_x, self.scroll_y);
2008         if !self.scroll_motion.is_animating() {
2009             return false;
2010         }
2011         let (mx, my) = self.scroll_max;
2012         let moved = self.scroll_motion.tick(dt, Bounds::max(mx), Bounds::max(my));
2013         self.scroll_x = self.scroll_motion.x.pos();
2014         self.scroll_y = self.scroll_motion.y.pos();
2015         moved || self.scroll_motion.is_animating()
2016     }
2017 
2018     fn wants_tick(&self) -> bool {
2019         true
2020     }
2021 
2022     fn tracks_base_focus(&self) -> bool {
2023         false
2024     }
2025 
2026     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
2027         // A press moves the caret: a composition in progress is left where
2028         // it was, cancelled.
2029         if let Event::MouseButton { state: ElementState::Pressed, .. } = event {
2030             self.abandon_composition();
2031         }
2032         match event {
2033             Event::MouseButton { button: MouseButton::Right, state: ElementState::Pressed, x: px, y: py, .. } => {
2034                 if self.disabled { return false; }
2035                 // Adapter hit-gates presses; legacy focused an un-editing box before opening
2036                 // the menu (work-before-menu, so `opens_context_menu` can't express it).
2037                 if !self.editing {
2038                     self.begin_editing();
2039                     ectx.request_focus();
2040                 }
2041                 ectx.open_context_menu(*px, *py);
2042                 true
2043             }
2044             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, .. } => {
2045                 if self.disabled { return false; }
2046                 // A click aims into the field: it places the caret where it landed,
2047                 // whether or not it is the click that focuses. Only KEYBOARD focus
2048                 // (`Event::FocusIn` -> `begin_editing`) arms select-all. That split is
2049                 // the convention everywhere — tabbing selects a field, clicking points
2050                 // into it — and it is what a prefilled box needs: select-all on the
2051                 // focusing click meant the first keystroke wiped the whole value, which
2052                 // is wrong for the ~26 prefilled single-line boxes across the fleet
2053                 // (login username, reply subject, a unit's ExecStart, a config value).
2054                 // This used to be carved out for multiline only; both arms now agree.
2055                 let focusing = !self.editing;
2056                 if focusing {
2057                     self.begin_editing();
2058                     ectx.request_focus();
2059                 }
2060                 let idx = self.position_to_idx(*px, *py);
2061                 self.cursor_idx = idx;
2062                 self.select_anchor = Some(idx);
2063                 self.all_selected = false;
2064                 if focusing {
2065                     self.sync_editor_state();
2066                 }
2067                 true
2068             }
2069             Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x: px, y: py, .. } => {
2070                 if self.disabled { return false; }
2071                 // Legacy gated releases on the hit test; the adapter delivers them ungated, so
2072                 // re-check containment (the plain block rect — row spans approximated).
2073                 let (bx, by, bw, bh) = (self.rect.x, self.rect.y, self.rect.width, self.rect.height);
2074                 if !(*px >= bx && *px <= bx + bw && *py >= by && *py <= by + bh) {
2075                     return false;
2076                 }
2077                 if self.dragging {
2078                     self.dragging = false;
2079                 }
2080                 if self.select_anchor == Some(self.cursor_idx) {
2081                     self.select_anchor = None;
2082                 }
2083                 true
2084             }
2085             Event::PointerMove { x: px, y: py, .. } => {
2086                 // The drag-selection half of the legacy `on_cursor_moved`; hover bookkeeping
2087                 // is the adapter's (Enter/Leave below).
2088                 if self.disabled {
2089                     return false;
2090                 }
2091                 if self.dragging && self.editing {
2092                     return self.extend_selection_to(*px, *py);
2093                 }
2094                 false
2095             }
2096             Event::MouseEnter => {
2097                 self.hovered = !self.disabled;
2098                 true
2099             }
2100             Event::MouseLeave => {
2101                 self.hovered = false;
2102                 true
2103             }
2104             Event::MouseWheel { delta, .. } => self.handle_wheel(delta),
2105             Event::KeyInput(key_event) => self.handle_key(key_event),
2106             Event::FocusIn => {
2107                 // The legacy `focus()`: enter editing and claim the global slot (unless
2108                 // disabled — legacy early-returned before `set_focused`). Skipped when
2109                 // already editing: a press-then-set_focused sequence must not re-arm
2110                 // select-all over the caret the press just placed.
2111                 if !self.disabled && !self.editing {
2112                     self.begin_editing();
2113                     ectx.request_focus();
2114                 }
2115                 false
2116             }
2117             Event::FocusOut => {
2118                 self.commit_editing();
2119                 false
2120             }
2121             _ => false,
2122         }
2123     }
2124 
2125     fn take_change(&mut self) -> bool {
2126         self.take_change()
2127     }
2128 
2129     fn value_string(&self) -> Option<String> {
2130         Some(self.text.clone())
2131     }
2132 
2133     fn set_value_string(&mut self, val: &str) -> bool {
2134         self.set_value(val)
2135     }
2136 
2137     fn a11y_text(&self) -> Option<crate::a11y::A11yText> {
2138         // What the box holds, never an input method's provisional run; a password as bullets.
2139         let held = if self.editing { self.committed_buffer() } else { self.text.clone() };
2140         let len = held.chars().count();
2141         let text = if self.is_password { "\u{2022}".repeat(len) } else { held };
2142         // The caret and anchor are indices into what is shown; a composition sits at the
2143         // caret, so past its start they come back to it.
2144         let held_idx = |i: usize| {
2145             let i = match self.composing {
2146                 Some((start, n)) if i > start => i.saturating_sub(n).max(start),
2147                 _ => i,
2148             };
2149             i.min(len)
2150         };
2151         let selection = self.editing.then(|| {
2152             let focus = held_idx(self.cursor_idx);
2153             (self.select_anchor.map_or(focus, held_idx), focus)
2154         });
2155         Some(crate::a11y::A11yText {
2156             text,
2157             selection,
2158             multiline: self.multiline,
2159             password: self.is_password,
2160             editable: !self.disabled,
2161             placeholder: self.placeholder.clone(),
2162             kind: None,
2163         })
2164     }
2165 
2166     fn a11y_set_text(&mut self, text: &str) -> bool {
2167         if self.disabled {
2168             return false;
2169         }
2170         self.abandon_composition();
2171         let held = if self.editing { self.committed_buffer() } else { self.text.clone() };
2172         if held == text {
2173             return false;
2174         }
2175         if self.editing {
2176             let before = self.snapshot();
2177             self.history.record(before);
2178         } else {
2179             self.history.clear();
2180         }
2181         self.text = text.to_string();
2182         self.edit_buffer = text.to_string();
2183         self.just_changed = true;
2184         self.cursor_idx = text.chars().count();
2185         self.select_anchor = None;
2186         self.all_selected = false;
2187         self.sync_editor_state();
2188         self.clamp_scroll();
2189         if self.editing {
2190             self.scroll_to_cursor();
2191         }
2192         true
2193     }
2194 
2195     fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
2196         use crate::widget::ContextAction as CA;
2197         match action {
2198             CA::Cut => {
2199                 let res = self.cut_selection();
2200                 if res {
2201                     self.just_changed = true;
2202                 }
2203                 res
2204             }
2205             CA::Copy => {
2206                 self.copy_selection();
2207                 true
2208             }
2209             CA::Paste => {
2210                 let res = self.paste_from_clipboard();
2211                 if res {
2212                     self.just_changed = true;
2213                 }
2214                 res
2215             }
2216             CA::SelectAll => {
2217                 self.select_all();
2218                 true
2219             }
2220             CA::ClearText => {
2221                 self.set_value("");
2222                 true
2223             }
2224             CA::Undo => self.undo_edit(),
2225             CA::Redo => self.redo_edit(),
2226             _ => false,
2227         }
2228     }
2229 
2230     fn draggable(&self, _rect: Rect) -> bool {
2231         !self.disabled
2232     }
2233 
2234     fn is_dragging(&self) -> bool {
2235         self.dragging
2236     }
2237 
2238     fn drag_begin(&mut self, _px: f32, _py: f32, _rect: Rect) {
2239         if self.disabled || !self.editing { return; }
2240         self.dragging = true;
2241     }
2242 
2243     fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
2244         if self.disabled || !self.editing { return false; }
2245         self.extend_selection_to(px, py)
2246     }
2247 
2248     fn drag_end(&mut self) {
2249         self.dragging = false;
2250     }
2251 }
2252 
2253 /// Legacy `Default` (an empty box) — settings' accounts page derives `Default` over fields of
2254 /// this type.
2255 impl Default for Adapted<TextBox> {
2256     fn default() -> Self {
2257         TextBox::new(String::new())
2258     }
2259 }
2260 
2261 unsafe impl Send for TextBox {}
2262 unsafe impl Sync for TextBox {}
2263 
2264 
2265 #[cfg(test)]
2266 mod tests {
2267     use super::*;
2268 
2269     /// The multiline caret/click math reads shaped per-line offsets; a caret
2270     /// must land exactly where it is drawn, on every column of every line.
2271     /// A right-to-left word in a one-line box is set against the box's right edge, and
2272     /// edited where it is drawn: the caret before each letter stands at its right edge, so
2273     /// the offsets fall from the right end to the word's left; a click at the right end is
2274     /// the start, at the word's left the end; the drawn text starts where the offsets do.
2275     /// A selection from inside English into Hebrew is two pieces. The bidi levels come from
2276     /// the text, so this holds whatever face draws the letters.
2277     #[test]
2278     fn a_right_to_left_word_is_edited_where_it_is_drawn() {
2279         let mut fs = crate::create_font_system();
2280         let mut tb = TextBox::new("שלום".to_string());
2281         tb.set_rect(10.0, 10.0, 300.0, 30.0);
2282         tb.prepare_text(&mut fs);
2283         let offs = tb.glyph_positions.clone();
2284         assert_eq!(offs.len(), 5, "four letters and the end");
2285         if tb.total_text_width == 0.0 {
2286             return; // nothing shaped (no fonts at all): nothing to check
2287         }
2288         let pad = tb.inner().pad();
2289         let (left, room) = (10.0 + pad, 300.0 - 2.0 * pad);
2290         assert!(offs.windows(2).all(|w| w[1] < w[0]), "carets fall right to left: {offs:?}");
2291         assert!((offs[0] - room).abs() < 0.01, "set against the right edge: {offs:?}");
2292         assert!((offs[4] - (room - tb.total_text_width)).abs() < 0.01, "{offs:?}");
2293         assert_eq!(tb.inner().map_x_to_idx(left + room), 0, "the right end is the start");
2294         assert_eq!(tb.inner().map_x_to_idx(left + offs[4]), 4, "the word's left is the end");
2295         let label = &tb.inner().value_labels()[0];
2296         assert!((label.x - (left + offs[4])).abs() < 0.01, "drawn where the offsets are: {} vs {}", label.x, left + offs[4]);
2297 
2298         // English then Hebrew: a left-to-right line, as it was; a selection from the "b" to
2299         // the Hebrew word's first letter is the b and that letter at the word's far right.
2300         let mut tb = TextBox::new("ab שלום".to_string());
2301         tb.set_rect(10.0, 10.0, 300.0, 30.0);
2302         tb.inner_mut().editing = true;
2303         tb.inner_mut().edit_buffer = "ab שלום".to_string();
2304         tb.prepare_text(&mut fs);
2305         assert!(tb.glyph_positions[0].abs() < 0.01, "a left-to-right line starts at the left");
2306         tb.inner_mut().select_anchor = Some(1);
2307         tb.inner_mut().cursor_idx = 4;
2308         let mut quads = Vec::new();
2309         tb.inner().selection_quads(10.0, 300.0, &mut quads);
2310         let sel: Vec<_> = quads.iter().filter(|q| q.2 > 2.0).collect();
2311         assert_eq!(sel.len(), 2, "two pieces: {sel:?}");
2312     }
2313 
2314     /// In a multiline box a Hebrew paragraph's lines are set against the right edge and an
2315     /// English paragraph's are not; the drawn lines carry the same shift as the offsets.
2316     #[test]
2317     fn a_right_to_left_paragraph_is_set_against_the_right() {
2318         let mut fs = crate::create_font_system();
2319         let mut tb = TextBox::new("hello there\nשלום עולם".to_string()).with_multiline(true).with_line_wrap(true);
2320         tb.set_rect(10.0, 10.0, 300.0, 200.0);
2321         tb.prepare_text(&mut fs);
2322         let room = 300.0 - 2.0 * tb.inner().pad();
2323         let lines = tb.line_glyph_positions.clone();
2324         assert_eq!(lines.len(), 2);
2325         if lines[1].iter().all(|x| *x == 0.0) {
2326             return; // nothing shaped
2327         }
2328         assert!(lines[0][0].abs() < 0.01, "English starts at the left");
2329         let widest = lines[1].iter().copied().fold(0.0f32, f32::max);
2330         assert!((widest - room).abs() < 0.01, "Hebrew ends at the right: {:?}", lines[1]);
2331         let labels = tb.inner().value_labels();
2332         assert!(labels[1].x > labels[0].x + 50.0, "the Hebrew line is drawn shifted: {} vs {}", labels[1].x, labels[0].x);
2333     }
2334 
2335     /// A one-line box shows the START of an overflowing line: the left end of English, the
2336     /// right end of Hebrew, whose characters run from the right. Editing follows the caret.
2337     #[test]
2338     fn an_overflowing_right_to_left_line_shows_its_start() {
2339         let mut fs = crate::create_font_system();
2340         let long = "שלום עולם ".repeat(8);
2341         let mut tb = TextBox::new(long).with_multiline(false);
2342         tb.set_rect(10.0, 10.0, 120.0, 28.0);
2343         tb.prepare_text(&mut fs);
2344         let room = 120.0 - 2.0 * tb.inner().pad();
2345         let total = tb.inner().total_text_width;
2346         if total <= room {
2347             return; // nothing shaped
2348         }
2349         assert!((tb.inner().scroll_x - (total - room)).abs() < 0.01, "scrolled to the right end: {} of {}", tb.inner().scroll_x, total - room);
2350         // The first character's stop lies in view.
2351         let first = tb.inner().glyph_positions[0];
2352         let x = first - tb.inner().scroll_x;
2353         assert!((0.0..=room + 0.5).contains(&x), "the line's first character is shown: {x}");
2354 
2355         let mut english = TextBox::new("hello there ".repeat(8)).with_multiline(false);
2356         english.set_rect(10.0, 10.0, 120.0, 28.0);
2357         english.prepare_text(&mut fs);
2358         assert_eq!(english.inner().scroll_x, 0.0, "English shows its left end");
2359     }
2360 
2361     /// A multiline box wraps by the shaped width of what it holds, not a count of chars:
2362     /// every line fits the box (a hanging space aside) and none breaks early — the next
2363     /// line's first word would not have fitted on it. Text of mixed widths (narrow, wide,
2364     /// CJK) is where a column count and the drawn width disagree.
2365     #[test]
2366     fn a_multiline_box_wraps_where_its_text_is_wide() {
2367         let text = "iiii WWWW lll MMM 漢字漢字 iii WW 漢字漢字漢字 il WM 漢字".repeat(3);
2368         let mut tb = TextBox::new(text.clone()).with_multiline(true).with_line_wrap(true);
2369         crate::widget::WidgetHost::set_rect(&mut tb, 0.0, 0.0, 160.0, 400.0);
2370         let inner = tb.inner();
2371         let max_w = inner.wrap_width(160.0);
2372         let (lines, map) = inner.wrap_text(max_w);
2373         assert!(lines.len() > 2, "{lines:?}");
2374         let width = |s: &str| inner.char_advances(s).iter().sum::<f32>();
2375         for (i, line) in lines.iter().enumerate() {
2376             let shown = line.trim_end();
2377             assert!(width(shown) <= max_w + 0.5 || shown.chars().count() == 1, "line {i} {shown:?} is {} wide, past {max_w}", width(shown));
2378             if let Some(next) = lines.get(i + 1) {
2379                 let word: String = next.chars().take_while(|c| !c.is_whitespace()).collect();
2380                 if !word.is_empty() && line.ends_with(' ') {
2381                     assert!(width(&format!("{line}{word}")) > max_w + 0.5, "line {i} {line:?} broke before {word:?}, which fitted");
2382                 }
2383             }
2384         }
2385         let rejoined: String = lines.concat();
2386         assert_eq!(rejoined, text, "the lines are the text, cut");
2387         assert_eq!(map.len(), text.chars().count() + 1);
2388     }
2389 
2390     #[test]
2391     fn multiline_shaped_offsets_round_trip() {
2392         let mut fs = cosmic_text::FontSystem::new();
2393         let mut tb = TextBox::new("wim wim wim\niiii WWWW mm ii".to_string())
2394             .with_multiline(true)
2395             .with_line_wrap(false);
2396         tb.set_rect(10.0, 10.0, 300.0, 100.0);
2397         tb.prepare_text(&mut fs);
2398 
2399         assert_eq!(tb.line_glyph_positions.len(), 2, "one offset row per line");
2400         for line in &tb.line_glyph_positions {
2401             for w in line.windows(2) {
2402                 assert!(w[1] >= w[0], "offsets must be non-decreasing: {:?}", line);
2403             }
2404         }
2405 
2406         // Round-trip caret→pixel→column. Skipped in a font-less environment
2407         // (no glyphs shape, offsets stay zero — nothing to verify).
2408         for (l, line) in tb.line_glyph_positions.clone().iter().enumerate() {
2409             if line.last().copied().unwrap_or(0.0) == 0.0 {
2410                 continue;
2411             }
2412             for c in 0..line.len() {
2413                 assert_eq!(
2414                     tb.line_x_to_col(l, tb.line_col_x(l, c)),
2415                     c,
2416                     "line {l} col {c} must round-trip"
2417                 );
2418             }
2419         }
2420     }
2421 
2422     /// Single-line offsets must be non-decreasing for MULTI-WORD text. Guards
2423     /// the `normalized_glyph_starts` workaround for cosmic-text 0.12's
2424     /// `Shaping::Basic` bug (span-relative `LayoutGlyph::start`, resetting at
2425     /// every word): without it, each word's glyphs overwrite the low columns
2426     /// and a mid-text caret lands inside the wrong word.
2427     #[test]
2428     fn single_line_multi_word_offsets_monotonic() {
2429         let mut fs = cosmic_text::FontSystem::new();
2430         let mut tb = TextBox::new("wim wim wim".to_string());
2431         tb.set_rect(10.0, 10.0, 300.0, 30.0);
2432         tb.prepare_text(&mut fs);
2433 
2434         for w in tb.glyph_positions.windows(2) {
2435             assert!(w[1] >= w[0], "offsets must be non-decreasing: {:?}", tb.glyph_positions);
2436         }
2437         // In a font-bearing environment the mid-text columns are real advances:
2438         // strictly inside (0, total). Skipped font-less (all zeros).
2439         if tb.glyph_positions.last().copied().unwrap_or(0.0) > 0.0 {
2440             let total = *tb.glyph_positions.last().unwrap();
2441             for (i, &x) in tb.glyph_positions.iter().enumerate().skip(1).take(tb.glyph_positions.len() - 2) {
2442                 assert!(x > 0.0 && x < total, "col {i} offset {x} must sit inside the text run");
2443             }
2444         }
2445     }
2446 
2447     /// Multi-byte text gets one offset per CHAR (not per byte), and the
2448     /// per-frame memo notices a change of text: the second shape must not
2449     /// serve the first one's offsets.
2450     #[test]
2451     fn offsets_count_chars_and_reshape_on_change() {
2452         let mut fs = cosmic_text::FontSystem::new();
2453         let mut tb = TextBox::new("héllo wörld".to_string());
2454         tb.set_rect(10.0, 10.0, 300.0, 30.0);
2455         tb.prepare_text(&mut fs);
2456         assert_eq!(tb.glyph_positions.len(), "héllo wörld".chars().count() + 1);
2457         for w in tb.glyph_positions.windows(2) {
2458             assert!(w[1] >= w[0], "offsets must be non-decreasing: {:?}", tb.glyph_positions);
2459         }
2460 
2461         let first = tb.glyph_positions.clone();
2462         tb.prepare_text(&mut fs);
2463         assert_eq!(tb.glyph_positions, first, "an unchanged box keeps its offsets");
2464 
2465         tb.text = "hé".to_string();
2466         tb.prepare_text(&mut fs);
2467         assert_eq!(tb.glyph_positions.len(), 3, "a changed text is reshaped");
2468     }
2469 
2470     #[test]
2471     fn test_textbox_selection_highlight() {
2472         let mut dummy = crate::context::UiContext::new();
2473         let mut tb = TextBox::new("Initial Text".to_string());
2474         tb.set_rect(10.0, 10.0, 200.0, 30.0);
2475 
2476         // 1. Initial state
2477         assert!(!tb.editing);
2478         assert!(!tb.all_selected);
2479 
2480         // 2. Keyboard focus triggers highlighting. (A CLICK deliberately does not
2481         //    — it places the caret; see `prefilled_single_line_click_does_not_wipe_the_value`.)
2482         tb.focus();
2483         assert!(tb.editing);
2484         assert!(tb.all_selected);
2485         assert_eq!(tb.edit_buffer, "Initial Text");
2486 
2487         // 3. Typing a key replaces all text
2488         let key_ev = KeyEvent {
2489             state: ElementState::Pressed,
2490             logical_key: Key::Character("A".to_string()),
2491             text: Some("A".to_string()),
2492             repeat: false,
2493             ctrl: false,
2494             shift: false,
2495             alt: false,
2496         };
2497         let handled = tb.keyboard_input(&key_ev, &mut dummy);
2498         assert!(handled);
2499         assert!(!tb.all_selected);
2500         assert_eq!(tb.edit_buffer, "A");
2501 
2502         // 4. Pressing Enter commits change
2503         let enter_ev = KeyEvent {
2504             state: ElementState::Pressed,
2505             logical_key: Key::Named(NamedKey::Enter),
2506             text: None,
2507             repeat: false,
2508             ctrl: false,
2509             shift: false,
2510             alt: false,
2511         };
2512         let handled_enter = tb.keyboard_input(&enter_ev, &mut dummy);
2513         assert!(handled_enter);
2514         assert!(!tb.editing);
2515         assert_eq!(tb.text, "A");
2516         assert!(tb.take_change());
2517     }
2518 
2519     /// The other half of the click change: keyboard focus must still arm
2520     /// select-all, so tabbing into a field and typing replaces it. Losing this
2521     /// would make every form field tedious to retype.
2522     #[test]
2523     fn keyboard_focus_still_selects_all() {
2524         let mut tb = TextBox::new("imap.example.org:993".to_string());
2525         tb.set_rect(10.0, 10.0, 200.0, 30.0);
2526 
2527         tb.focus();
2528 
2529         assert!(tb.editing);
2530         assert!(tb.all_selected, "tabbing in selects the whole value");
2531         assert_eq!(tb.select_anchor, Some(0));
2532         assert_eq!(tb.cursor_idx, "imap.example.org:993".chars().count());
2533     }
2534 
2535     /// A prefilled single-line box is the case that motivated the change: the
2536     /// focusing click must leave the value intact so the first keystroke edits
2537     /// rather than erases. Same guarantee the multiline test below asserts.
2538     #[test]
2539     fn prefilled_single_line_click_does_not_wipe_the_value() {
2540         let mut dummy = crate::context::UiContext::new();
2541         let mut tb = TextBox::new("imap.example.org:993".to_string());
2542         tb.set_rect(10.0, 10.0, 300.0, 30.0);
2543 
2544         let click_x = 10.0 + 8.0 + 4.0 * tb.char_width();
2545         assert!(tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x, 20.0, &mut dummy));
2546         assert!(tb.editing);
2547         assert!(!tb.all_selected);
2548 
2549         let key_ev = KeyEvent {
2550             state: ElementState::Pressed,
2551             logical_key: Key::Character("X".to_string()),
2552             text: Some("X".to_string()),
2553             repeat: false,
2554             ctrl: false,
2555             shift: false,
2556             alt: false,
2557         };
2558         assert!(tb.keyboard_input(&key_ev, &mut dummy));
2559         assert_eq!(
2560             tb.edit_buffer.chars().count(),
2561             21,
2562             "typing must insert into the prefilled value, not replace it"
2563         );
2564         assert!(tb.edit_buffer.starts_with("imap"), "the existing value survives the first keystroke");
2565     }
2566 
2567     /// An input method's composition is a provisional run in the buffer: shown
2568     /// at the caret with the caret where the input method has it, never held
2569     /// (`committed_buffer`, `take_change`), replaced by the commit, which is
2570     /// typed — and dropped, the input method told, when editing ends under it.
2571     #[test]
2572     fn a_composition_is_shown_in_place_and_the_commit_is_typed() {
2573         use crate::ime::{self, Preedit};
2574         let mut dummy = crate::context::UiContext::new();
2575         let mut tb = TextBox::new("ab".to_string()).with_update_on_type(true);
2576         tb.set_rect(10.0, 10.0, 300.0, 30.0);
2577         tb.focus();
2578         tb.cursor_idx = 1;
2579         tb.select_anchor = None;
2580         tb.all_selected = false;
2581         let _ = ime::take_reset();
2582 
2583         // "にほ", the input method's cursor after "に".
2584         ime::set_preedit(Some(Preedit::new("にほ", Some((0, 3)))));
2585         tb.sync_preedit();
2586         assert_eq!(tb.edit_buffer, "aにほb");
2587         assert_eq!(tb.composing, Some((1, 2)));
2588         assert_eq!(tb.cursor_idx, 2);
2589         assert_eq!(tb.committed_buffer(), "ab");
2590         assert!(!tb.take_change(), "a composition is not a change");
2591         // A key the input method lets through mid-composition is not typed.
2592         let typed = |t: &str| KeyEvent {
2593             state: ElementState::Pressed,
2594             logical_key: Key::Character(t.to_string()),
2595             text: Some(t.to_string()),
2596             repeat: false,
2597             ctrl: false,
2598             shift: false,
2599             alt: false,
2600         };
2601         assert!(tb.keyboard_input(&typed("x"), &mut dummy));
2602         assert_eq!(tb.edit_buffer, "aにほb");
2603 
2604         // The commit: the composition ends, then its text is typed.
2605         ime::set_preedit(None);
2606         assert!(tb.keyboard_input(&typed("日本"), &mut dummy));
2607         assert_eq!(tb.edit_buffer, "a日本b");
2608         assert_eq!((tb.composing, tb.cursor_idx), (None, 3));
2609         assert!(tb.take_change());
2610         assert_eq!(tb.text, "a日本b");
2611 
2612         // Editing ends mid-composition: the run goes, the input method is
2613         // told, and the text is what was held.
2614         ime::set_preedit(Some(Preedit::new("か", None)));
2615         tb.sync_preedit();
2616         assert_eq!(tb.edit_buffer, "a日本かb");
2617         tb.commit_editing();
2618         assert_eq!(tb.text, "a日本b");
2619         assert!(ime::take_reset());
2620         assert_eq!(ime::preedit(), None);
2621     }
2622 
2623     /// Undo/redo over an editing session: a typed word is one step, a
2624     /// space starts the next, a cursor move splits a run, Backspace runs
2625     /// coalesce, redo walks forward, a fresh keystroke after an undo forks,
2626     /// and the chord reaches the box both as a `ContextAction` (the runner's
2627     /// route) and as a raw key (the no-context fallback).
2628     #[test]
2629     fn typing_undoes_by_run() {
2630         let mut dummy = crate::context::UiContext::new();
2631         let mut tb = TextBox::new(String::new());
2632         tb.set_rect(10.0, 10.0, 300.0, 30.0);
2633         tb.focus();
2634         assert!(tb.editing);
2635         assert!(!tb.undo_edit(), "a fresh session has nothing to undo");
2636 
2637         let key = |k: &str, ctrl: bool, shift: bool| KeyEvent {
2638             state: ElementState::Pressed,
2639             logical_key: Key::Character(k.to_string()),
2640             text: if ctrl { None } else { Some(k.to_string()) },
2641             repeat: false,
2642             ctrl,
2643             shift,
2644             alt: false,
2645         };
2646         let named = |n: NamedKey| KeyEvent {
2647             state: ElementState::Pressed,
2648             logical_key: Key::Named(n),
2649             text: None,
2650             repeat: false,
2651             ctrl: false,
2652             shift: false,
2653             alt: false,
2654         };
2655         let type_str = |tb: &mut Adapted<TextBox>, dummy: &mut crate::context::UiContext, s: &str| {
2656             for ch in s.chars() {
2657                 assert!(tb.keyboard_input(&key(&ch.to_string(), false, false), dummy));
2658             }
2659         };
2660 
2661         type_str(&mut tb, &mut dummy, "hello world");
2662         assert_eq!(tb.edit_buffer, "hello world");
2663         assert_eq!(tb.history.undo_len(), 3, "'hello', ' ', 'world'");
2664 
2665         // A cursor move splits the next run off.
2666         assert!(tb.keyboard_input(&named(NamedKey::ArrowLeft), &mut dummy));
2667         type_str(&mut tb, &mut dummy, "XY");
2668         assert_eq!(tb.edit_buffer, "hello worlXYd");
2669         assert_eq!(tb.history.undo_len(), 4);
2670 
2671         // Backspaces coalesce into one step.
2672         assert!(tb.keyboard_input(&named(NamedKey::Backspace), &mut dummy));
2673         assert!(tb.keyboard_input(&named(NamedKey::Backspace), &mut dummy));
2674         assert_eq!(tb.edit_buffer, "hello world");
2675         assert_eq!(tb.history.undo_len(), 5);
2676 
2677         // Undo through the ContextAction route, then the raw-chord route.
2678         assert!(crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::Undo));
2679         assert_eq!(tb.edit_buffer, "hello worlXYd");
2680         assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2681         assert_eq!(tb.edit_buffer, "hello world");
2682         assert!(tb.keyboard_input(&key("Z", true, true), &mut dummy), "redo via ctrl+shift+z");
2683         assert_eq!(tb.edit_buffer, "hello worlXYd");
2684         assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2685         assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2686         assert_eq!(tb.edit_buffer, "hello ");
2687         assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2688         assert_eq!(tb.edit_buffer, "hello");
2689         assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2690         assert_eq!(tb.edit_buffer, "");
2691         assert!(!tb.undo_edit(), "history exhausted");
2692 
2693         // Redo forward one, then a fresh keystroke forks the branch.
2694         assert!(crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::Redo));
2695         assert_eq!(tb.edit_buffer, "hello");
2696         type_str(&mut tb, &mut dummy, "!");
2697         assert_eq!(tb.edit_buffer, "hello!");
2698         assert!(!tb.redo_edit(), "a new edit after an undo drops the redo branch");
2699 
2700         // A committed value is not the box's to undo.
2701         tb.unfocus();
2702         assert!(!tb.editing);
2703         assert!(!crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::Undo));
2704     }
2705 
2706     #[test]
2707     fn empty_box_click_ignores_placeholder_glyphs() {
2708         let mut dummy = crate::context::UiContext::new();
2709         let mut tb = TextBox::new(String::new()).with_placeholder("Search...");
2710         tb.set_rect(10.0, 10.0, 200.0, 30.0);
2711 
2712         // `prepare_text` shapes the placeholder when the value is empty; the
2713         // caret math must still treat the box as zero-length. Simulate the
2714         // shaped placeholder ("Search...", 9 cols) directly so the test does
2715         // not depend on a font being present.
2716         tb.glyph_positions = (0..=9).map(|i| i as f32 * 7.0).collect();
2717 
2718         // Click deep into the painted placeholder.
2719         let click_x = 10.0 + 8.0 + 8.0 * 7.0;
2720         assert!(tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x, 20.0, &mut dummy));
2721         assert!(tb.editing);
2722         assert_eq!(tb.cursor_idx, 0, "empty box: the caret lands at the start, not on a placeholder column");
2723     }
2724 
2725     #[test]
2726     fn multiline_focus_click_places_caret_instead_of_select_all() {
2727         let mut dummy = crate::context::UiContext::new();
2728         let mut tb = TextBox::new("abcdef".to_string()).with_multiline(true);
2729         tb.set_rect(10.0, 10.0, 200.0, 100.0);
2730 
2731         // The focusing click must NOT arm select-all (a first keystroke would wipe
2732         // prefilled content, e.g. a reply quote) — it places the caret like any click.
2733         let clicked = tb.mouse_input(MouseButton::Left, ElementState::Pressed, 12.0, 20.0, &mut dummy);
2734         assert!(clicked);
2735         assert!(tb.editing);
2736         assert!(!tb.all_selected);
2737         let caret_after_click = tb.cursor_idx;
2738         let key_ev = KeyEvent {
2739             state: ElementState::Pressed,
2740             logical_key: Key::Character("X".to_string()),
2741             text: Some("X".to_string()),
2742             repeat: false,
2743             ctrl: false,
2744             shift: false,
2745             alt: false,
2746         };
2747         let handled = tb.keyboard_input(&key_ev, &mut dummy);
2748         assert!(handled);
2749         assert_eq!(tb.edit_buffer.chars().count(), 7, "typing must insert, not replace the buffer");
2750         assert!(tb.edit_buffer.contains("X"));
2751         assert_eq!(tb.cursor_idx, caret_after_click + 1);
2752     }
2753 
2754     #[test]
2755     fn test_textbox_drag_and_modifier_selection() {
2756         let mut dummy = crate::context::UiContext::new();
2757         let mut tb = TextBox::new("Hello World".to_string());
2758         tb.set_rect(10.0, 10.0, 200.0, 30.0);
2759 
2760         // 1. Initial click focuses and places the caret where it landed — it does
2761         //    NOT select all. Selecting all belongs to keyboard focus (see
2762         //    `keyboard_focus_still_selects_all`).
2763         let click_x3 = 10.0 + 8.0 + 3.0 * tb.char_width();
2764         let pressed = tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x3, 20.0, &mut dummy);
2765         assert!(pressed);
2766         let released = tb.mouse_input(MouseButton::Left, ElementState::Released, click_x3, 20.0, &mut dummy);
2767         assert!(released);
2768         assert!(tb.editing);
2769         assert!(!tb.all_selected);
2770         assert_eq!(tb.cursor_idx, 3);
2771         // The release collapses the zero-width selection the press opened.
2772         assert_eq!(tb.select_anchor, None);
2773 
2774         // 2. Click inside placed caret at index 5
2775         let click_x5 = 10.0 + 8.0 + 5.0 * tb.char_width();
2776         let pressed_inside = tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x5, 20.0, &mut dummy);
2777         assert!(pressed_inside);
2778         assert_eq!(tb.cursor_idx, 5);
2779         assert_eq!(tb.select_anchor, Some(5));
2780         assert!(!tb.all_selected);
2781 
2782         // 3. Drag to index 11
2783         let drag_x11 = 10.0 + 8.0 + 11.0 * tb.char_width();
2784         tb.drag_begin(click_x5, 20.0);
2785         let updated = tb.drag_update(drag_x11, 20.0);
2786         assert!(updated);
2787         assert_eq!(tb.cursor_idx, 11);
2788         assert_eq!(tb.select_anchor, Some(5));
2789         tb.drag_end();
2790 
2791         // 4. Keyboard ArrowLeft with Shift shrinks selection from 11 to 10
2792         let left_shift_ev = KeyEvent {
2793             state: ElementState::Pressed,
2794             logical_key: Key::Named(NamedKey::ArrowLeft),
2795             text: None,
2796             repeat: false,
2797             ctrl: false,
2798             shift: true,
2799             alt: false,
2800         };
2801         let handled = tb.keyboard_input(&left_shift_ev, &mut dummy);
2802         assert!(handled);
2803         assert_eq!(tb.cursor_idx, 10);
2804         assert_eq!(tb.select_anchor, Some(5));
2805 
2806         // 5. Keyboard ArrowLeft without Shift collapses selection to start (index 5)
2807         let left_ev = KeyEvent {
2808             state: ElementState::Pressed,
2809             logical_key: Key::Named(NamedKey::ArrowLeft),
2810             text: None,
2811             repeat: false,
2812             ctrl: false,
2813             shift: false,
2814             alt: false,
2815         };
2816         let handled = tb.keyboard_input(&left_ev, &mut dummy);
2817         assert!(handled);
2818         assert_eq!(tb.cursor_idx, 5);
2819         assert_eq!(tb.select_anchor, None);
2820 
2821         // 6. Keyboard Shift+Up highlights to beginning (cursor 0, anchor 5)
2822         let up_shift_ev = KeyEvent {
2823             state: ElementState::Pressed,
2824             logical_key: Key::Named(NamedKey::ArrowUp),
2825             text: None,
2826             repeat: false,
2827             ctrl: false,
2828             shift: true,
2829             alt: false,
2830         };
2831         let handled = tb.keyboard_input(&up_shift_ev, &mut dummy);
2832         assert!(handled);
2833         assert_eq!(tb.cursor_idx, 0);
2834         assert_eq!(tb.select_anchor, Some(5));
2835 
2836         // 7. Typing a key replaces selected range "Hello" with "Rust"
2837         let rust_ev = KeyEvent {
2838             state: ElementState::Pressed,
2839             logical_key: Key::Character("Rust".to_string()),
2840             text: Some("Rust".to_string()),
2841             repeat: false,
2842             ctrl: false,
2843             shift: false,
2844             alt: false,
2845         };
2846         let handled = tb.keyboard_input(&rust_ev, &mut dummy);
2847         assert!(handled);
2848         assert_eq!(tb.edit_buffer, "Rust World");
2849         assert_eq!(tb.cursor_idx, 4);
2850         assert_eq!(tb.select_anchor, None);
2851     }
2852 
2853     #[test]
2854     fn test_textbox_right_click_context_menu() {
2855         let mut dummy = crate::context::UiContext::new();
2856         let mut tb = TextBox::new("Context Menu Text".to_string());
2857         tb.set_rect(10.0, 10.0, 200.0, 30.0);
2858 
2859         // Hide context menu initially
2860         dummy.hide_context_menu();
2861         assert!(!dummy.is_context_menu_visible());
2862 
2863         // Right click on textbox
2864         let clicked = tb.mouse_input(MouseButton::Right, ElementState::Pressed, 50.0, 20.0, &mut dummy);
2865         assert!(clicked);
2866         assert!(dummy.is_context_menu_visible());
2867         assert!(tb.editing);
2868      }
2869 
2870     #[test]
2871     fn test_textbox_multiline_selection_highlight() {
2872         let mut tb = TextBox::new("Line 1\nLine 2\nLine 3".to_string()).with_multiline(true);
2873         tb.set_rect(10.0, 10.0, 200.0, 100.0);
2874         tb.select_anchor = Some(7); // starts at "Line 2"
2875         tb.cursor_idx = 13;        // ends at end of "Line 2"
2876 
2877         use crate::scene::paint::Prim;
2878         let highlight = [0.20, 0.50, 0.85, 0.3];
2879         let has_highlight = crate::widget::shown_prims(&tb).iter().any(|p| match p {
2880             Prim::Quad { color, .. } | Prim::RoundedRect { color, .. } => *color == highlight,
2881             _ => false,
2882         });
2883         assert!(has_highlight, "Should have a highlight quad!");
2884     }
2885 
2886     #[test]
2887     fn test_textbox_line_wrap_disabled_horizontal_scrolling() {
2888         let _dummy = crate::context::UiContext::new();
2889 
2890         // Wrap is stated on the widget (`line_wrap_override`), not on the
2891         // process-global `textbox_line_wrap` — this box is single-line, which
2892         // is already unwrapped, and the override says so whatever the config
2893         // does. The global would be visible to every other test in parallel.
2894         let mut tb = TextBox::new("Very long text that should not wrap and instead scroll horizontally".to_string())
2895             .with_line_wrap(false);
2896         tb.set_rect(10.0, 10.0, 100.0, 30.0);
2897         assert!(!tb.line_wrap_enabled());
2898 
2899         assert_eq!(tb.scroll_x, 0.0);
2900 
2901         tb.focus();
2902         tb.cursor_idx = tb.edit_buffer.chars().count();
2903         tb.scroll_to_cursor();
2904 
2905         assert!(tb.scroll_x > 0.0, "scroll_x should be scrolled horizontally to keep the cursor visible");
2906     }
2907 
2908     #[test]
2909     fn test_search_textbox_clear_option() {
2910         let mut dummy = crate::context::UiContext::new();
2911         let mut tb = TextBox::new("Some Search query".to_string()).with_placeholder("Search...");
2912         tb.set_rect(10.0, 10.0, 200.0, 30.0);
2913 
2914         // Right click on textbox
2915         let clicked = tb.mouse_input(MouseButton::Right, ElementState::Pressed, 50.0, 20.0, &mut dummy);
2916         assert!(clicked);
2917         assert!(dummy.is_context_menu_visible());
2918 
2919         // Verify "Clear" option is in options
2920         let opts = crate::widget::context_menu::options();
2921         assert!(opts.contains(&"Clear".to_string()));
2922 
2923         // Simulate choosing the "Clear" option
2924         crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::ClearText);
2925         assert_eq!(tb.text, "");
2926         assert_eq!(tb.edit_buffer, "");
2927     }
2928 
2929     /// A single-line box's border is always the hairline; a multiline box's
2930     /// is whatever `textbox_multiline_border_width` resolves to. Read, never
2931     /// written: that width is a process global and the suite runs in
2932     /// parallel, so setting it here would widen every other test's borders.
2933     #[test]
2934     fn test_multiline_textbox_border_width() {
2935         let _dummy = crate::context::UiContext::new();
2936         let tb_single = TextBox::new("Singleline".to_string()).with_multiline(false);
2937         let tb_multi = TextBox::new("Multiline".to_string()).with_multiline(true);
2938 
2939         let configured = crate::layout::textbox_multiline_border_width();
2940         assert_eq!(tb_single.border_width(), 1.0);
2941         assert_eq!(tb_multi.border_width(), configured);
2942     }
2943     /// A tall recessed box's wall is the full `bevel_width`, deeper than the
2944     /// old fixed 8px inset: its text starts on the well's floor, past the
2945     /// wall, not on it (cce-fonts' preview box drew its sample over its relief).
2946     #[test]
2947     fn tall_box_text_starts_past_its_relief_wall() {
2948         let _dummy = crate::context::UiContext::new();
2949         let mut tb = TextBox::new("The quick brown fox".to_string())
2950             .with_multiline(true)
2951             .with_recessed(true);
2952         tb.set_rect(10.0, 10.0, 400.0, 180.0);
2953         let field = tb.well().expect("a recessed box with rounded corners carves a well");
2954         let floor_x = field.rect.x + field.depth;
2955         let floor_y = field.rect.y + field.depth;
2956         let first = &tb.value_labels()[0];
2957         assert!(first.x >= floor_x, "text x {} is on the wall (floor at {})", first.x, floor_x);
2958         assert!(first.y >= floor_y, "text y {} is on the wall (floor at {})", first.y, floor_y);
2959     }
2960     /// A password box never hands its text to the clipboard -- not from a
2961     /// selection, not by Ctrl+X, not through the menu -- and its menu offers
2962     /// no Cut or Copy. Its Ctrl+X leaves the text in place.
2963     #[test]
2964     fn a_password_box_never_copies_or_cuts() {
2965         let mut dummy = crate::context::UiContext::new();
2966         let mut tb = TextBox::new(String::new()).with_password(true);
2967         tb.set_rect(10.0, 10.0, 200.0, 30.0);
2968         tb.focus();
2969         tb.edit_buffer = "hunter2".to_string();
2970         tb.cursor_idx = 7;
2971         tb.select_all();
2972         let state = TextEditorState {
2973             buffer: tb.edit_buffer.clone(),
2974             cursor_idx: tb.cursor_idx,
2975             select_anchor: tb.select_anchor,
2976             all_selected: tb.all_selected,
2977         };
2978         assert_eq!(state.selected_text().as_deref(), Some("hunter2"), "the selection is there");
2979         assert_eq!(tb.clipboard_text(&state), None, "but never for the clipboard");
2980 
2981         let ctrl_x = KeyEvent {
2982             state: ElementState::Pressed,
2983             logical_key: Key::Character("x".to_string()),
2984             text: None,
2985             repeat: false,
2986             ctrl: true,
2987             shift: false,
2988             alt: false,
2989         };
2990         tb.keyboard_input(&ctrl_x, &mut dummy);
2991         assert_eq!(tb.edit_buffer, "hunter2", "Ctrl+X cuts nothing");
2992         assert!(!crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::Cut));
2993         assert_eq!(tb.edit_buffer, "hunter2", "nor does the menu's Cut");
2994 
2995         dummy.hide_context_menu();
2996         assert!(tb.mouse_input(MouseButton::Right, ElementState::Pressed, 50.0, 20.0, &mut dummy));
2997         let opts = crate::widget::context_menu::options();
2998         assert!(!opts.iter().any(|o| o == "Cut" || o == "Copy"), "no Cut / Copy rows: {opts:?}");
2999         assert!(opts.iter().any(|o| o == "Paste"), "Paste stays: {opts:?}");
3000         dummy.hide_context_menu();
3001     }
3002 
3003     /// The gate is the password flag alone: an ordinary box's selection goes.
3004     #[test]
3005     fn an_ordinary_box_selection_is_clipboard_text() {
3006         let tb = TextBox::new("hello".to_string());
3007         let state = TextEditorState {
3008             buffer: "hello".to_string(),
3009             cursor_idx: 5,
3010             select_anchor: Some(0),
3011             all_selected: false,
3012         };
3013         assert_eq!(tb.clipboard_text(&state).as_deref(), Some("hello"));
3014     }
3015 }