Skip to main content

damascene_core/widgets/
text_input.rs

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