Skip to main content

damascene_core/widgets/
text_input.rs

1//! Single-line text input widget with selection.
2//!
3//! `text_input(key, value, selection)` renders a focusable, key-capturing
4//! input field with a visible caret and (when non-empty) a tinted
5//! selection rectangle behind the selected glyphs. The application
6//! owns both the string and the global [`Selection`]; routed events are
7//! folded back via [`apply_event`] in the app's `on_event` handler.
8//!
9//! ```ignore
10//! use damascene_core::prelude::*;
11//!
12//! struct Form {
13//!     name: String,
14//!     selection: Selection,
15//! }
16//!
17//! impl App for Form {
18//!     fn build(&self, _cx: &BuildCx) -> El {
19//!         text_input("name", &self.name, &self.selection)
20//!     }
21//!
22//!     fn on_event(&mut self, e: UiEvent, _cx: &EventCx) {
23//!         if e.target_key() == Some("name") {
24//!             text_input::apply_event(&mut self.name, &mut self.selection, &e, "name");
25//!         } else if let Some(selection) = e.selection.clone() {
26//!             self.selection = selection;
27//!         }
28//!     }
29//!
30//!     fn selection(&self) -> Selection {
31//!         self.selection.clone()
32//!     }
33//! }
34//! ```
35//!
36//! # Dogfood note
37//!
38//! Composes only the public widget-kit surface. The widget pairs a
39//! caret + character/IME path with selection semantics layered on top
40//! via [`Selection`] (an app-owned value, not stored in `widget_state`),
41//! covering drag-select, shift-extend, replace-on-type, and `Ctrl+A`.
42//! See `widget_kit.md`.
43
44// Lock in full per-item documentation for this module (issue #73).
45#![warn(missing_docs)]
46
47use std::borrow::Cow;
48use std::panic::Location;
49
50use crate::cursor::Cursor;
51use crate::event::{LogicalKey, NamedKey, UiEvent, UiEventKind};
52use crate::metrics::MetricsRole;
53use crate::selection::{Selection, SelectionPoint, SelectionRange};
54use crate::style::StyleProfile;
55use crate::text::metrics::TextGeometry;
56use crate::tokens;
57use crate::tree::*;
58use crate::widgets::text::text;
59
60/// A `(anchor, head)` byte-index pair representing the selection in a
61/// text field. `head` is the caret position; the selection covers
62/// `min(anchor, head)..max(anchor, head)`. When `anchor == head` the
63/// selection is collapsed and the field shows just a caret.
64///
65/// Both indices are byte offsets into the source string and are
66/// clamped to a UTF-8 grapheme boundary by every method that reads or
67/// writes them — callers can safely poke them directly.
68#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
69pub struct TextSelection {
70    /// Byte offset where the selection started (the non-moving end).
71    pub anchor: usize,
72    /// Byte offset of the caret (the moving end).
73    pub head: usize,
74}
75
76/// How (or whether) the rendered text should be visually masked. The
77/// underlying `value` is always the real string; mask only affects
78/// what's painted, what widths are measured against (so caret and
79/// selection band line up with the dots), and which pointer column
80/// maps to which byte offset.
81///
82/// The library's [`clipboard_request_for`] also reads this — copy /
83/// cut are suppressed for masked fields (a password manager pasted in
84/// is fine, but you don't want Ctrl+C to leak the secret to the system
85/// clipboard).
86#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
87pub enum MaskMode {
88    /// No masking — the value renders as typed.
89    #[default]
90    None,
91    /// Every character renders as a bullet (`•`), like
92    /// `<input type="password">`.
93    Password,
94}
95
96const MASK_CHAR: char = '•';
97
98/// Optional configuration for [`text_input_with`] / [`apply_event_with`].
99/// The defaults reproduce [`text_input`] / [`apply_event`] verbatim, so
100/// callers only set the fields they need.
101///
102/// Fields mirror the corresponding HTML `<input>` attributes:
103/// `placeholder`, `maxlength`, `type=password`. The same value is
104/// expected to be available both at build-time (so the placeholder
105/// renders, the mask is applied) and at event-time (so `max_length`
106/// can clip a paste, and Copy / Cut can be suppressed on a masked
107/// field) — that joint availability is why this is a struct the app
108/// holds onto rather than chained modifiers on the returned `El`.
109#[derive(Clone, Copy, Debug, Default)]
110pub struct TextInputOpts<'a> {
111    /// Muted hint text shown only while `value` is empty. Visible even
112    /// while the field is focused (matches HTML `<input placeholder>`).
113    pub placeholder: Option<&'a str>,
114    /// Cap on the *character* count of `value` after an edit. Inserts
115    /// (typing, paste, IME commit) are truncated so the post-edit
116    /// length doesn't exceed this. Existing values longer than the cap
117    /// are left alone — the cap only constrains future inserts.
118    pub max_length: Option<usize>,
119    /// Visual masking of the rendered value. See [`MaskMode`].
120    pub mask: MaskMode,
121    /// Shape the value with tabular (fixed-width) numerals — OpenType
122    /// `tnum`. Caret placement, hit-testing, and the rendered text all
123    /// use the same tabular advances, so the caret can't drift from
124    /// the glyphs. Numeric fields (e.g. `numeric_input`) default this
125    /// on so digits don't shift as values change.
126    pub tabular_numerals: bool,
127}
128
129impl<'a> TextInputOpts<'a> {
130    /// Set the muted hint shown while the value is empty (see
131    /// [`TextInputOpts::placeholder`]).
132    pub fn placeholder(mut self, p: &'a str) -> Self {
133        self.placeholder = Some(p);
134        self
135    }
136
137    /// Cap the character count of future edits (see
138    /// [`TextInputOpts::max_length`]).
139    pub fn max_length(mut self, n: usize) -> Self {
140        self.max_length = Some(n);
141        self
142    }
143
144    /// Mask the rendered value as a password field
145    /// ([`MaskMode::Password`]).
146    pub fn password(mut self) -> Self {
147        self.mask = MaskMode::Password;
148        self
149    }
150
151    /// Shape the value with tabular numerals (see
152    /// [`TextInputOpts::tabular_numerals`]).
153    pub fn tabular_numerals(mut self) -> Self {
154        self.tabular_numerals = true;
155        self
156    }
157
158    fn is_masked(&self) -> bool {
159        !matches!(self.mask, MaskMode::None)
160    }
161}
162
163impl TextSelection {
164    /// Collapsed selection at byte offset `head`.
165    pub const fn caret(head: usize) -> Self {
166        Self { anchor: head, head }
167    }
168
169    /// Selection from `anchor` to `head`. Either order is valid; the
170    /// widget renders `min..max` as the highlighted band.
171    pub const fn range(anchor: usize, head: usize) -> Self {
172        Self { anchor, head }
173    }
174
175    /// `(min, max)` byte offsets, ordered.
176    pub fn ordered(self) -> (usize, usize) {
177        (self.anchor.min(self.head), self.anchor.max(self.head))
178    }
179
180    /// True when the selection is collapsed (anchor == head).
181    pub fn is_collapsed(self) -> bool {
182        self.anchor == self.head
183    }
184}
185
186/// Build a single-line text input. `value` is the string to render
187/// and `selection` carries the caret + selection state. Both are
188/// owned by the application — pass them in from your state and update
189/// them via [`apply_event`] in your event handler.
190///
191/// # Layout
192///
193/// The value is rendered as **one shaped text leaf** so cosmic-text
194/// applies kerning across the whole string. The caret bar and the
195/// selection band sit on top of the text via overlay layout +
196/// paint-time `translate`, with offsets derived from `line_width` of
197/// the prefix substrings. This means moving the caret never re-shapes
198/// the text — characters don't "jitter" left/right as the caret moves.
199///
200/// # Focus
201///
202/// The caret bar carries `alpha_follows_focused_ancestor()` so it only
203/// paints while the input is focused (and fades in/out via the
204/// library's standard focus animation).
205///
206/// # Selection
207///
208/// The input participates in the global
209/// [`crate::selection::Selection`], reading its caret + selection band
210/// through `selection.within(key)`:
211///
212/// - Selection is in this `key` → render caret at `head.byte` and a
213///   band from `min(anchor.byte, head.byte)` to the max.
214/// - Selection lives in another key (or is empty) → render no band;
215///   caret falls back to byte 0 (still hidden by the focus envelope
216///   when the input isn't focused).
217///
218/// The widget sets `.key(key)` on the returned `El` itself — callers
219/// no longer chain `.key(...)` after this builder.
220#[track_caller]
221pub fn text_input(key: &str, value: &str, selection: &Selection) -> El {
222    text_input_with(key, value, selection, TextInputOpts::default())
223}
224
225/// Like [`text_input`], but takes an optional [`TextInputOpts`] for
226/// placeholder / max-length / password masking. Pass
227/// `TextInputOpts::default()` for an output identical to
228/// [`text_input`].
229#[track_caller]
230pub fn text_input_with(
231    key: &str,
232    value: &str,
233    selection: &Selection,
234    opts: TextInputOpts<'_>,
235) -> El {
236    build_text_input(value, selection.within(key), opts).key(key)
237}
238
239/// Render the input El given an already-extracted local view. Pure
240/// rendering: doesn't touch [`Selection`], doesn't set the El's key.
241/// Public callers should go through [`text_input`] /
242/// [`text_input_with`] instead.
243///
244/// `view` is `None` when the active selection lives in a different
245/// widget; in that case no caret bar is emitted, so blurring this
246/// input doesn't briefly paint a stray caret at byte 0 while the
247/// focus envelope fades out.
248#[track_caller]
249fn build_text_input(value: &str, view: Option<TextSelection>, opts: TextInputOpts<'_>) -> El {
250    let selection = view.unwrap_or_default();
251    let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
252    let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
253    let lo = anchor.min(head);
254    let hi = anchor.max(head);
255    let line_h = line_height_px();
256
257    // Pick the rendered string. In password mode each scalar of `value`
258    // becomes one bullet; widths and indices below all reference this
259    // displayed string so the caret and selection band sit under the
260    // dots, not under the (invisible) original glyphs.
261    let display = display_str(value, opts.mask);
262
263    // Pixel offsets along the same shaped run that paints the input text.
264    // Using `TextGeometry::prefix_width` keeps caret / selection placement
265    // tied to the text engine instead of remeasuring prefix substrings.
266    let geometry = single_line_geometry(&display, opts.tabular_numerals);
267    let to_display = |b: usize| original_to_display_byte(value, b, opts.mask);
268    let head_px = geometry.prefix_width(to_display(head));
269    let lo_px = geometry.prefix_width(to_display(lo));
270    let hi_px = geometry.prefix_width(to_display(hi));
271
272    let mut children: Vec<El> = Vec::with_capacity(4);
273
274    // Selection band paints first (behind text, behind caret). The
275    // band is fill-only and inherits its parent input's focus
276    // envelope, so `dim_fill` produces the macOS-style muted-when-
277    // unfocused color without any per-frame state plumbing here.
278    if lo < hi {
279        children.push(
280            El::new(Kind::Custom("text_input_selection"))
281                .style_profile(StyleProfile::Solid)
282                .fill(tokens::SELECTION_BG)
283                .dim_fill(tokens::SELECTION_BG_UNFOCUSED)
284                .radius(2.0)
285                .width(Size::Fixed(hi_px - lo_px))
286                .height(Size::Fixed(line_h))
287                .translate(lo_px, 0.0),
288        );
289    }
290
291    // Placeholder hint — shown only while the value is empty. Sits at
292    // the same origin as the (empty) text leaf, so it visually fills
293    // the gap. The caret still paints on top.
294    if value.is_empty()
295        && let Some(ph) = opts.placeholder
296    {
297        children.push(
298            text(ph)
299                .muted()
300                .width(Size::Hug)
301                .height(Size::Fixed(line_h)),
302        );
303    }
304
305    // The value (or its mask) as one shaped run. Hug width so the
306    // leaf's intrinsic measure is the actual glyph extent.
307    let mut value_leaf = text(display.into_owned())
308        .width(Size::Hug)
309        .height(Size::Fixed(line_h));
310    if opts.tabular_numerals {
311        value_leaf = value_leaf.tabular_numerals();
312    }
313    children.push(value_leaf);
314
315    // Caret bar — emitted only when the selection actually lives in
316    // this input. Without that gate, blurring an input by clicking
317    // into another would render this input's caret at byte 0 (its
318    // `view` defaults when selection moves away) for the duration of
319    // the focus-envelope fade-out — a visible "blink at byte 0" the
320    // user reads as the caret jumping home before vanishing. The
321    // focus envelope's alpha fade still applies on focus *gain*: the
322    // caret is in the tree from frame one of focus arrival and fades
323    // in as the envelope eases up.
324    if view.is_some() {
325        children.push(
326            caret_bar()
327                .translate(head_px, 0.0)
328                .alpha_follows_focused_ancestor()
329                .blink_when_focused(),
330        );
331    }
332
333    // Inner container: clips horizontal overflow and applies a
334    // horizontal `x_offset` so the caret stays inside the visible
335    // viewport. Stateless — `x_offset` is computed each frame from
336    // the current `head_px` and the inner's available width.
337    //
338    // The clip lives on the inner (not the outer) so the outer's
339    // focus-ring band, which paints outside the layout rect via
340    // `paint_overflow`, isn't scissored. Same pattern as
341    // `text_area`'s stage-1 scroll viewport.
342    let inner = El::new(Kind::Group)
343        .clip()
344        .width(Size::Fill(1.0))
345        .height(Size::Fill(1.0))
346        .layout(move |ctx| {
347            // Sticky-right: when the caret would land past the
348            // right edge, slide content left so the caret sits at
349            // the right edge of the visible area. Otherwise leave
350            // it anchored at the left (x_offset = 0). Identical
351            // math to `current_x_offset` so the event-time
352            // pointer→byte mapping in `apply_event` lands on the
353            // same content column the user sees.
354            let x_offset = (head_px - ctx.container.w).max(0.0);
355            ctx.children
356                .iter()
357                .map(|c| {
358                    let (w, h) = (ctx.measure)(c);
359                    // Pick the size the actual layout pass would have
360                    // resolved: Fixed/Hug → intrinsic, Fill → fill the
361                    // available extent on that axis.
362                    let w = match c.width {
363                        Size::Fixed(v) => v,
364                        // `Ch` is resolved to `Fixed` before layout, so it
365                        // can't reach this custom-layout override; fold it in
366                        // with the intrinsic arm for exhaustiveness.
367                        Size::Hug | Size::Ch(_) => w,
368                        Size::Fill(_) => ctx.container.w,
369                        Size::Aspect(r) => h * r,
370                    };
371                    let h = match c.height {
372                        Size::Fixed(v) => v,
373                        Size::Hug | Size::Ch(_) => h,
374                        Size::Fill(_) => ctx.container.h,
375                        Size::Aspect(r) => w * r,
376                    };
377                    // Vertical center inside the inner's content area
378                    // — the outer's `Justify::Center` no longer
379                    // applies here (layout_override replaces axis
380                    // distribution).
381                    let y = ctx.container.y + (ctx.container.h - h) * 0.5;
382                    Rect::new(ctx.container.x - x_offset, y, w, h)
383                })
384                .collect()
385        })
386        .children(children);
387
388    El::new(Kind::Custom("text_input"))
389        .at_loc(Location::caller())
390        .style_profile(StyleProfile::Surface)
391        .metrics_role(MetricsRole::Input)
392        .surface_role(SurfaceRole::Input)
393        .focusable()
394        // The "now editable" affordance on a text input is the ring
395        // around the box, not just the caret — keep it on click too.
396        .always_show_focus_ring()
397        .capture_keys()
398        .paint_overflow(Sides::all(tokens::RING_WIDTH))
399        .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
400        .cursor(Cursor::Text)
401        .fill(tokens::MUTED)
402        .stroke(tokens::BORDER)
403        .default_radius(tokens::RADIUS_MD)
404        .axis(Axis::Overlay)
405        .align(Align::Start)
406        .justify(Justify::Center)
407        .default_width(Size::Fill(1.0))
408        .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
409        .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
410        .child(inner)
411}
412
413fn caret_bar() -> El {
414    El::new(Kind::Custom("text_input_caret"))
415        .style_profile(StyleProfile::Solid)
416        .fill(tokens::FOREGROUND)
417        .width(Size::Fixed(2.0))
418        .height(Size::Fixed(line_height_px()))
419        .radius(1.0)
420}
421
422fn line_height_px() -> f32 {
423    tokens::TEXT_SM.line_height
424}
425
426fn single_line_geometry(value: &str, tabular: bool) -> TextGeometry<'_> {
427    let geometry = TextGeometry::new(
428        value,
429        tokens::TEXT_SM.size,
430        FontWeight::Regular,
431        false,
432        TextWrap::NoWrap,
433        None,
434    );
435    if tabular {
436        geometry.tabular_numerals()
437    } else {
438        geometry
439    }
440}
441
442/// Fold a routed [`UiEvent`] into `value` and `selection`. Returns
443/// `true` when either was mutated.
444///
445/// Handles:
446/// - [`UiEventKind::TextInput`] — replace the selection with the
447///   composed text (or insert at the caret when collapsed).
448/// - [`UiEventKind::KeyDown`] for Backspace, Delete, ArrowLeft,
449///   ArrowRight, Home, End. Without Shift the selection collapses and
450///   moves; with Shift the head extends and the anchor stays.
451/// - [`UiEventKind::KeyDown`] for Ctrl+A — select all.
452/// - [`UiEventKind::PointerDown`] — set the caret to the click position
453///   and the anchor to the same position. With Shift held, only the
454///   head moves (extend selection from the existing anchor).
455/// - [`UiEventKind::LongPress`] — select the word at the touch
456///   position, matching mobile text-editing conventions.
457/// - [`UiEventKind::Drag`] — extend the head to the dragged position;
458///   the anchor stays where pointer-down placed it.
459/// - [`UiEventKind::Click`] — no-op. The selection was already
460///   established by the prior PointerDown / Drag sequence.
461///
462/// All caret arithmetic respects UTF-8 grapheme boundaries.
463///
464/// The function operates on the global [`Selection`] through `key`:
465/// when an event mutates the input's contents, the result is written
466/// back as a single-leaf range under `key`, transferring selection
467/// ownership to this input. Pointer events (`PointerDown`/`Drag`/
468/// `MiddleClick`/`LongPress`) are self-gated on route — only those the
469/// runtime routed to this `key` are handled — so callers may dispatch every
470/// input's `apply_event` unconditionally without one widget stealing
471/// another's press/drag. Key events flow naturally to whatever widget is
472/// focused (and the runtime targets the event accordingly).
473pub fn apply_event(
474    value: &mut String,
475    selection: &mut Selection,
476    event: &UiEvent,
477    key: &str,
478) -> bool {
479    apply_event_with(value, selection, event, key, &TextInputOpts::default())
480}
481
482/// Like [`apply_event`], but takes a [`TextInputOpts`] so the field
483/// honors `max_length` and password-masked pointer hits. Default opts
484/// produce identical behavior to [`apply_event`].
485pub fn apply_event_with(
486    value: &mut String,
487    selection: &mut Selection,
488    event: &UiEvent,
489    key: &str,
490    opts: &TextInputOpts<'_>,
491) -> bool {
492    // Pointer events are routed by the runtime to a concrete target (a
493    // press/drag goes to the *pressed* widget). Only handle the ones routed to
494    // THIS input, so a press/drag belonging to another widget — e.g. a slider
495    // dispatched from the same `on_event` — isn't mis-claimed (the press/drag
496    // arms below read `event.target.rect` and would otherwise fold a foreign
497    // drag into this input's selection, swallowing it). Mirrors the route gate
498    // `slider::apply_event` already applies. Keyboard / text events are
499    // focus-routed and handled regardless of route (the focused input claims
500    // them — see `apply_event_claims_selection_when_event_routed_from_elsewhere`).
501    if matches!(
502        event.kind,
503        UiEventKind::PointerDown
504            | UiEventKind::Drag
505            | UiEventKind::MiddleClick
506            | UiEventKind::LongPress
507    ) && !event.is_route(key)
508    {
509        return false;
510    }
511    let mut local = selection.within(key).unwrap_or_default();
512    let changed = fold_event_local(value, &mut local, event, opts);
513    if changed {
514        selection.range = Some(SelectionRange {
515            anchor: SelectionPoint::new(key, local.anchor),
516            head: SelectionPoint::new(key, local.head),
517        });
518    }
519    changed
520}
521
522/// Apply the event to the input's *local* (`TextSelection`) view of
523/// its slice. The internal worker behind [`apply_event_with`]; pure
524/// in the sense that it doesn't touch [`Selection`].
525fn fold_event_local(
526    value: &mut String,
527    selection: &mut TextSelection,
528    event: &UiEvent,
529    opts: &TextInputOpts<'_>,
530) -> bool {
531    selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
532    selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
533    match event.kind {
534        UiEventKind::TextInput => {
535            let Some(insert) = event.text.as_deref() else {
536                return false;
537            };
538            // winit emits TextInput alongside named-key / shortcut
539            // KeyDowns. Two filters protect us:
540            //
541            // 1. Strip control characters — winit fires "\u{8}" for
542            //    Backspace, "\u{7f}" for Delete, "\r"/"\n" for Enter,
543            //    "\u{1b}" for Escape, "\t" for Tab. The named-key arm
544            //    handles those correctly; we don't want a duplicate
545            //    insertion of the control byte.
546            //
547            // 2. Drop the event when Ctrl-or-Cmd is held (without Alt
548            //    — AltGr on Windows is reported as Ctrl+Alt and is a
549            //    legitimate text-producing modifier). Ctrl+C / Ctrl+V
550            //    etc. emit TextInput("c"/"v") on some platforms; the
551            //    clipboard side already handled the KeyDown, and we
552            //    don't want the literal letter to land in the field.
553            if (event.modifiers.ctrl && !event.modifiers.alt) || event.modifiers.logo {
554                return false;
555            }
556            let filtered: String = insert.chars().filter(|c| !c.is_control()).collect();
557            if filtered.is_empty() {
558                return false;
559            }
560            let to_insert = clip_to_max_length(value, *selection, &filtered, opts.max_length);
561            if to_insert.is_empty() {
562                return false;
563            }
564            replace_selection(value, selection, &to_insert);
565            true
566        }
567        UiEventKind::MiddleClick => {
568            let Some(byte) = caret_byte_at(value, event, opts) else {
569                return false;
570            };
571            *selection = TextSelection::caret(byte);
572            if let Some(insert) = event.text.as_deref() {
573                replace_selection_with(value, selection, insert, opts);
574            }
575            true
576        }
577        UiEventKind::KeyDown => {
578            let Some(kp) = event.key_press.as_ref() else {
579                return false;
580            };
581            let mods = kp.modifiers;
582            // Ctrl+A: select all. We test for this before modifier-less
583            // key arms so the "Character('a')" path doesn't reach
584            // KeyDown's no-op fallthrough.
585            if mods.ctrl
586                && !mods.alt
587                && !mods.logo
588                && let LogicalKey::Character(c) = &kp.logical
589                && c.eq_ignore_ascii_case("a")
590            {
591                let len = value.len();
592                if selection.anchor == 0 && selection.head == len {
593                    return false;
594                }
595                *selection = TextSelection {
596                    anchor: 0,
597                    head: len,
598                };
599                return true;
600            }
601            // Ctrl+W: delete word backward (Emacs / terminal convention).
602            // Matched here as a Character keypress so it sits next to the
603            // Ctrl+A handling above. Ctrl+Backspace below uses the same
604            // delete-word path.
605            if mods.ctrl
606                && !mods.alt
607                && !mods.logo
608                && !mods.shift
609                && let LogicalKey::Character(c) = &kp.logical
610                && c.eq_ignore_ascii_case("w")
611            {
612                return delete_word_backward(value, selection);
613            }
614            match kp.logical.named() {
615                Some(NamedKey::Escape) => {
616                    if selection.is_collapsed() {
617                        return false;
618                    }
619                    selection.anchor = selection.head;
620                    true
621                }
622                Some(NamedKey::Backspace) => {
623                    if !selection.is_collapsed() {
624                        replace_selection(value, selection, "");
625                        return true;
626                    }
627                    if selection.head == 0 {
628                        return false;
629                    }
630                    if mods.ctrl && !mods.alt && !mods.logo {
631                        return delete_word_backward(value, selection);
632                    }
633                    let prev = prev_char_boundary(value, selection.head);
634                    value.replace_range(prev..selection.head, "");
635                    selection.head = prev;
636                    selection.anchor = prev;
637                    true
638                }
639                Some(NamedKey::Delete) => {
640                    if !selection.is_collapsed() {
641                        replace_selection(value, selection, "");
642                        return true;
643                    }
644                    if selection.head >= value.len() {
645                        return false;
646                    }
647                    if mods.ctrl && !mods.alt && !mods.logo {
648                        return delete_word_forward(value, selection);
649                    }
650                    let next = next_char_boundary(value, selection.head);
651                    value.replace_range(selection.head..next, "");
652                    true
653                }
654                Some(NamedKey::ArrowLeft) => {
655                    let target = if selection.is_collapsed() || mods.shift {
656                        if selection.head == 0 {
657                            return false;
658                        }
659                        if mods.ctrl && !mods.alt && !mods.logo {
660                            crate::selection::prev_word_boundary(value, selection.head)
661                        } else {
662                            prev_char_boundary(value, selection.head)
663                        }
664                    } else if mods.ctrl && !mods.alt && !mods.logo {
665                        // Ctrl+Left with a non-empty selection: still a
666                        // word jump, anchored at the current head.
667                        crate::selection::prev_word_boundary(value, selection.head)
668                    } else {
669                        // Collapse a non-empty selection to its left edge.
670                        selection.ordered().0
671                    };
672                    selection.head = target;
673                    if !mods.shift {
674                        selection.anchor = target;
675                    }
676                    true
677                }
678                Some(NamedKey::ArrowRight) => {
679                    let target = if selection.is_collapsed() || mods.shift {
680                        if selection.head >= value.len() {
681                            return false;
682                        }
683                        if mods.ctrl && !mods.alt && !mods.logo {
684                            crate::selection::next_word_boundary(value, selection.head)
685                        } else {
686                            next_char_boundary(value, selection.head)
687                        }
688                    } else if mods.ctrl && !mods.alt && !mods.logo {
689                        crate::selection::next_word_boundary(value, selection.head)
690                    } else {
691                        // Collapse a non-empty selection to its right edge.
692                        selection.ordered().1
693                    };
694                    selection.head = target;
695                    if !mods.shift {
696                        selection.anchor = target;
697                    }
698                    true
699                }
700                Some(NamedKey::Home) => {
701                    if selection.head == 0 && (mods.shift || selection.anchor == 0) {
702                        return false;
703                    }
704                    selection.head = 0;
705                    if !mods.shift {
706                        selection.anchor = 0;
707                    }
708                    true
709                }
710                Some(NamedKey::End) => {
711                    let end = value.len();
712                    if selection.head == end && (mods.shift || selection.anchor == end) {
713                        return false;
714                    }
715                    selection.head = end;
716                    if !mods.shift {
717                        selection.anchor = end;
718                    }
719                    true
720                }
721                _ => false,
722            }
723        }
724        UiEventKind::PointerDown => {
725            let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
726                return false;
727            };
728            // Account for the inner clip group's horizontal
729            // caret-into-view shift: with a long value scrolled
730            // past the right edge, the content the user clicks
731            // lives at `local_x + x_offset` in content space, not
732            // at raw `local_x`.
733            let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
734            let x_offset = current_x_offset(value, selection.head, viewport_w, opts);
735            let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
736            let pos = caret_from_x(value, local_x, opts);
737            // Multi-click: 2 = select word at hit; ≥3 = select all.
738            // Modifier-shift extend still wins over multi-click — it
739            // reads as "extend whatever I had", and that's what shift-
740            // double-click does in browsers. Single-click (and
741            // missing/zero count, e.g. synthetic events) keeps the
742            // existing set-caret behavior.
743            if !event.modifiers.shift {
744                match event.click_count {
745                    2 => {
746                        let (lo, hi) = crate::selection::word_range_at(value, pos);
747                        selection.anchor = lo;
748                        selection.head = hi;
749                        return true;
750                    }
751                    n if n >= 3 => {
752                        selection.anchor = 0;
753                        selection.head = value.len();
754                        return true;
755                    }
756                    _ => {}
757                }
758            }
759            selection.head = pos;
760            if !event.modifiers.shift {
761                selection.anchor = pos;
762            }
763            true
764        }
765        UiEventKind::LongPress => {
766            let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
767                return false;
768            };
769            let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
770            let x_offset = current_x_offset(value, selection.head, viewport_w, opts);
771            let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
772            let pos = caret_from_x(value, local_x, opts);
773            let (lo, hi) = crate::selection::word_range_at(value, pos);
774            selection.anchor = lo;
775            selection.head = hi;
776            true
777        }
778        UiEventKind::Drag => {
779            let (Some((px, _py)), Some(target)) = (event.pointer, event.target.as_ref()) else {
780                return false;
781            };
782            // Same scroll-offset adjustment as the PointerDown
783            // path above. The current `selection.head` reflects
784            // pre-event state — that's the head the rendered
785            // frame used to compute its `x_offset`.
786            let viewport_w = (target.rect.w - 2.0 * tokens::SPACE_3).max(0.0);
787            let x_offset = current_x_offset(value, selection.head, viewport_w, opts);
788            let local_x = px - target.rect.x - tokens::SPACE_3 + x_offset;
789            let pos = caret_from_x(value, local_x, opts);
790            if !event.modifiers.shift {
791                match event.click_count {
792                    2 => {
793                        extend_word_selection(value, selection, pos);
794                        return true;
795                    }
796                    n if n >= 3 => {
797                        selection.anchor = 0;
798                        selection.head = value.len();
799                        return true;
800                    }
801                    _ => {}
802                }
803            }
804            selection.head = pos;
805            true
806        }
807        UiEventKind::Click => false,
808        _ => false,
809    }
810}
811
812fn extend_word_selection(value: &str, selection: &mut TextSelection, pos: usize) {
813    let (selected_lo, selected_hi) = selection.ordered();
814    let (word_lo, word_hi) = crate::selection::word_range_at(value, pos);
815    if pos < selected_lo {
816        selection.anchor = selected_hi;
817        selection.head = word_lo;
818    } else {
819        selection.anchor = selected_lo;
820        selection.head = word_hi;
821    }
822}
823
824/// The currently-selected substring of `value`. Returns `""` when the
825/// selection is collapsed.
826pub fn selected_text(value: &str, selection: TextSelection) -> &str {
827    let head = clamp_to_char_boundary(value, selection.head.min(value.len()));
828    let anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
829    &value[anchor.min(head)..anchor.max(head)]
830}
831
832/// Delete the run of characters between the caret and the previous
833/// word boundary. Used by `Ctrl+Backspace` and `Ctrl+W`. Returns
834/// `true` when something was deleted. A non-collapsed selection is
835/// deleted whole instead (matching the plain Backspace contract).
836pub(crate) fn delete_word_backward(value: &mut String, selection: &mut TextSelection) -> bool {
837    if !selection.is_collapsed() {
838        replace_selection(value, selection, "");
839        return true;
840    }
841    if selection.head == 0 {
842        return false;
843    }
844    let target = crate::selection::prev_word_boundary(value, selection.head);
845    if target == selection.head {
846        return false;
847    }
848    value.replace_range(target..selection.head, "");
849    selection.head = target;
850    selection.anchor = target;
851    true
852}
853
854/// Delete the run of characters between the caret and the next word
855/// boundary. Used by `Ctrl+Delete`. Returns `true` when something was
856/// deleted. A non-collapsed selection is deleted whole instead
857/// (matching the plain Delete contract).
858pub(crate) fn delete_word_forward(value: &mut String, selection: &mut TextSelection) -> bool {
859    if !selection.is_collapsed() {
860        replace_selection(value, selection, "");
861        return true;
862    }
863    if selection.head >= value.len() {
864        return false;
865    }
866    let target = crate::selection::next_word_boundary(value, selection.head);
867    if target == selection.head {
868        return false;
869    }
870    value.replace_range(selection.head..target, "");
871    true
872}
873
874/// Replace the selected substring (or insert at the caret when the
875/// selection is collapsed) with `replacement`. Updates `selection` to
876/// a collapsed caret immediately after the inserted text.
877pub fn replace_selection(value: &mut String, selection: &mut TextSelection, replacement: &str) {
878    selection.anchor = clamp_to_char_boundary(value, selection.anchor.min(value.len()));
879    selection.head = clamp_to_char_boundary(value, selection.head.min(value.len()));
880    let (lo, hi) = selection.ordered();
881    value.replace_range(lo..hi, replacement);
882    let new_caret = lo + replacement.len();
883    selection.anchor = new_caret;
884    selection.head = new_caret;
885}
886
887/// [`replace_selection`] that respects [`TextInputOpts::max_length`]:
888/// the replacement is truncated (by character count) so the post-edit
889/// `value` doesn't exceed the cap. Use this for paste / drop / IME
890/// commit flows where the field has a length cap. Returns the byte
891/// length of the actually-inserted text — useful when the caller wants
892/// to know whether the input was clipped.
893pub fn replace_selection_with(
894    value: &mut String,
895    selection: &mut TextSelection,
896    replacement: &str,
897    opts: &TextInputOpts<'_>,
898) -> usize {
899    let clipped = clip_to_max_length(value, *selection, replacement, opts.max_length);
900    let len = clipped.len();
901    replace_selection(value, selection, &clipped);
902    len
903}
904
905/// `(0, value.len())` — the selection that spans the whole field.
906pub fn select_all(value: &str) -> TextSelection {
907    TextSelection {
908        anchor: 0,
909        head: value.len(),
910    }
911}
912
913/// Which clipboard operation a keypress is requesting.
914///
915/// [`clipboard_request`] just identifies the keystroke; platform
916/// clipboard access lives outside `damascene-core`. The turnkey
917/// `damascene-winit-wgpu` host handles Ctrl/Cmd+C/X/V and middle-click
918/// paste for apps that return their current [`Selection`] from
919/// [`crate::event::App::selection`]. Custom hosts or examples that
920/// manage their own clipboard can use this enum to dispatch the
921/// actual `set_text` / `get_text` call against `arboard`, the web
922/// Clipboard API, or another backend.
923#[derive(Clone, Copy, Debug, PartialEq, Eq)]
924pub enum ClipboardKind {
925    /// `Ctrl+C` / `Cmd+C` — copy the current selection.
926    Copy,
927    /// `Ctrl+X` / `Cmd+X` — copy the current selection, then delete it.
928    Cut,
929    /// `Ctrl+V` / `Cmd+V` — replace the selection with clipboard text.
930    Paste,
931}
932
933/// Detect a clipboard keystroke (Ctrl/Cmd + C/X/V) in `event`.
934/// Returns `None` for any other event, including `Ctrl+Shift+C`
935/// (browser dev tools convention) and `Ctrl+Alt+V`.
936///
937/// Apps integrate clipboard by checking this before falling through
938/// to [`apply_event`]:
939///
940/// ```ignore
941/// match text_input::clipboard_request(&event) {
942///     Some(ClipboardKind::Copy) => { clipboard.set_text(text_input::selected_text(&value, sel)); }
943///     Some(ClipboardKind::Cut) => {
944///         clipboard.set_text(text_input::selected_text(&value, sel));
945///         text_input::replace_selection(&mut value, &mut sel, "");
946///     }
947///     Some(ClipboardKind::Paste) => {
948///         if let Ok(text) = clipboard.get_text() {
949///             text_input::replace_selection(&mut value, &mut sel, &text);
950///         }
951///     }
952///     None => { text_input::apply_event(&mut value, &mut sel, &event, key); }
953/// }
954/// ```
955///
956/// # Image paste
957///
958/// Apps that accept image paste (chat clients, image viewers, paint
959/// apps) handle the `Paste` branch themselves and call their
960/// clipboard backend's image API before falling through to
961/// `get_text`. With `arboard`:
962///
963/// ```ignore
964/// Some(ClipboardKind::Paste) => {
965///     if let Ok(img) = clipboard.get_image() {
966///         // img.bytes is RGBA8; wrap in `Image::from_rgba8(...)`
967///         // and stash on app state for `image()` widget rendering.
968///         self.attachments.push(decode_clipboard_image(img));
969///     } else if let Ok(text) = clipboard.get_text() {
970///         text_input::replace_selection(&mut value, &mut sel, &text);
971///     }
972/// }
973/// ```
974///
975/// No new damascene API is needed for image paste — the dispatch shape
976/// mirrors the text path. File-drop input rides a different channel:
977/// see [`crate::UiEventKind::FileDropped`].
978pub fn clipboard_request(event: &UiEvent) -> Option<ClipboardKind> {
979    clipboard_request_for(event, &TextInputOpts::default())
980}
981
982/// Mask-aware variant of [`clipboard_request`]: returns `None` for
983/// `Copy` / `Cut` when the field is masked (password mode). Paste is
984/// still recognized — pasting *into* a password field is normal.
985pub fn clipboard_request_for(event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<ClipboardKind> {
986    if event.kind != UiEventKind::KeyDown {
987        return None;
988    }
989    let kp = event.key_press.as_ref()?;
990    let mods = kp.modifiers;
991    // Reject when Alt or Shift is held — those modifiers select
992    // different bindings (browser dev tools, alternative paste, etc.).
993    if mods.alt || mods.shift {
994        return None;
995    }
996    let kind = match &kp.logical {
997        LogicalKey::Character(c) if mods.ctrl || mods.logo => {
998            match c.to_ascii_lowercase().as_str() {
999                "c" => ClipboardKind::Copy,
1000                "x" => ClipboardKind::Cut,
1001                "v" => ClipboardKind::Paste,
1002                _ => return None,
1003            }
1004        }
1005        // Android and some desktop keyboards have dedicated semantic
1006        // clipboard keys, surfaced as named logical keys.
1007        LogicalKey::Named(named) if !mods.ctrl && !mods.logo => match named {
1008            NamedKey::Copy => ClipboardKind::Copy,
1009            NamedKey::Cut => ClipboardKind::Cut,
1010            NamedKey::Paste => ClipboardKind::Paste,
1011            _ => return None,
1012        },
1013        _ => return None,
1014    };
1015    if opts.is_masked() && matches!(kind, ClipboardKind::Copy | ClipboardKind::Cut) {
1016        return None;
1017    }
1018    Some(kind)
1019}
1020
1021/// Resolve the byte offset a pointer event maps to inside a text
1022/// input's `value`. Returns `None` for events that carry no pointer
1023/// coordinate or no target rect — typical of synthesized or routed
1024/// events that didn't originate from a press / move on the input.
1025///
1026/// Apps use this to implement Linux middle-click paste: route the
1027/// `MiddleClick` event through this helper to learn where the user
1028/// pointed, then `replace_selection_with` the primary-clipboard text
1029/// at that position.
1030#[track_caller]
1031pub fn caret_byte_at(value: &str, event: &UiEvent, opts: &TextInputOpts<'_>) -> Option<usize> {
1032    let (px, _py) = event.pointer?;
1033    let target = event.target.as_ref()?;
1034    let local_x = px - target.rect.x - tokens::SPACE_3;
1035    Some(caret_from_x(value, local_x, opts))
1036}
1037
1038/// Horizontal scroll offset applied to text_input's content for
1039/// caret-into-view. Mirrored between the build-time `layout_override`
1040/// (where it shifts content left) and the event-time pointer-to-byte
1041/// math (where it shifts the pointer's local x right to land in
1042/// content coords). Stateless — derived purely from current
1043/// `value`, `head`, and the viewport width.
1044///
1045/// Returns `0.0` when the caret would land inside the viewport
1046/// without any scroll, otherwise the minimum positive offset that
1047/// pins the caret at the right edge of the visible area. Same
1048/// `head` clamp + mask handling as `build_text_input`.
1049fn current_x_offset(value: &str, head: usize, viewport_w: f32, opts: &TextInputOpts<'_>) -> f32 {
1050    if viewport_w <= 0.0 {
1051        return 0.0;
1052    }
1053    let head = clamp_to_char_boundary(value, head.min(value.len()));
1054    let display = display_str(value, opts.mask);
1055    let geometry = single_line_geometry(&display, opts.tabular_numerals);
1056    let head_display = original_to_display_byte(value, head, opts.mask);
1057    let head_px = geometry.prefix_width(head_display);
1058    (head_px - viewport_w).max(0.0)
1059}
1060
1061fn caret_from_x(value: &str, local_x: f32, opts: &TextInputOpts<'_>) -> usize {
1062    if value.is_empty() || local_x <= 0.0 {
1063        return 0;
1064    }
1065    let probe = display_str(value, opts.mask);
1066    let local_y = line_height_px() * 0.5;
1067    let geometry = single_line_geometry(&probe, opts.tabular_numerals);
1068    let display_byte = match geometry.hit_byte(local_x, local_y) {
1069        Some(byte) => byte.min(probe.len()),
1070        None => probe.len(),
1071    };
1072    display_to_original_byte(value, display_byte, opts.mask)
1073}
1074
1075/// Borrow `value` directly when [`MaskMode::None`]; otherwise build a
1076/// masked rendering (one [`MASK_CHAR`] per Unicode scalar). Used at
1077/// build-time to position the caret / selection band against the same
1078/// pixel widths the text leaf will eventually shape.
1079fn display_str(value: &str, mask: MaskMode) -> Cow<'_, str> {
1080    match mask {
1081        MaskMode::None => Cow::Borrowed(value),
1082        MaskMode::Password => {
1083            let n = value.chars().count();
1084            let mut s = String::with_capacity(n * MASK_CHAR.len_utf8());
1085            for _ in 0..n {
1086                s.push(MASK_CHAR);
1087            }
1088            Cow::Owned(s)
1089        }
1090    }
1091}
1092
1093fn original_to_display_byte(value: &str, byte_index: usize, mask: MaskMode) -> usize {
1094    match mask {
1095        MaskMode::None => byte_index.min(value.len()),
1096        MaskMode::Password => {
1097            let clamped = clamp_to_char_boundary(value, byte_index.min(value.len()));
1098            value[..clamped].chars().count() * MASK_CHAR.len_utf8()
1099        }
1100    }
1101}
1102
1103/// Inverse of [`original_to_display_byte`].
1104fn display_to_original_byte(value: &str, display_byte: usize, mask: MaskMode) -> usize {
1105    match mask {
1106        MaskMode::None => clamp_to_char_boundary(value, display_byte.min(value.len())),
1107        MaskMode::Password => {
1108            let scalar_idx = display_byte / MASK_CHAR.len_utf8();
1109            value
1110                .char_indices()
1111                .nth(scalar_idx)
1112                .map(|(i, _)| i)
1113                .unwrap_or(value.len())
1114        }
1115    }
1116}
1117
1118/// Truncate `replacement` so that, after replacing the current
1119/// selection in `value`, the post-edit character count doesn't exceed
1120/// `max_length`. Returns `replacement` unchanged when no cap is set;
1121/// when the value already exceeds the cap, refuses any insert (we
1122/// don't auto-shrink an existing value just because the cap was
1123/// lowered — that's the caller's call). Defensive against an
1124/// unclamped `selection`.
1125fn clip_to_max_length<'a>(
1126    value: &str,
1127    selection: TextSelection,
1128    replacement: &'a str,
1129    max_length: Option<usize>,
1130) -> Cow<'a, str> {
1131    let Some(max) = max_length else {
1132        return Cow::Borrowed(replacement);
1133    };
1134    let lo = clamp_to_char_boundary(value, selection.anchor.min(selection.head).min(value.len()));
1135    let hi = clamp_to_char_boundary(value, selection.anchor.max(selection.head).min(value.len()));
1136    let post_other = value[..lo].chars().count() + value[hi..].chars().count();
1137    let allowed = max.saturating_sub(post_other);
1138    if replacement.chars().count() <= allowed {
1139        Cow::Borrowed(replacement)
1140    } else {
1141        Cow::Owned(replacement.chars().take(allowed).collect())
1142    }
1143}
1144
1145fn clamp_to_char_boundary(s: &str, idx: usize) -> usize {
1146    let mut idx = idx.min(s.len());
1147    while idx > 0 && !s.is_char_boundary(idx) {
1148        idx -= 1;
1149    }
1150    idx
1151}
1152
1153fn prev_char_boundary(s: &str, from: usize) -> usize {
1154    let mut i = from.saturating_sub(1);
1155    while i > 0 && !s.is_char_boundary(i) {
1156        i -= 1;
1157    }
1158    i
1159}
1160
1161fn next_char_boundary(s: &str, from: usize) -> usize {
1162    let mut i = (from + 1).min(s.len());
1163    while i < s.len() && !s.is_char_boundary(i) {
1164        i += 1;
1165    }
1166    i
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use super::*;
1172    use crate::event::{
1173        KeyModifiers, KeyPress, PhysicalKey, Pointer, PointerButton, PointerKind, UiTarget,
1174    };
1175    use crate::layout::layout;
1176    use crate::palette::Palette;
1177    use crate::runtime::RunnerCore;
1178    use crate::state::UiState;
1179    use crate::text::metrics;
1180
1181    /// Test key for the local-view shim helpers below. Matches the
1182    /// `.key("ti")` chain used by every fixture in this module so the
1183    /// `text_input` and `text_input_with` shims (which set the El's
1184    /// key internally) line up with the existing assertions.
1185    const TEST_KEY: &str = "ti";
1186
1187    /// Wrap the old `text_input(value, TextSelection)` API by lifting
1188    /// the local view into a single-leaf [`Selection`] under
1189    /// [`TEST_KEY`]. Lets the existing test bodies stay readable
1190    /// against the post-migration API.
1191    #[track_caller]
1192    fn text_input(value: &str, sel: TextSelection) -> El {
1193        super::text_input(TEST_KEY, value, &as_selection(sel))
1194    }
1195
1196    #[track_caller]
1197    fn text_input_with(value: &str, sel: TextSelection, opts: TextInputOpts<'_>) -> El {
1198        super::text_input_with(TEST_KEY, value, &as_selection(sel), opts)
1199    }
1200
1201    fn apply_event(value: &mut String, sel: &mut TextSelection, event: &UiEvent) -> bool {
1202        let mut g = as_selection(*sel);
1203        let changed = super::apply_event(value, &mut g, event, TEST_KEY);
1204        sync_back(sel, &g);
1205        changed
1206    }
1207
1208    fn apply_event_with(
1209        value: &mut String,
1210        sel: &mut TextSelection,
1211        event: &UiEvent,
1212        opts: &TextInputOpts<'_>,
1213    ) -> bool {
1214        let mut g = as_selection(*sel);
1215        let changed = super::apply_event_with(value, &mut g, event, TEST_KEY, opts);
1216        sync_back(sel, &g);
1217        changed
1218    }
1219
1220    fn as_selection(sel: TextSelection) -> Selection {
1221        Selection {
1222            range: Some(SelectionRange {
1223                anchor: SelectionPoint::new(TEST_KEY, sel.anchor),
1224                head: SelectionPoint::new(TEST_KEY, sel.head),
1225            }),
1226        }
1227    }
1228
1229    fn sync_back(local: &mut TextSelection, global: &Selection) {
1230        match global.within(TEST_KEY) {
1231            Some(view) => *local = view,
1232            None => *local = TextSelection::default(),
1233        }
1234    }
1235
1236    fn ev_text(s: &str) -> UiEvent {
1237        ev_text_with_mods(s, KeyModifiers::default())
1238    }
1239
1240    fn ev_text_with_mods(s: &str, modifiers: KeyModifiers) -> UiEvent {
1241        UiEvent {
1242            path: None,
1243            key: None,
1244            target: None,
1245            pointer: None,
1246            key_press: None,
1247            text: Some(s.into()),
1248            selection: None,
1249            modifiers,
1250            click_count: 0,
1251            pointer_kind: None,
1252            wheel_delta: None,
1253            kind: UiEventKind::TextInput,
1254        }
1255    }
1256
1257    fn ev_key(key: LogicalKey) -> UiEvent {
1258        ev_key_with_mods(key, KeyModifiers::default())
1259    }
1260
1261    fn ev_key_with_mods(key: LogicalKey, modifiers: KeyModifiers) -> UiEvent {
1262        UiEvent {
1263            path: None,
1264            key: None,
1265            target: None,
1266            pointer: None,
1267            key_press: Some(KeyPress {
1268                logical: key,
1269                physical: PhysicalKey::Unidentified,
1270                modifiers,
1271                repeat: false,
1272            }),
1273            text: None,
1274            selection: None,
1275            modifiers,
1276            click_count: 0,
1277            pointer_kind: None,
1278            wheel_delta: None,
1279            kind: UiEventKind::KeyDown,
1280        }
1281    }
1282
1283    fn ev_pointer_down(target: UiTarget, pointer: (f32, f32), modifiers: KeyModifiers) -> UiEvent {
1284        ev_pointer_down_with_count(target, pointer, modifiers, 1)
1285    }
1286
1287    fn ev_pointer_down_with_count(
1288        target: UiTarget,
1289        pointer: (f32, f32),
1290        modifiers: KeyModifiers,
1291        click_count: u8,
1292    ) -> UiEvent {
1293        UiEvent {
1294            path: None,
1295            key: Some(target.key.clone()),
1296            target: Some(target),
1297            pointer: Some(pointer),
1298            key_press: None,
1299            text: None,
1300            selection: None,
1301            modifiers,
1302            click_count,
1303            pointer_kind: None,
1304            wheel_delta: None,
1305            kind: UiEventKind::PointerDown,
1306        }
1307    }
1308
1309    fn ev_long_press(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1310        UiEvent {
1311            path: None,
1312            key: Some(target.key.clone()),
1313            target: Some(target),
1314            pointer: Some(pointer),
1315            key_press: None,
1316            text: None,
1317            selection: None,
1318            modifiers: KeyModifiers::default(),
1319            click_count: 0,
1320            pointer_kind: Some(PointerKind::Touch),
1321            wheel_delta: None,
1322            kind: UiEventKind::LongPress,
1323        }
1324    }
1325
1326    fn ev_drag(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1327        ev_drag_with_count(target, pointer, 0)
1328    }
1329
1330    fn ev_drag_with_count(target: UiTarget, pointer: (f32, f32), click_count: u8) -> UiEvent {
1331        UiEvent {
1332            path: None,
1333            key: Some(target.key.clone()),
1334            target: Some(target),
1335            pointer: Some(pointer),
1336            key_press: None,
1337            text: None,
1338            selection: None,
1339            modifiers: KeyModifiers::default(),
1340            click_count,
1341            pointer_kind: None,
1342            wheel_delta: None,
1343            kind: UiEventKind::Drag,
1344        }
1345    }
1346
1347    fn ev_middle_click(target: UiTarget, pointer: (f32, f32), text: Option<&str>) -> UiEvent {
1348        UiEvent {
1349            path: None,
1350            key: Some(target.key.clone()),
1351            target: Some(target),
1352            pointer: Some(pointer),
1353            key_press: None,
1354            text: text.map(str::to_string),
1355            selection: None,
1356            modifiers: KeyModifiers::default(),
1357            click_count: 1,
1358            pointer_kind: None,
1359            wheel_delta: None,
1360            kind: UiEventKind::MiddleClick,
1361        }
1362    }
1363
1364    fn ti_target() -> UiTarget {
1365        UiTarget {
1366            key: "ti".into(),
1367            node_id: "root.text_input[ti]".into(),
1368            rect: Rect::new(20.0, 20.0, 400.0, 36.0),
1369            tooltip: None,
1370            scroll_offset_y: 0.0,
1371        }
1372    }
1373
1374    #[test]
1375    fn apply_event_ignores_pointer_routed_to_another_widget() {
1376        // A PointerDown/Drag the runtime routed to a *different* widget (e.g. a
1377        // slider sharing the app's on_event dispatch) must not be folded into
1378        // this input's selection: the pointer arms read `event.target.rect`, so
1379        // an ungated call would scribble a foreign drag into our value.
1380        let foreign = || UiTarget {
1381            key: "other".into(),
1382            node_id: "root.slider[other]".into(),
1383            rect: Rect::new(0.0, 0.0, 200.0, 20.0),
1384            tooltip: None,
1385            scroll_offset_y: 0.0,
1386        };
1387        let mut value = String::from("hello");
1388        let mut sel = TextSelection::range(1, 3);
1389
1390        let drag = ev_drag(foreign(), (40.0, 10.0));
1391        assert!(!apply_event(&mut value, &mut sel, &drag));
1392        let down = ev_pointer_down(foreign(), (40.0, 10.0), KeyModifiers::default());
1393        assert!(!apply_event(&mut value, &mut sel, &down));
1394
1395        // Value and selection untouched by the foreign-routed pointer events.
1396        assert_eq!(value, "hello");
1397        assert_eq!(sel, TextSelection::range(1, 3));
1398    }
1399
1400    /// Return the visual content children of a built text_input —
1401    /// selection band(s), placeholder, text leaf, and caret bar.
1402    /// The widget wraps these in an inner clipping group that
1403    /// applies horizontal caret-into-view via `layout_override`, so
1404    /// `el.children` itself is `[inner_group]` and the real content
1405    /// children live one level deeper. This helper keeps the
1406    /// existing assertions concise.
1407    fn content_children(el: &El) -> &[El] {
1408        assert_eq!(
1409            el.children.len(),
1410            1,
1411            "text_input wraps its content in a single inner group"
1412        );
1413        &el.children[0].children
1414    }
1415
1416    #[test]
1417    fn text_input_collapsed_renders_value_as_single_text_leaf_plus_caret() {
1418        let el = text_input("hello", TextSelection::caret(2));
1419        assert!(matches!(el.kind, Kind::Custom("text_input")));
1420        assert!(el.focusable);
1421        assert!(el.capture_keys);
1422        // Content: [0] = text leaf with the full value, [1] = caret
1423        // bar. (The outer wraps these in a single inner clip group
1424        // for horizontal caret-into-view; see `content_children`.)
1425        let cs = content_children(&el);
1426        assert_eq!(cs.len(), 2);
1427        assert!(matches!(cs[0].kind, Kind::Text));
1428        assert_eq!(cs[0].text.as_deref(), Some("hello"));
1429        assert!(matches!(cs[1].kind, Kind::Custom("text_input_caret")));
1430        assert!(cs[1].alpha_follows_focused_ancestor);
1431    }
1432
1433    #[test]
1434    fn text_input_declares_text_cursor() {
1435        let el = text_input("hello", TextSelection::caret(0));
1436        assert_eq!(el.cursor, Some(Cursor::Text));
1437    }
1438
1439    #[test]
1440    fn text_input_with_selection_inserts_selection_band_first() {
1441        // anchor=2, head=4 → selection "ll", head at right edge.
1442        let el = text_input("hello", TextSelection::range(2, 4));
1443        let cs = content_children(&el);
1444        // [0] = selection band, [1] = full-value text leaf, [2] = caret.
1445        assert_eq!(cs.len(), 3);
1446        assert!(matches!(cs[0].kind, Kind::Custom("text_input_selection")));
1447        assert_eq!(cs[1].text.as_deref(), Some("hello"));
1448        assert!(matches!(cs[2].kind, Kind::Custom("text_input_caret")));
1449    }
1450
1451    #[test]
1452    fn text_input_caret_translate_advances_with_head() {
1453        // The caret's translate.x grows with the head's byte index.
1454        // Use line_width as ground truth; caret should be measured from
1455        // the start of the value to head.
1456        use crate::text::metrics::line_width;
1457        let value = "hello";
1458        let head = 3;
1459        let el = text_input(value, TextSelection::caret(head));
1460        let caret = content_children(&el)
1461            .iter()
1462            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1463            .expect("caret child");
1464        let expected = line_width(
1465            &value[..head],
1466            tokens::TEXT_SM.size,
1467            FontWeight::Regular,
1468            false,
1469        );
1470        assert!(
1471            (caret.translate.0 - expected).abs() < 0.01,
1472            "caret translate.x = {}, expected {}",
1473            caret.translate.0,
1474            expected
1475        );
1476    }
1477
1478    #[test]
1479    fn text_input_clamps_off_utf8_boundary() {
1480        // 'é' is two bytes; head=1 sits inside the codepoint and must
1481        // snap back to 0. The single text leaf still renders the whole
1482        // value; only the caret offset reflects the snap.
1483        let el = text_input("é", TextSelection::caret(1));
1484        let cs = content_children(&el);
1485        assert_eq!(cs[0].text.as_deref(), Some("é"));
1486        let caret = cs
1487            .iter()
1488            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1489            .expect("caret child");
1490        // caret head clamped to 0 → translate.x = 0.
1491        assert!(caret.translate.0.abs() < 0.01);
1492    }
1493
1494    #[test]
1495    fn selection_band_fill_dims_when_input_unfocused() {
1496        // When the input lacks focus, the band paints in
1497        // SELECTION_BG_UNFOCUSED. As focus animates in, dim_fill lerps
1498        // the painted color toward SELECTION_BG.
1499        use crate::draw_ops::draw_ops;
1500        use crate::ir::DrawOp;
1501        use crate::shader::UniformValue;
1502        use crate::state::AnimationMode;
1503        use web_time::Instant;
1504
1505        let mut tree = crate::column([text_input("hello", TextSelection::range(0, 5)).key("ti")])
1506            .padding(20.0);
1507        let mut state = UiState::new();
1508        state.set_animation_mode(AnimationMode::Settled);
1509        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1510        state.sync_focus_order(&tree);
1511
1512        // Unfocused: focus envelope settles to 0 → band fill matches
1513        // SELECTION_BG_UNFOCUSED rgb (alpha is multiplied by `opacity`
1514        // so we compare rgb only).
1515        state.apply_to_state();
1516        state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1517        let unfocused = band_fill(&tree, &state).expect("band quad emitted");
1518        let [ur, ug, ub, _] = unfocused.to_srgb_u8a();
1519        let [tr, tg, tb, _] = tokens::SELECTION_BG_UNFOCUSED.to_srgb_u8a();
1520        assert_eq!(
1521            (ur, ug, ub),
1522            (tr, tg, tb),
1523            "unfocused → band rgb is the muted token"
1524        );
1525
1526        // Focused: focus envelope settles to 1 → band fill matches
1527        // SELECTION_BG.
1528        let target = state
1529            .focus
1530            .order
1531            .iter()
1532            .find(|t| t.key == "ti")
1533            .expect("ti in focus order")
1534            .clone();
1535        state.set_focus(Some(target));
1536        state.apply_to_state();
1537        state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1538        let focused = band_fill(&tree, &state).expect("band quad emitted");
1539        let [fr, fg, fb, _] = focused.to_srgb_u8a();
1540        let [tr, tg, tb, _] = tokens::SELECTION_BG.to_srgb_u8a();
1541        assert_eq!(
1542            (fr, fg, fb),
1543            (tr, tg, tb),
1544            "focused → band rgb is the saturated token"
1545        );
1546
1547        fn band_fill(tree: &El, state: &UiState) -> Option<crate::tree::Color> {
1548            let ops = draw_ops(tree, state);
1549            for op in ops {
1550                if let DrawOp::Quad { id, uniforms, .. } = op
1551                    && id.contains("text_input_selection")
1552                    && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1553                {
1554                    return Some(*c);
1555                }
1556            }
1557            None
1558        }
1559    }
1560
1561    #[test]
1562    fn caret_alpha_follows_focus_envelope() {
1563        // The caret bar paints with full alpha when the input is
1564        // focused (envelope = 1) and zero alpha when it isn't
1565        // (envelope = 0). This is what hides the caret in unfocused
1566        // inputs without any app-side focus tracking.
1567        use crate::draw_ops::draw_ops;
1568        use crate::ir::DrawOp;
1569        use crate::shader::UniformValue;
1570        use crate::state::AnimationMode;
1571        use web_time::Instant;
1572
1573        let mut tree =
1574            crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1575        let mut state = UiState::new();
1576        state.set_animation_mode(AnimationMode::Settled);
1577        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1578        state.sync_focus_order(&tree);
1579
1580        // Initially unfocused: focus envelope settles to 0.
1581        state.apply_to_state();
1582        state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1583        let caret_alpha = caret_fill_alpha(&tree, &state);
1584        assert_eq!(caret_alpha, Some(0), "unfocused → caret invisible");
1585
1586        // Focus the input: focus envelope settles to 1.
1587        let target = state
1588            .focus
1589            .order
1590            .iter()
1591            .find(|t| t.key == "ti")
1592            .expect("ti in focus order")
1593            .clone();
1594        state.set_focus(Some(target));
1595        state.apply_to_state();
1596        state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1597        let caret_alpha = caret_fill_alpha(&tree, &state);
1598        assert_eq!(
1599            caret_alpha,
1600            Some(255),
1601            "focused → caret fully visible (alpha=255)"
1602        );
1603
1604        fn caret_fill_alpha(tree: &El, state: &UiState) -> Option<u8> {
1605            let ops = draw_ops(tree, state);
1606            for op in ops {
1607                if let DrawOp::Quad { id, uniforms, .. } = op
1608                    && id.contains("text_input_caret")
1609                    && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1610                {
1611                    return Some(c.to_srgb_u8a()[3]);
1612                }
1613            }
1614            None
1615        }
1616    }
1617
1618    #[test]
1619    fn caret_blink_alpha_holds_solid_through_grace_then_cycles() {
1620        // The blink helper is deterministic on input duration; this
1621        // test pins the cycle shape we paint with.
1622        use crate::state::caret_blink_alpha_for;
1623        use std::time::Duration;
1624        // Inside the 500ms grace window → solid.
1625        assert_eq!(caret_blink_alpha_for(Duration::from_millis(0)), 1.0);
1626        assert_eq!(caret_blink_alpha_for(Duration::from_millis(499)), 1.0);
1627        // Past grace, first half of the 1060ms period → on.
1628        assert_eq!(caret_blink_alpha_for(Duration::from_millis(500)), 1.0);
1629        assert_eq!(caret_blink_alpha_for(Duration::from_millis(1029)), 1.0);
1630        // Second half → off.
1631        assert_eq!(caret_blink_alpha_for(Duration::from_millis(1030)), 0.0);
1632        assert_eq!(caret_blink_alpha_for(Duration::from_millis(1559)), 0.0);
1633        // Back to on for the next cycle.
1634        assert_eq!(caret_blink_alpha_for(Duration::from_millis(1560)), 1.0);
1635    }
1636
1637    #[test]
1638    fn caret_paint_alpha_blinks_after_focus_in_live_mode() {
1639        // Drive the tick at staged Instants so we hit each phase of
1640        // the blink cycle; verifies the painter actually multiplies
1641        // the caret bar's alpha by ui_state.caret.blink_alpha.
1642        use crate::draw_ops::draw_ops;
1643        use crate::ir::DrawOp;
1644        use crate::shader::UniformValue;
1645        use crate::state::AnimationMode;
1646        use std::time::Duration;
1647
1648        let mut tree =
1649            crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1650        let mut state = UiState::new();
1651        state.set_animation_mode(AnimationMode::Live);
1652        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1653        state.sync_focus_order(&tree);
1654
1655        // Focus the input — set_focus bumps caret activity.
1656        let target = state
1657            .focus
1658            .order
1659            .iter()
1660            .find(|t| t.key == "ti")
1661            .unwrap()
1662            .clone();
1663        state.set_focus(Some(target));
1664        let activity_at = state.caret.activity_at.expect("set_focus bumps activity");
1665        let input_id = tree.children[0].computed_id.clone();
1666
1667        // Pin focus envelope after each tick so the caret's
1668        // focus-fade contribution is out of the picture and we can
1669        // attribute alpha changes purely to the blink.
1670        let pin_focus = |state: &mut UiState| {
1671            state.animation.envelopes.insert(
1672                (input_id.clone(), crate::state::EnvelopeKind::FocusRing),
1673                1.0,
1674            );
1675        };
1676
1677        // t = 0 → grace, on.
1678        state.tick_visual_animations(&mut tree, activity_at, &Palette::default());
1679        pin_focus(&mut state);
1680        assert_eq!(caret_alpha(&tree, &state), Some(255));
1681
1682        // t = 1100ms → second half of cycle, off.
1683        state.tick_visual_animations(
1684            &mut tree,
1685            activity_at + Duration::from_millis(1100),
1686            &Palette::default(),
1687        );
1688        pin_focus(&mut state);
1689        assert_eq!(caret_alpha(&tree, &state), Some(0));
1690
1691        // t = 1600ms → back on.
1692        state.tick_visual_animations(
1693            &mut tree,
1694            activity_at + Duration::from_millis(1600),
1695            &Palette::default(),
1696        );
1697        pin_focus(&mut state);
1698        assert_eq!(caret_alpha(&tree, &state), Some(255));
1699
1700        fn caret_alpha(tree: &El, state: &UiState) -> Option<u8> {
1701            for op in draw_ops(tree, state) {
1702                if let DrawOp::Quad { id, uniforms, .. } = op
1703                    && id.contains("text_input_caret")
1704                    && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1705                {
1706                    return Some(c.to_srgb_u8a()[3]);
1707                }
1708            }
1709            None
1710        }
1711    }
1712
1713    #[test]
1714    fn caret_blink_resumes_solid_after_selection_change() {
1715        // Editing (selection change) bumps activity, which puts the
1716        // caret back into the grace window even mid-cycle.
1717        use crate::state::AnimationMode;
1718        use std::time::Duration;
1719        use web_time::Instant;
1720
1721        let mut tree =
1722            crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1723        let mut state = UiState::new();
1724        state.set_animation_mode(AnimationMode::Live);
1725        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1726        state.sync_focus_order(&tree);
1727
1728        // Drive activity to deep into the off phase.
1729        let t0 = Instant::now();
1730        state.bump_caret_activity(t0);
1731        state.tick_visual_animations(
1732            &mut tree,
1733            t0 + Duration::from_millis(1100),
1734            &Palette::default(),
1735        );
1736        assert_eq!(state.caret.blink_alpha, 0.0, "deep in off phase");
1737
1738        // Re-bump (e.g. user typed) — alpha snaps back to solid.
1739        state.bump_caret_activity(t0 + Duration::from_millis(1100));
1740        assert_eq!(state.caret.blink_alpha, 1.0, "fresh activity → solid");
1741    }
1742
1743    #[test]
1744    fn caret_tick_requests_redraw_while_capture_keys_node_focused() {
1745        // Without this, the host's animation loop wouldn't keep
1746        // pumping frames during idle, and the caret would freeze
1747        // mid-blink.
1748        use crate::state::AnimationMode;
1749        use web_time::Instant;
1750
1751        let mut tree =
1752            crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1753        let mut state = UiState::new();
1754        state.set_animation_mode(AnimationMode::Live);
1755        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1756        state.sync_focus_order(&tree);
1757
1758        // No focus → no redraw demand from blink.
1759        let no_focus = state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1760        assert!(!no_focus, "without focus, blink doesn't request redraws");
1761
1762        // Focus the input → tick should keep requesting redraws so
1763        // the on/off cycle keeps animating.
1764        let target = state
1765            .focus
1766            .order
1767            .iter()
1768            .find(|t| t.key == "ti")
1769            .unwrap()
1770            .clone();
1771        state.set_focus(Some(target));
1772        let focused = state.tick_visual_animations(&mut tree, Instant::now(), &Palette::default());
1773        assert!(focused, "focused capture_keys node → tick demands redraws");
1774    }
1775
1776    #[test]
1777    fn apply_text_input_inserts_at_caret_when_collapsed() {
1778        let mut value = String::from("ho");
1779        let mut sel = TextSelection::caret(1);
1780        assert!(apply_event(&mut value, &mut sel, &ev_text("i, t")));
1781        assert_eq!(value, "hi, to");
1782        assert_eq!(sel, TextSelection::caret(5));
1783    }
1784
1785    #[test]
1786    fn apply_text_input_replaces_selection() {
1787        let mut value = String::from("hello world");
1788        let mut sel = TextSelection::range(6, 11); // "world"
1789        assert!(apply_event(&mut value, &mut sel, &ev_text("kit")));
1790        assert_eq!(value, "hello kit");
1791        assert_eq!(sel, TextSelection::caret(9));
1792    }
1793
1794    #[test]
1795    fn apply_backspace_removes_selection_when_non_empty() {
1796        let mut value = String::from("hello world");
1797        let mut sel = TextSelection::range(6, 11);
1798        assert!(apply_event(
1799            &mut value,
1800            &mut sel,
1801            &ev_key(LogicalKey::Named(NamedKey::Backspace))
1802        ));
1803        assert_eq!(value, "hello ");
1804        assert_eq!(sel, TextSelection::caret(6));
1805    }
1806
1807    #[test]
1808    fn apply_delete_removes_selection_when_non_empty() {
1809        let mut value = String::from("hello world");
1810        let mut sel = TextSelection::range(0, 6); // "hello "
1811        assert!(apply_event(
1812            &mut value,
1813            &mut sel,
1814            &ev_key(LogicalKey::Named(NamedKey::Delete))
1815        ));
1816        assert_eq!(value, "world");
1817        assert_eq!(sel, TextSelection::caret(0));
1818    }
1819
1820    #[test]
1821    fn apply_escape_collapses_selection_without_editing() {
1822        let mut value = String::from("hello");
1823        let mut sel = TextSelection::range(1, 4);
1824        assert!(apply_event(
1825            &mut value,
1826            &mut sel,
1827            &ev_key(LogicalKey::Named(NamedKey::Escape))
1828        ));
1829        assert_eq!(value, "hello");
1830        assert_eq!(sel, TextSelection::caret(4));
1831        assert!(!apply_event(
1832            &mut value,
1833            &mut sel,
1834            &ev_key(LogicalKey::Named(NamedKey::Escape))
1835        ));
1836    }
1837
1838    #[test]
1839    fn apply_backspace_collapsed_at_start_is_noop() {
1840        let mut value = String::from("hi");
1841        let mut sel = TextSelection::caret(0);
1842        assert!(!apply_event(
1843            &mut value,
1844            &mut sel,
1845            &ev_key(LogicalKey::Named(NamedKey::Backspace))
1846        ));
1847    }
1848
1849    #[test]
1850    fn apply_arrow_walks_utf8_boundaries() {
1851        let mut value = String::from("aé");
1852        let mut sel = TextSelection::caret(0);
1853        apply_event(
1854            &mut value,
1855            &mut sel,
1856            &ev_key(LogicalKey::Named(NamedKey::ArrowRight)),
1857        );
1858        assert_eq!(sel.head, 1);
1859        apply_event(
1860            &mut value,
1861            &mut sel,
1862            &ev_key(LogicalKey::Named(NamedKey::ArrowRight)),
1863        );
1864        assert_eq!(sel.head, 3);
1865        assert!(!apply_event(
1866            &mut value,
1867            &mut sel,
1868            &ev_key(LogicalKey::Named(NamedKey::ArrowRight))
1869        ));
1870        apply_event(
1871            &mut value,
1872            &mut sel,
1873            &ev_key(LogicalKey::Named(NamedKey::ArrowLeft)),
1874        );
1875        assert_eq!(sel.head, 1);
1876    }
1877
1878    #[test]
1879    fn apply_arrow_collapses_selection_without_shift() {
1880        let mut value = String::from("hello");
1881        let mut sel = TextSelection::range(1, 4); // "ell"
1882        // ArrowLeft (no shift) collapses to the LEFT edge of the
1883        // selection (the smaller of anchor/head).
1884        assert!(apply_event(
1885            &mut value,
1886            &mut sel,
1887            &ev_key(LogicalKey::Named(NamedKey::ArrowLeft))
1888        ));
1889        assert_eq!(sel, TextSelection::caret(1));
1890
1891        let mut sel = TextSelection::range(1, 4);
1892        // ArrowRight (no shift) collapses to the RIGHT edge.
1893        assert!(apply_event(
1894            &mut value,
1895            &mut sel,
1896            &ev_key(LogicalKey::Named(NamedKey::ArrowRight))
1897        ));
1898        assert_eq!(sel, TextSelection::caret(4));
1899    }
1900
1901    #[test]
1902    fn apply_shift_arrow_extends_selection() {
1903        let mut value = String::from("hello");
1904        let mut sel = TextSelection::caret(2);
1905        let shift = KeyModifiers {
1906            shift: true,
1907            ..Default::default()
1908        };
1909        assert!(apply_event(
1910            &mut value,
1911            &mut sel,
1912            &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), shift)
1913        ));
1914        assert_eq!(sel, TextSelection::range(2, 3));
1915        assert!(apply_event(
1916            &mut value,
1917            &mut sel,
1918            &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), shift)
1919        ));
1920        assert_eq!(sel, TextSelection::range(2, 4));
1921        // Shift+ArrowLeft retreats the head, anchor stays.
1922        assert!(apply_event(
1923            &mut value,
1924            &mut sel,
1925            &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowLeft), shift)
1926        ));
1927        assert_eq!(sel, TextSelection::range(2, 3));
1928    }
1929
1930    #[test]
1931    fn apply_home_end_collapse_or_extend() {
1932        let mut value = String::from("hello");
1933        let mut sel = TextSelection::caret(2);
1934        assert!(apply_event(
1935            &mut value,
1936            &mut sel,
1937            &ev_key(LogicalKey::Named(NamedKey::End))
1938        ));
1939        assert_eq!(sel, TextSelection::caret(5));
1940        assert!(apply_event(
1941            &mut value,
1942            &mut sel,
1943            &ev_key(LogicalKey::Named(NamedKey::Home))
1944        ));
1945        assert_eq!(sel, TextSelection::caret(0));
1946
1947        // Shift+End extends.
1948        let shift = KeyModifiers {
1949            shift: true,
1950            ..Default::default()
1951        };
1952        let mut sel = TextSelection::caret(2);
1953        assert!(apply_event(
1954            &mut value,
1955            &mut sel,
1956            &ev_key_with_mods(LogicalKey::Named(NamedKey::End), shift)
1957        ));
1958        assert_eq!(sel, TextSelection::range(2, 5));
1959    }
1960
1961    #[test]
1962    fn apply_ctrl_a_selects_all() {
1963        let mut value = String::from("hello");
1964        let mut sel = TextSelection::caret(2);
1965        let ctrl = KeyModifiers {
1966            ctrl: true,
1967            ..Default::default()
1968        };
1969        assert!(apply_event(
1970            &mut value,
1971            &mut sel,
1972            &ev_key_with_mods(LogicalKey::Character("a".into()), ctrl)
1973        ));
1974        assert_eq!(sel, TextSelection::range(0, 5));
1975        // A second Ctrl+A is a no-op.
1976        assert!(!apply_event(
1977            &mut value,
1978            &mut sel,
1979            &ev_key_with_mods(LogicalKey::Character("a".into()), ctrl)
1980        ));
1981    }
1982
1983    #[test]
1984    fn apply_pointer_down_sets_anchor_and_head() {
1985        let mut value = String::from("hello");
1986        let mut sel = TextSelection::range(0, 5);
1987        // Click far-left should collapse to caret=0.
1988        let down = ev_pointer_down(
1989            ti_target(),
1990            (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1991            KeyModifiers::default(),
1992        );
1993        assert!(apply_event(&mut value, &mut sel, &down));
1994        assert_eq!(sel, TextSelection::caret(0));
1995    }
1996
1997    #[test]
1998    fn apply_double_click_selects_word_at_caret() {
1999        let mut value = String::from("hello world");
2000        let mut sel = TextSelection::caret(0);
2001        // Click somewhere inside "world" with click_count = 2.
2002        let target = ti_target();
2003        let click_x = target.rect.x
2004            + tokens::SPACE_3
2005            + crate::text::metrics::line_width(
2006                "hello w",
2007                tokens::TEXT_SM.size,
2008                FontWeight::Regular,
2009                false,
2010            );
2011        let down = ev_pointer_down_with_count(
2012            target.clone(),
2013            (click_x, target.rect.y + 18.0),
2014            KeyModifiers::default(),
2015            2,
2016        );
2017        assert!(apply_event(&mut value, &mut sel, &down));
2018        // "world" sits at bytes 6..11.
2019        assert_eq!(sel.anchor, 6);
2020        assert_eq!(sel.head, 11);
2021    }
2022
2023    #[test]
2024    fn apply_long_press_selects_word_at_caret() {
2025        let mut value = String::from("hello world");
2026        let mut sel = TextSelection::caret(0);
2027        let target = ti_target();
2028        let event = ev_long_press(target.clone(), (target.rect.x + 4.0, target.rect.y + 18.0));
2029
2030        assert!(apply_event(&mut value, &mut sel, &event));
2031        assert_eq!(sel, TextSelection::range(0, 5));
2032    }
2033
2034    #[test]
2035    fn apply_triple_click_selects_all() {
2036        let mut value = String::from("hello world");
2037        let mut sel = TextSelection::caret(0);
2038        let target = ti_target();
2039        let down = ev_pointer_down_with_count(
2040            target.clone(),
2041            (target.rect.x + 1.0, target.rect.y + 18.0),
2042            KeyModifiers::default(),
2043            3,
2044        );
2045        assert!(apply_event(&mut value, &mut sel, &down));
2046        assert_eq!(sel.anchor, 0);
2047        assert_eq!(sel.head, value.len());
2048    }
2049
2050    #[test]
2051    fn apply_shift_double_click_falls_back_to_extend_not_word_select() {
2052        // Shift + double-click extends the existing selection rather
2053        // than replacing it with the word — matching browser behavior.
2054        let mut value = String::from("hello world");
2055        let mut sel = TextSelection::caret(0);
2056        let target = ti_target();
2057        let click_x = target.rect.x
2058            + tokens::SPACE_3
2059            + crate::text::metrics::line_width(
2060                "hello w",
2061                tokens::TEXT_SM.size,
2062                FontWeight::Regular,
2063                false,
2064            );
2065        let shift = KeyModifiers {
2066            shift: true,
2067            ..Default::default()
2068        };
2069        let down =
2070            ev_pointer_down_with_count(target.clone(), (click_x, target.rect.y + 18.0), shift, 2);
2071        assert!(apply_event(&mut value, &mut sel, &down));
2072        // anchor unchanged at 0; head moved to the click position.
2073        assert_eq!(sel.anchor, 0);
2074        assert!(sel.head > 0 && sel.head < value.len());
2075    }
2076
2077    #[test]
2078    fn apply_shift_pointer_down_only_moves_head() {
2079        let mut value = String::from("hello");
2080        let mut sel = TextSelection::caret(2);
2081        let shift = KeyModifiers {
2082            shift: true,
2083            ..Default::default()
2084        };
2085        // Click far-right with shift: head goes to end, anchor stays.
2086        let down = ev_pointer_down(
2087            ti_target(),
2088            (
2089                ti_target().rect.x + ti_target().rect.w - 4.0,
2090                ti_target().rect.y + 18.0,
2091            ),
2092            shift,
2093        );
2094        assert!(apply_event(&mut value, &mut sel, &down));
2095        assert_eq!(sel.anchor, 2);
2096        assert_eq!(sel.head, value.len());
2097    }
2098
2099    #[test]
2100    fn apply_drag_extends_head_only() {
2101        let mut value = String::from("hello world");
2102        let mut sel = TextSelection::caret(0);
2103        // First, pointer-down at the start.
2104        let down = ev_pointer_down(
2105            ti_target(),
2106            (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
2107            KeyModifiers::default(),
2108        );
2109        apply_event(&mut value, &mut sel, &down);
2110        assert_eq!(sel, TextSelection::caret(0));
2111        // Drag to the right edge — head extends, anchor stays at 0.
2112        let drag = ev_drag(
2113            ti_target(),
2114            (
2115                ti_target().rect.x + ti_target().rect.w - 4.0,
2116                ti_target().rect.y + 18.0,
2117            ),
2118        );
2119        assert!(apply_event(&mut value, &mut sel, &drag));
2120        assert_eq!(sel.anchor, 0);
2121        assert_eq!(sel.head, value.len());
2122    }
2123
2124    #[test]
2125    fn double_click_hold_drag_inside_word_keeps_word_selected() {
2126        let mut value = String::from("hello world");
2127        let mut sel = TextSelection::caret(0);
2128        let target = ti_target();
2129        let click_x = target.rect.x
2130            + tokens::SPACE_3
2131            + crate::text::metrics::line_width(
2132                "hello w",
2133                tokens::TEXT_SM.size,
2134                FontWeight::Regular,
2135                false,
2136            );
2137        let down = ev_pointer_down_with_count(
2138            target.clone(),
2139            (click_x, target.rect.y + 18.0),
2140            KeyModifiers::default(),
2141            2,
2142        );
2143        assert!(apply_event(&mut value, &mut sel, &down));
2144        assert_eq!(sel, TextSelection::range(6, 11));
2145
2146        let drag = ev_drag_with_count(target.clone(), (click_x + 1.0, target.rect.y + 18.0), 2);
2147        assert!(apply_event(&mut value, &mut sel, &drag));
2148        assert_eq!(sel, TextSelection::range(6, 11));
2149    }
2150
2151    #[test]
2152    fn apply_click_is_noop_for_selection() {
2153        // Click fires after a drag — handling it would clobber the
2154        // selection drag established. We deliberately ignore Click in
2155        // text_input.
2156        let mut value = String::from("hello");
2157        let mut sel = TextSelection::range(0, 5);
2158        let click = UiEvent {
2159            path: None,
2160            key: Some("ti".into()),
2161            target: Some(ti_target()),
2162            pointer: Some((ti_target().rect.x + 1.0, ti_target().rect.y + 18.0)),
2163            key_press: None,
2164            text: None,
2165            selection: None,
2166            modifiers: KeyModifiers::default(),
2167            click_count: 1,
2168            pointer_kind: None,
2169            wheel_delta: None,
2170            kind: UiEventKind::Click,
2171        };
2172        assert!(!apply_event(&mut value, &mut sel, &click));
2173        assert_eq!(sel, TextSelection::range(0, 5));
2174    }
2175
2176    #[test]
2177    fn apply_middle_click_inserts_event_text_at_pointer() {
2178        let mut value = String::from("world");
2179        let mut sel = TextSelection::caret(value.len());
2180        let target = ti_target();
2181        let pointer = (
2182            target.rect.x + tokens::SPACE_3,
2183            target.rect.y + target.rect.h * 0.5,
2184        );
2185        let event = ev_middle_click(target, pointer, Some("hello "));
2186        assert!(apply_event(&mut value, &mut sel, &event));
2187        assert_eq!(value, "hello world");
2188        assert_eq!(sel, TextSelection::caret("hello ".len()));
2189    }
2190
2191    #[test]
2192    fn helpers_selected_text_and_replace_selection() {
2193        let value = String::from("hello world");
2194        let sel = TextSelection::range(6, 11);
2195        assert_eq!(selected_text(&value, sel), "world");
2196
2197        let mut value = value;
2198        let mut sel = sel;
2199        replace_selection(&mut value, &mut sel, "kit");
2200        assert_eq!(value, "hello kit");
2201        assert_eq!(sel, TextSelection::caret(9));
2202
2203        assert_eq!(select_all(&value), TextSelection::range(0, value.len()));
2204    }
2205
2206    #[test]
2207    fn apply_text_input_filters_control_chars() {
2208        // winit emits "\u{8}" alongside the named Backspace key event.
2209        // The TextInput branch must reject it so only the KeyDown
2210        // handler edits the value.
2211        let mut value = String::from("hi");
2212        let mut sel = TextSelection::caret(2);
2213        for ctrl in ["\u{8}", "\u{7f}", "\r", "\n", "\u{1b}", "\t"] {
2214            assert!(
2215                !apply_event(&mut value, &mut sel, &ev_text(ctrl)),
2216                "expected {ctrl:?} to be filtered"
2217            );
2218            assert_eq!(value, "hi");
2219            assert_eq!(sel, TextSelection::caret(2));
2220        }
2221        // Mixed input — printable parts come through, control parts drop.
2222        assert!(apply_event(&mut value, &mut sel, &ev_text("a\u{8}b")));
2223        assert_eq!(value, "hiab");
2224        assert_eq!(sel, TextSelection::caret(4));
2225    }
2226
2227    #[test]
2228    fn apply_text_input_drops_when_ctrl_or_cmd_is_held() {
2229        // winit emits TextInput("c") alongside KeyDown(Ctrl+C) on some
2230        // platforms. The clipboard handler consumes the KeyDown; the
2231        // TextInput must be ignored, otherwise the literal 'c'
2232        // replaces the selection right after the copy.
2233        let mut value = String::from("hello");
2234        let mut sel = TextSelection::range(0, 5);
2235        let ctrl = KeyModifiers {
2236            ctrl: true,
2237            ..Default::default()
2238        };
2239        let cmd = KeyModifiers {
2240            logo: true,
2241            ..Default::default()
2242        };
2243        assert!(!apply_event(
2244            &mut value,
2245            &mut sel,
2246            &ev_text_with_mods("c", ctrl)
2247        ));
2248        assert_eq!(value, "hello");
2249        assert!(!apply_event(
2250            &mut value,
2251            &mut sel,
2252            &ev_text_with_mods("v", cmd)
2253        ));
2254        assert_eq!(value, "hello");
2255        // AltGr (Ctrl+Alt) on Windows still produces text — exempt it.
2256        let altgr = KeyModifiers {
2257            ctrl: true,
2258            alt: true,
2259            ..Default::default()
2260        };
2261        let mut value = String::from("");
2262        let mut sel = TextSelection::caret(0);
2263        assert!(apply_event(
2264            &mut value,
2265            &mut sel,
2266            &ev_text_with_mods("é", altgr)
2267        ));
2268        assert_eq!(value, "é");
2269    }
2270
2271    #[test]
2272    fn text_input_value_emits_a_single_glyph_run() {
2273        // Regression test against a kerning bug: splitting the value
2274        // into [prefix, suffix] across the caret meant cosmic-text
2275        // shaped each substring independently, breaking kerning and
2276        // causing glyphs to "jump" left/right as the caret moved.
2277        // The fix renders the value as one shaped run.
2278        use crate::draw_ops::draw_ops;
2279        use crate::ir::DrawOp;
2280        let mut tree =
2281            crate::column([text_input("Type", TextSelection::caret(1)).key("ti")]).padding(20.0);
2282        let mut state = UiState::new();
2283        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2284
2285        let ops = draw_ops(&tree, &state);
2286        let glyph_runs = ops
2287            .iter()
2288            .filter(|op| matches!(op, DrawOp::GlyphRun { id, .. } if id.contains("text_input[ti]")))
2289            .count();
2290        assert_eq!(
2291            glyph_runs, 1,
2292            "value should shape as one run; got {glyph_runs}"
2293        );
2294    }
2295
2296    #[test]
2297    fn clipboard_request_detects_ctrl_c_x_v() {
2298        let ctrl = KeyModifiers {
2299            ctrl: true,
2300            ..Default::default()
2301        };
2302        let cases = [
2303            ("c", ClipboardKind::Copy),
2304            ("C", ClipboardKind::Copy),
2305            ("x", ClipboardKind::Cut),
2306            ("v", ClipboardKind::Paste),
2307        ];
2308        for (ch, expected) in cases {
2309            let e = ev_key_with_mods(LogicalKey::Character(ch.into()), ctrl);
2310            assert_eq!(clipboard_request(&e), Some(expected), "char {ch:?}");
2311        }
2312    }
2313
2314    #[test]
2315    fn clipboard_request_accepts_cmd_on_macos() {
2316        // winit reports Cmd as Logo. Apps should get the same behavior
2317        // on Linux/Windows (Ctrl) and macOS (Logo).
2318        let logo = KeyModifiers {
2319            logo: true,
2320            ..Default::default()
2321        };
2322        let e = ev_key_with_mods(LogicalKey::Character("c".into()), logo);
2323        assert_eq!(clipboard_request(&e), Some(ClipboardKind::Copy));
2324    }
2325
2326    #[test]
2327    fn clipboard_request_detects_semantic_clipboard_keys() {
2328        let cases = [
2329            (NamedKey::Copy, ClipboardKind::Copy),
2330            (NamedKey::Cut, ClipboardKind::Cut),
2331            (NamedKey::Paste, ClipboardKind::Paste),
2332        ];
2333        for (named, expected) in cases {
2334            let e = ev_key(LogicalKey::Named(named));
2335            assert_eq!(
2336                clipboard_request(&e),
2337                Some(expected),
2338                "semantic key {named:?}"
2339            );
2340        }
2341    }
2342
2343    #[test]
2344    fn clipboard_request_rejects_with_shift_or_alt() {
2345        // Ctrl+Shift+C is browser devtools, not Copy.
2346        let e = ev_key_with_mods(
2347            LogicalKey::Character("c".into()),
2348            KeyModifiers {
2349                ctrl: true,
2350                shift: true,
2351                ..Default::default()
2352            },
2353        );
2354        assert_eq!(clipboard_request(&e), None);
2355
2356        let e = ev_key_with_mods(
2357            LogicalKey::Character("v".into()),
2358            KeyModifiers {
2359                ctrl: true,
2360                alt: true,
2361                ..Default::default()
2362            },
2363        );
2364        assert_eq!(clipboard_request(&e), None);
2365    }
2366
2367    #[test]
2368    fn clipboard_request_ignores_other_keys_and_event_kinds() {
2369        // Plain "c" without modifiers is just text input.
2370        let e = ev_key(LogicalKey::Character("c".into()));
2371        assert_eq!(clipboard_request(&e), None);
2372        // Ctrl+A is select-all (handled by apply_event), not clipboard.
2373        let e = ev_key_with_mods(
2374            LogicalKey::Character("a".into()),
2375            KeyModifiers {
2376                ctrl: true,
2377                ..Default::default()
2378            },
2379        );
2380        assert_eq!(clipboard_request(&e), None);
2381        // TextInput events never report a clipboard request.
2382        assert_eq!(clipboard_request(&ev_text("c")), None);
2383    }
2384
2385    fn password_opts() -> TextInputOpts<'static> {
2386        TextInputOpts::default().password()
2387    }
2388
2389    #[test]
2390    fn password_input_renders_value_as_bullets_not_plaintext() {
2391        // The text leaf should never expose the original characters in
2392        // a password field. One bullet per scalar.
2393        let el = text_input_with("hunter2", TextSelection::caret(0), password_opts());
2394        let leaf = content_children(&el)
2395            .iter()
2396            .find(|c| matches!(c.kind, Kind::Text))
2397            .expect("text leaf");
2398        assert_eq!(leaf.text.as_deref(), Some("•••••••"));
2399    }
2400
2401    #[test]
2402    fn password_input_caret_position_uses_masked_widths() {
2403        // Caret offset must come from the rendered (masked) prefix
2404        // width, not the original-string prefix width — otherwise the
2405        // caret drifts away from the dots.
2406        use crate::text::metrics::line_width;
2407        let value = "abc";
2408        let head = 2;
2409        let el = text_input_with(value, TextSelection::caret(head), password_opts());
2410        let caret = content_children(&el)
2411            .iter()
2412            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2413            .expect("caret child");
2414        // Two bullets of prefix.
2415        let expected = line_width("••", tokens::TEXT_SM.size, FontWeight::Regular, false);
2416        assert!(
2417            (caret.translate.0 - expected).abs() < 0.01,
2418            "caret translate.x = {}, expected {}",
2419            caret.translate.0,
2420            expected
2421        );
2422    }
2423
2424    #[test]
2425    fn password_pointer_click_maps_back_to_original_byte() {
2426        // A pointer at the right edge of a 5-char password should
2427        // place the caret at byte index value.len() (=5 for ASCII).
2428        let mut value = String::from("abcde");
2429        let mut sel = TextSelection::default();
2430        let target = ti_target();
2431        let down = ev_pointer_down(
2432            target.clone(),
2433            (target.rect.x + target.rect.w - 4.0, target.rect.y + 18.0),
2434            KeyModifiers::default(),
2435        );
2436        assert!(apply_event_with(
2437            &mut value,
2438            &mut sel,
2439            &down,
2440            &password_opts()
2441        ));
2442        assert_eq!(sel.head, value.len());
2443    }
2444
2445    #[test]
2446    fn password_pointer_click_with_multibyte_value() {
2447        // Mask is one bullet per scalar; the returned byte index must
2448        // be a valid boundary in the (multi-byte) original value.
2449        // 'é' is 2 bytes; "éé" is 4 bytes total.
2450        let mut value = String::from("éé");
2451        let mut sel = TextSelection::default();
2452        let target = ti_target();
2453        // Click at a position that should land between the two bullets.
2454        let bullet_w = metrics::line_width("•", tokens::TEXT_SM.size, FontWeight::Regular, false);
2455        let click_x = target.rect.x + tokens::SPACE_3 + bullet_w * 1.4;
2456        let down = ev_pointer_down(
2457            target,
2458            (click_x, ti_target().rect.y + 18.0),
2459            KeyModifiers::default(),
2460        );
2461        assert!(apply_event_with(
2462            &mut value,
2463            &mut sel,
2464            &down,
2465            &password_opts()
2466        ));
2467        // After 1 scalar in "éé" the byte offset is 2 (or 4 if the hit
2468        // landed past the second bullet). Either way, must be a char
2469        // boundary in `value`.
2470        assert!(
2471            value.is_char_boundary(sel.head),
2472            "head={} not on a char boundary in {value:?}",
2473            sel.head
2474        );
2475        assert!(sel.head == 2 || sel.head == 4, "head={}", sel.head);
2476    }
2477
2478    #[test]
2479    fn password_clipboard_request_suppresses_copy_and_cut_only() {
2480        let ctrl = KeyModifiers {
2481            ctrl: true,
2482            ..Default::default()
2483        };
2484        let opts = password_opts();
2485        let copy = ev_key_with_mods(LogicalKey::Character("c".into()), ctrl);
2486        let cut = ev_key_with_mods(LogicalKey::Character("x".into()), ctrl);
2487        let paste = ev_key_with_mods(LogicalKey::Character("v".into()), ctrl);
2488        assert_eq!(clipboard_request_for(&copy, &opts), None);
2489        assert_eq!(clipboard_request_for(&cut, &opts), None);
2490        assert_eq!(
2491            clipboard_request_for(&paste, &opts),
2492            Some(ClipboardKind::Paste)
2493        );
2494        // Plain (non-masked) opts behave like the legacy entry point.
2495        let plain = TextInputOpts::default();
2496        assert_eq!(
2497            clipboard_request_for(&copy, &plain),
2498            Some(ClipboardKind::Copy)
2499        );
2500    }
2501
2502    #[test]
2503    fn placeholder_renders_only_when_value_is_empty() {
2504        let opts = TextInputOpts::default().placeholder("Email");
2505        let empty = text_input_with("", TextSelection::default(), opts);
2506        let muted_leaf = content_children(&empty)
2507            .iter()
2508            .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2509        assert!(muted_leaf.is_some(), "placeholder leaf should be present");
2510
2511        let nonempty = text_input_with("hi", TextSelection::caret(2), opts);
2512        let muted_leaf = content_children(&nonempty)
2513            .iter()
2514            .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2515        assert!(
2516            muted_leaf.is_none(),
2517            "placeholder should not render once the field has a value"
2518        );
2519    }
2520
2521    #[test]
2522    fn long_value_with_caret_at_end_shifts_content_left_to_keep_caret_in_view() {
2523        // Regression: when value width exceeds the viewport, the
2524        // inner clip group's `layout_override` shifts content left
2525        // by `head_px - viewport_w` so the caret pins to the right
2526        // edge of the visible area. Verify by laying out a long
2527        // value in a narrow text_input and checking the text
2528        // leaf's painted rect extends left of the outer's content
2529        // origin (i.e. negative-x relative to the outer's content
2530        // rect).
2531        use crate::tree::Size;
2532        let value = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2);
2533        let mut root = super::text_input(
2534            "ti",
2535            &value,
2536            &as_selection_in("ti", TextSelection::caret(value.len())),
2537        )
2538        .width(Size::Fixed(120.0));
2539        let mut ui_state = crate::state::UiState::new();
2540        crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2541
2542        // Find the text leaf (the Kind::Text under the inner Group).
2543        let inner = &root.children[0];
2544        let text_leaf = inner
2545            .children
2546            .iter()
2547            .find(|c| matches!(c.kind, Kind::Text))
2548            .expect("text leaf");
2549        let leaf_rect = text_leaf.computed_rect;
2550
2551        // The leaf's x must be left of the inner's content origin
2552        // (i.e. negative-relative) because the long content has
2553        // been scrolled left to keep the caret on the right edge.
2554        let inner_rect = inner.computed_rect;
2555        assert!(
2556            leaf_rect.x < inner_rect.x,
2557            "text leaf rect.x={} should be left of inner rect.x={} after \
2558             horizontal caret-into-view; layout did not shift content",
2559            leaf_rect.x,
2560            inner_rect.x,
2561        );
2562    }
2563
2564    #[test]
2565    fn short_value_does_not_shift_content() {
2566        // Counter-test: when value fits inside the viewport, no
2567        // x_offset is applied and the text leaf sits at the
2568        // inner's content origin.
2569        use crate::tree::Size;
2570        let mut root =
2571            super::text_input("ti", "hi", &as_selection_in("ti", TextSelection::caret(2)))
2572                .width(Size::Fixed(120.0));
2573        let mut ui_state = crate::state::UiState::new();
2574        crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2575
2576        let inner = &root.children[0];
2577        let text_leaf = inner
2578            .children
2579            .iter()
2580            .find(|c| matches!(c.kind, Kind::Text))
2581            .expect("text leaf");
2582        let leaf_rect = text_leaf.computed_rect;
2583        let inner_rect = inner.computed_rect;
2584        assert!(
2585            (leaf_rect.x - inner_rect.x).abs() < 0.5,
2586            "short value should not shift; got leaf.x={} inner.x={}",
2587            leaf_rect.x,
2588            inner_rect.x
2589        );
2590    }
2591
2592    /// Test helper: build a `Selection` with `(anchor, head)` under
2593    /// a single key.
2594    fn as_selection_in(key: &str, sel: TextSelection) -> Selection {
2595        Selection {
2596            range: Some(SelectionRange {
2597                anchor: SelectionPoint::new(key, sel.anchor),
2598                head: SelectionPoint::new(key, sel.head),
2599            }),
2600        }
2601    }
2602
2603    #[test]
2604    fn max_length_truncates_text_input_inserts() {
2605        let mut value = String::from("ab");
2606        let mut sel = TextSelection::caret(2);
2607        let opts = TextInputOpts::default().max_length(4);
2608        // "cdef" would push to 6 chars; only "cd" fits.
2609        assert!(apply_event_with(
2610            &mut value,
2611            &mut sel,
2612            &ev_text("cdef"),
2613            &opts
2614        ));
2615        assert_eq!(value, "abcd");
2616        assert_eq!(sel, TextSelection::caret(4));
2617        // A further insert is refused — there's no room.
2618        assert!(!apply_event_with(
2619            &mut value,
2620            &mut sel,
2621            &ev_text("z"),
2622            &opts
2623        ));
2624        assert_eq!(value, "abcd");
2625    }
2626
2627    #[test]
2628    fn max_length_replaces_selection_with_capacity_freed_by_removal() {
2629        // Replacing 3 chars with 5 chars at a 4-char cap: post_other = 0,
2630        // allowed = 4, replacement truncated to 4.
2631        let mut value = String::from("abc");
2632        let mut sel = TextSelection::range(0, 3); // whole value selected
2633        let opts = TextInputOpts::default().max_length(4);
2634        assert!(apply_event_with(
2635            &mut value,
2636            &mut sel,
2637            &ev_text("12345"),
2638            &opts
2639        ));
2640        assert_eq!(value, "1234");
2641        assert_eq!(sel, TextSelection::caret(4));
2642    }
2643
2644    #[test]
2645    fn replace_selection_with_max_length_clips_a_paste() {
2646        let mut value = String::from("ab");
2647        let mut sel = TextSelection::caret(2);
2648        let opts = TextInputOpts::default().max_length(5);
2649        // Paste 10 chars into a value already at 2/5; only 3 fit.
2650        let inserted = replace_selection_with(&mut value, &mut sel, "0123456789", &opts);
2651        assert_eq!(value, "ab012");
2652        assert_eq!(inserted, 3);
2653        assert_eq!(sel, TextSelection::caret(5));
2654    }
2655
2656    #[test]
2657    fn max_length_does_not_shrink_an_already_overlong_value() {
2658        // Caller is allowed to pass a value already longer than the cap;
2659        // the cap only constrains future inserts. Existing chars stay.
2660        let mut value = String::from("abcdef");
2661        let mut sel = TextSelection::caret(6);
2662        let opts = TextInputOpts::default().max_length(3);
2663        // No room for a new char.
2664        assert!(!apply_event_with(
2665            &mut value,
2666            &mut sel,
2667            &ev_text("z"),
2668            &opts
2669        ));
2670        assert_eq!(value, "abcdef");
2671        // But a delete still works — apply_event_with isn't gating
2672        // removals on max_length.
2673        assert!(apply_event_with(
2674            &mut value,
2675            &mut sel,
2676            &ev_key(LogicalKey::Named(NamedKey::Backspace)),
2677            &opts
2678        ));
2679        assert_eq!(value, "abcde");
2680    }
2681
2682    #[test]
2683    fn end_to_end_drag_select_through_runner_core() {
2684        // Lay out a tree with one text_input keyed "ti". Drive a
2685        // pointer_down + drag + pointer_up sequence through RunnerCore;
2686        // verify the resulting events fold into a non-empty selection.
2687        let mut value = String::from("hello world");
2688        let mut sel = TextSelection::default();
2689        let mut tree = crate::column([text_input(&value, sel).key("ti")]).padding(20.0);
2690        let mut core = RunnerCore::new();
2691        let mut state = UiState::new();
2692        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2693        core.ui_state = state;
2694        core.snapshot(&tree, &mut Default::default());
2695
2696        let rect = core.rect_of_key("ti").expect("ti rect");
2697        let down_x = rect.x + 8.0;
2698        let drag_x = rect.x + 80.0;
2699        let cy = rect.y + rect.h * 0.5;
2700
2701        core.pointer_moved(Pointer::moving(down_x, cy));
2702        let down = core
2703            .pointer_down(Pointer::mouse(down_x, cy, PointerButton::Primary))
2704            .into_iter()
2705            .find(|e| e.kind == UiEventKind::PointerDown)
2706            .expect("pointer_down emits PointerDown");
2707        assert!(apply_event(&mut value, &mut sel, &down));
2708
2709        let drag = core
2710            .pointer_moved(Pointer::moving(drag_x, cy))
2711            .events
2712            .into_iter()
2713            .find(|e| e.kind == UiEventKind::Drag)
2714            .expect("Drag while pressed");
2715        assert!(apply_event(&mut value, &mut sel, &drag));
2716
2717        let events = core.pointer_up(Pointer::mouse(drag_x, cy, PointerButton::Primary));
2718        for e in &events {
2719            apply_event(&mut value, &mut sel, e);
2720        }
2721        assert!(
2722            !sel.is_collapsed(),
2723            "expected drag-select to leave a non-empty selection"
2724        );
2725        assert_eq!(
2726            sel.anchor, 0,
2727            "anchor should sit at the down position (caret 0)"
2728        );
2729        assert!(
2730            sel.head > 0 && sel.head <= value.len(),
2731            "head={} value.len={}",
2732            sel.head,
2733            value.len()
2734        );
2735    }
2736
2737    // ---- Global-Selection integration ----
2738    //
2739    // The shimmed tests above exercise the local edit logic via the
2740    // `(value, &mut Selection, key, event)` API by routing through a
2741    // single fixed test key. The tests here verify the *integration*
2742    // semantics that only the post-migration API can express.
2743
2744    #[test]
2745    fn apply_event_writes_back_under_the_inputs_key() {
2746        // Type a character: the resulting range lives under "name".
2747        let mut value = String::new();
2748        let mut sel = Selection::default();
2749        let event = ev_text("h");
2750        assert!(super::apply_event(&mut value, &mut sel, &event, "name"));
2751        assert_eq!(value, "h");
2752        let r = sel.range.as_ref().expect("selection set");
2753        assert_eq!(r.anchor.key, "name");
2754        assert_eq!(r.head.key, "name");
2755        assert_eq!(r.head.byte, 1);
2756    }
2757
2758    #[test]
2759    fn apply_event_claims_selection_when_event_routed_from_elsewhere() {
2760        // Selection is currently in another key (e.g. a static text
2761        // paragraph). The user is focused on the "email" input and
2762        // types — the event arrives because the runtime routes
2763        // capture_keys events to the focused element. apply_event
2764        // claims the selection by writing back into the input's key.
2765        let mut value = String::new();
2766        let mut sel = Selection {
2767            range: Some(SelectionRange {
2768                anchor: SelectionPoint::new("para-a", 0),
2769                head: SelectionPoint::new("para-a", 5),
2770            }),
2771        };
2772        let event = ev_text("x");
2773        assert!(super::apply_event(&mut value, &mut sel, &event, "email"));
2774        assert_eq!(value, "x");
2775        let r = sel.range.as_ref().unwrap();
2776        assert_eq!(r.anchor.key, "email", "selection ownership migrated");
2777        assert_eq!(r.head.byte, 1);
2778    }
2779
2780    #[test]
2781    fn apply_event_leaves_selection_alone_when_event_is_unhandled() {
2782        // A KeyDown the input doesn't recognize (e.g. F-key) should
2783        // not perturb the global selection — even if it lives in
2784        // another key. apply_event returns false; we don't write back.
2785        let mut value = String::from("hi");
2786        let mut sel = Selection {
2787            range: Some(SelectionRange {
2788                anchor: SelectionPoint::new("para-a", 0),
2789                head: SelectionPoint::new("para-a", 3),
2790            }),
2791        };
2792        let event = ev_key(LogicalKey::Named(NamedKey::F1));
2793        assert!(!super::apply_event(&mut value, &mut sel, &event, "name"));
2794        // Selection unchanged.
2795        let r = sel.range.as_ref().unwrap();
2796        assert_eq!(r.anchor.key, "para-a");
2797        assert_eq!(r.head.byte, 3);
2798    }
2799
2800    #[test]
2801    fn text_input_renders_caret_at_local_byte_when_selection_is_within_key() {
2802        let sel = Selection::caret("name", 2);
2803        let el = super::text_input("name", "hello", &sel);
2804        // Builder set the El's key.
2805        assert_eq!(el.key.as_deref(), Some("name"));
2806        // Caret child translates to the prefix width of "he".
2807        let caret = content_children(&el)
2808            .iter()
2809            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2810            .expect("caret child");
2811        let expected = metrics::line_width("he", tokens::TEXT_SM.size, FontWeight::Regular, false);
2812        assert!(
2813            (caret.translate.0 - expected).abs() < 0.01,
2814            "caret.x={} expected {}",
2815            caret.translate.0,
2816            expected
2817        );
2818    }
2819
2820    #[test]
2821    fn tabular_opts_move_caret_and_value_leaf_together() {
2822        // A tnum field must place the caret with tabular advances and
2823        // mark the rendered leaf tabular — half-threading either way
2824        // makes the caret drift from the glyphs as digits change.
2825        let sel = Selection::caret("qty", 3);
2826        let value = "1111";
2827        let el = super::text_input_with(
2828            "qty",
2829            value,
2830            &sel,
2831            TextInputOpts::default().tabular_numerals(),
2832        );
2833
2834        let leaf = content_children(&el)
2835            .iter()
2836            .find(|c| matches!(c.kind, Kind::Text))
2837            .cloned()
2838            .expect("value leaf");
2839        assert!(
2840            leaf.text_tabular_numerals,
2841            "value leaf must render with tabular numerals"
2842        );
2843
2844        let caret = content_children(&el)
2845            .iter()
2846            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2847            .cloned()
2848            .expect("caret child");
2849        let tabular_prefix = crate::text::metrics::layout_text_with_family(
2850            "111",
2851            tokens::TEXT_SM.size,
2852            crate::tree::FontFamily::default(),
2853            FontWeight::Regular,
2854            false,
2855            true,
2856            TextWrap::NoWrap,
2857            None,
2858        )
2859        .width;
2860        assert!(
2861            (caret.translate.0 - tabular_prefix).abs() < 0.01,
2862            "caret.x={} expected tabular prefix width {}",
2863            caret.translate.0,
2864            tabular_prefix
2865        );
2866
2867        // Sanity: tnum actually changes Inter's digit advances —
2868        // otherwise this test can't distinguish the two paths.
2869        let proportional_prefix =
2870            metrics::line_width("111", tokens::TEXT_SM.size, FontWeight::Regular, false);
2871        assert!(
2872            (tabular_prefix - proportional_prefix).abs() > 0.5,
2873            "tabular ({tabular_prefix}) and proportional ({proportional_prefix}) \
2874             '111' widths must differ for this test to have teeth"
2875        );
2876    }
2877
2878    #[test]
2879    fn text_input_omits_caret_when_selection_lives_elsewhere() {
2880        // When the active selection lives in another widget, this
2881        // input emits neither a band nor a caret. Without the caret
2882        // gate, blurring an input by clicking into another would
2883        // visibly snap this caret to byte 0 for the duration of the
2884        // focus-envelope fade-out — read by the user as the caret
2885        // jumping home before vanishing.
2886        let sel = Selection {
2887            range: Some(SelectionRange {
2888                anchor: SelectionPoint::new("other", 0),
2889                head: SelectionPoint::new("other", 5),
2890            }),
2891        };
2892        let el = super::text_input("name", "hello", &sel);
2893        let band = el
2894            .children
2895            .iter()
2896            .find(|c| matches!(c.kind, Kind::Custom("text_input_selection")));
2897        assert!(band.is_none(), "no band when selection lives elsewhere");
2898        let caret = el
2899            .children
2900            .iter()
2901            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")));
2902        assert!(
2903            caret.is_none(),
2904            "no caret when selection lives elsewhere — focus-fade has nothing to bring back to byte 0"
2905        );
2906    }
2907
2908    fn ctrl_mods() -> KeyModifiers {
2909        KeyModifiers {
2910            ctrl: true,
2911            ..Default::default()
2912        }
2913    }
2914
2915    fn ctrl_shift_mods() -> KeyModifiers {
2916        KeyModifiers {
2917            ctrl: true,
2918            shift: true,
2919            ..Default::default()
2920        }
2921    }
2922
2923    #[test]
2924    fn ctrl_backspace_deletes_previous_word() {
2925        let mut value = String::from("hello world foo");
2926        let mut sel = TextSelection::caret(value.len());
2927        assert!(apply_event(
2928            &mut value,
2929            &mut sel,
2930            &ev_key_with_mods(LogicalKey::Named(NamedKey::Backspace), ctrl_mods())
2931        ));
2932        assert_eq!(value, "hello world ");
2933        assert_eq!(sel, TextSelection::caret(value.len()));
2934    }
2935
2936    #[test]
2937    fn ctrl_backspace_at_caret_zero_is_noop() {
2938        let mut value = String::from("hello");
2939        let mut sel = TextSelection::caret(0);
2940        assert!(!apply_event(
2941            &mut value,
2942            &mut sel,
2943            &ev_key_with_mods(LogicalKey::Named(NamedKey::Backspace), ctrl_mods())
2944        ));
2945        assert_eq!(value, "hello");
2946    }
2947
2948    #[test]
2949    fn ctrl_w_deletes_previous_word_like_terminal() {
2950        let mut value = String::from("alpha beta gamma");
2951        let mut sel = TextSelection::caret(value.len());
2952        assert!(apply_event(
2953            &mut value,
2954            &mut sel,
2955            &ev_key_with_mods(LogicalKey::Character("w".into()), ctrl_mods())
2956        ));
2957        assert_eq!(value, "alpha beta ");
2958    }
2959
2960    #[test]
2961    fn ctrl_delete_deletes_next_word() {
2962        let mut value = String::from("alpha beta gamma");
2963        let mut sel = TextSelection::caret(0);
2964        assert!(apply_event(
2965            &mut value,
2966            &mut sel,
2967            &ev_key_with_mods(LogicalKey::Named(NamedKey::Delete), ctrl_mods())
2968        ));
2969        assert_eq!(value, " beta gamma");
2970        assert_eq!(sel, TextSelection::caret(0));
2971    }
2972
2973    #[test]
2974    fn ctrl_arrow_left_jumps_word_backward() {
2975        let mut value = String::from("alpha beta gamma");
2976        let mut sel = TextSelection::caret(value.len());
2977        assert!(apply_event(
2978            &mut value,
2979            &mut sel,
2980            &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowLeft), ctrl_mods())
2981        ));
2982        // Skip back over "gamma" → caret lands at start of "gamma" (byte 11).
2983        assert_eq!(sel, TextSelection::caret(11));
2984    }
2985
2986    #[test]
2987    fn ctrl_arrow_right_jumps_word_forward() {
2988        let mut value = String::from("alpha beta gamma");
2989        let mut sel = TextSelection::caret(0);
2990        assert!(apply_event(
2991            &mut value,
2992            &mut sel,
2993            &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), ctrl_mods())
2994        ));
2995        // Skip forward past "alpha" → caret at byte 5.
2996        assert_eq!(sel, TextSelection::caret(5));
2997    }
2998
2999    #[test]
3000    fn ctrl_shift_arrow_extends_selection_by_word() {
3001        let mut value = String::from("alpha beta gamma");
3002        let mut sel = TextSelection::caret(0);
3003        assert!(apply_event(
3004            &mut value,
3005            &mut sel,
3006            &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), ctrl_shift_mods())
3007        ));
3008        assert_eq!(sel, TextSelection::range(0, 5));
3009        assert!(apply_event(
3010            &mut value,
3011            &mut sel,
3012            &ev_key_with_mods(LogicalKey::Named(NamedKey::ArrowRight), ctrl_shift_mods())
3013        ));
3014        assert_eq!(sel, TextSelection::range(0, 10));
3015    }
3016}