Skip to main content

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