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, 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            kind: UiEventKind::TextInput,
1089        }
1090    }
1091
1092    fn ev_key(key: UiKey) -> UiEvent {
1093        ev_key_with_mods(key, KeyModifiers::default())
1094    }
1095
1096    fn ev_key_with_mods(key: UiKey, modifiers: KeyModifiers) -> UiEvent {
1097        UiEvent {
1098            path: None,
1099            key: None,
1100            target: None,
1101            pointer: None,
1102            key_press: Some(KeyPress {
1103                key,
1104                modifiers,
1105                repeat: false,
1106            }),
1107            text: None,
1108            selection: None,
1109            modifiers,
1110            click_count: 0,
1111            kind: UiEventKind::KeyDown,
1112        }
1113    }
1114
1115    fn ev_pointer_down(target: UiTarget, pointer: (f32, f32), modifiers: KeyModifiers) -> UiEvent {
1116        ev_pointer_down_with_count(target, pointer, modifiers, 1)
1117    }
1118
1119    fn ev_pointer_down_with_count(
1120        target: UiTarget,
1121        pointer: (f32, f32),
1122        modifiers: KeyModifiers,
1123        click_count: u8,
1124    ) -> UiEvent {
1125        UiEvent {
1126            path: None,
1127            key: Some(target.key.clone()),
1128            target: Some(target),
1129            pointer: Some(pointer),
1130            key_press: None,
1131            text: None,
1132            selection: None,
1133            modifiers,
1134            click_count,
1135            kind: UiEventKind::PointerDown,
1136        }
1137    }
1138
1139    fn ev_drag(target: UiTarget, pointer: (f32, f32)) -> UiEvent {
1140        ev_drag_with_count(target, pointer, 0)
1141    }
1142
1143    fn ev_drag_with_count(target: UiTarget, pointer: (f32, f32), click_count: u8) -> UiEvent {
1144        UiEvent {
1145            path: None,
1146            key: Some(target.key.clone()),
1147            target: Some(target),
1148            pointer: Some(pointer),
1149            key_press: None,
1150            text: None,
1151            selection: None,
1152            modifiers: KeyModifiers::default(),
1153            click_count,
1154            kind: UiEventKind::Drag,
1155        }
1156    }
1157
1158    fn ev_middle_click(target: UiTarget, pointer: (f32, f32), text: Option<&str>) -> UiEvent {
1159        UiEvent {
1160            path: None,
1161            key: Some(target.key.clone()),
1162            target: Some(target),
1163            pointer: Some(pointer),
1164            key_press: None,
1165            text: text.map(str::to_string),
1166            selection: None,
1167            modifiers: KeyModifiers::default(),
1168            click_count: 1,
1169            kind: UiEventKind::MiddleClick,
1170        }
1171    }
1172
1173    fn ti_target() -> UiTarget {
1174        UiTarget {
1175            key: "ti".into(),
1176            node_id: "root.text_input[ti]".into(),
1177            rect: Rect::new(20.0, 20.0, 400.0, 36.0),
1178            tooltip: None,
1179            scroll_offset_y: 0.0,
1180        }
1181    }
1182
1183    /// Return the visual content children of a built text_input —
1184    /// selection band(s), placeholder, text leaf, and caret bar.
1185    /// The widget wraps these in an inner clipping group that
1186    /// applies horizontal caret-into-view via `layout_override`, so
1187    /// `el.children` itself is `[inner_group]` and the real content
1188    /// children live one level deeper. This helper keeps the
1189    /// existing assertions concise.
1190    fn content_children(el: &El) -> &[El] {
1191        assert_eq!(
1192            el.children.len(),
1193            1,
1194            "text_input wraps its content in a single inner group"
1195        );
1196        &el.children[0].children
1197    }
1198
1199    #[test]
1200    fn text_input_collapsed_renders_value_as_single_text_leaf_plus_caret() {
1201        let el = text_input("hello", TextSelection::caret(2));
1202        assert!(matches!(el.kind, Kind::Custom("text_input")));
1203        assert!(el.focusable);
1204        assert!(el.capture_keys);
1205        // Content: [0] = text leaf with the full value, [1] = caret
1206        // bar. (The outer wraps these in a single inner clip group
1207        // for horizontal caret-into-view; see `content_children`.)
1208        let cs = content_children(&el);
1209        assert_eq!(cs.len(), 2);
1210        assert!(matches!(cs[0].kind, Kind::Text));
1211        assert_eq!(cs[0].text.as_deref(), Some("hello"));
1212        assert!(matches!(cs[1].kind, Kind::Custom("text_input_caret")));
1213        assert!(cs[1].alpha_follows_focused_ancestor);
1214    }
1215
1216    #[test]
1217    fn text_input_declares_text_cursor() {
1218        let el = text_input("hello", TextSelection::caret(0));
1219        assert_eq!(el.cursor, Some(Cursor::Text));
1220    }
1221
1222    #[test]
1223    fn text_input_with_selection_inserts_selection_band_first() {
1224        // anchor=2, head=4 → selection "ll", head at right edge.
1225        let el = text_input("hello", TextSelection::range(2, 4));
1226        let cs = content_children(&el);
1227        // [0] = selection band, [1] = full-value text leaf, [2] = caret.
1228        assert_eq!(cs.len(), 3);
1229        assert!(matches!(cs[0].kind, Kind::Custom("text_input_selection")));
1230        assert_eq!(cs[1].text.as_deref(), Some("hello"));
1231        assert!(matches!(cs[2].kind, Kind::Custom("text_input_caret")));
1232    }
1233
1234    #[test]
1235    fn text_input_caret_translate_advances_with_head() {
1236        // The caret's translate.x grows with the head's byte index.
1237        // Use line_width as ground truth; caret should be measured from
1238        // the start of the value to head.
1239        use crate::text::metrics::line_width;
1240        let value = "hello";
1241        let head = 3;
1242        let el = text_input(value, TextSelection::caret(head));
1243        let caret = content_children(&el)
1244            .iter()
1245            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1246            .expect("caret child");
1247        let expected = line_width(
1248            &value[..head],
1249            tokens::TEXT_SM.size,
1250            FontWeight::Regular,
1251            false,
1252        );
1253        assert!(
1254            (caret.translate.0 - expected).abs() < 0.01,
1255            "caret translate.x = {}, expected {}",
1256            caret.translate.0,
1257            expected
1258        );
1259    }
1260
1261    #[test]
1262    fn text_input_clamps_off_utf8_boundary() {
1263        // 'é' is two bytes; head=1 sits inside the codepoint and must
1264        // snap back to 0. The single text leaf still renders the whole
1265        // value; only the caret offset reflects the snap.
1266        let el = text_input("é", TextSelection::caret(1));
1267        let cs = content_children(&el);
1268        assert_eq!(cs[0].text.as_deref(), Some("é"));
1269        let caret = cs
1270            .iter()
1271            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
1272            .expect("caret child");
1273        // caret head clamped to 0 → translate.x = 0.
1274        assert!(caret.translate.0.abs() < 0.01);
1275    }
1276
1277    #[test]
1278    fn selection_band_fill_dims_when_input_unfocused() {
1279        // When the input lacks focus, the band paints in
1280        // SELECTION_BG_UNFOCUSED. As focus animates in, dim_fill lerps
1281        // the painted color toward SELECTION_BG.
1282        use crate::draw_ops::draw_ops;
1283        use crate::ir::DrawOp;
1284        use crate::shader::UniformValue;
1285        use crate::state::AnimationMode;
1286        use web_time::Instant;
1287
1288        let mut tree = crate::column([text_input("hello", TextSelection::range(0, 5)).key("ti")])
1289            .padding(20.0);
1290        let mut state = UiState::new();
1291        state.set_animation_mode(AnimationMode::Settled);
1292        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1293        state.sync_focus_order(&tree);
1294
1295        // Unfocused: focus envelope settles to 0 → band fill matches
1296        // SELECTION_BG_UNFOCUSED rgb (alpha is multiplied by `opacity`
1297        // so we compare rgb only).
1298        state.apply_to_state();
1299        state.tick_visual_animations(&mut tree, Instant::now());
1300        let unfocused = band_fill(&tree, &state).expect("band quad emitted");
1301        assert_eq!(
1302            (unfocused.r, unfocused.g, unfocused.b),
1303            (
1304                tokens::SELECTION_BG_UNFOCUSED.r,
1305                tokens::SELECTION_BG_UNFOCUSED.g,
1306                tokens::SELECTION_BG_UNFOCUSED.b
1307            ),
1308            "unfocused → band rgb is the muted token"
1309        );
1310
1311        // Focused: focus envelope settles to 1 → band fill matches
1312        // SELECTION_BG.
1313        let target = state
1314            .focus
1315            .order
1316            .iter()
1317            .find(|t| t.key == "ti")
1318            .expect("ti in focus order")
1319            .clone();
1320        state.set_focus(Some(target));
1321        state.apply_to_state();
1322        state.tick_visual_animations(&mut tree, Instant::now());
1323        let focused = band_fill(&tree, &state).expect("band quad emitted");
1324        assert_eq!(
1325            (focused.r, focused.g, focused.b),
1326            (
1327                tokens::SELECTION_BG.r,
1328                tokens::SELECTION_BG.g,
1329                tokens::SELECTION_BG.b
1330            ),
1331            "focused → band rgb is the saturated token"
1332        );
1333
1334        fn band_fill(tree: &El, state: &UiState) -> Option<crate::tree::Color> {
1335            let ops = draw_ops(tree, state);
1336            for op in ops {
1337                if let DrawOp::Quad { id, uniforms, .. } = op
1338                    && id.contains("text_input_selection")
1339                    && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1340                {
1341                    return Some(*c);
1342                }
1343            }
1344            None
1345        }
1346    }
1347
1348    #[test]
1349    fn caret_alpha_follows_focus_envelope() {
1350        // The caret bar paints with full alpha when the input is
1351        // focused (envelope = 1) and zero alpha when it isn't
1352        // (envelope = 0). This is what hides the caret in unfocused
1353        // inputs without any app-side focus tracking.
1354        use crate::draw_ops::draw_ops;
1355        use crate::ir::DrawOp;
1356        use crate::shader::UniformValue;
1357        use crate::state::AnimationMode;
1358        use web_time::Instant;
1359
1360        let mut tree =
1361            crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1362        let mut state = UiState::new();
1363        state.set_animation_mode(AnimationMode::Settled);
1364        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1365        state.sync_focus_order(&tree);
1366
1367        // Initially unfocused: focus envelope settles to 0.
1368        state.apply_to_state();
1369        state.tick_visual_animations(&mut tree, Instant::now());
1370        let caret_alpha = caret_fill_alpha(&tree, &state);
1371        assert_eq!(caret_alpha, Some(0), "unfocused → caret invisible");
1372
1373        // Focus the input: focus envelope settles to 1.
1374        let target = state
1375            .focus
1376            .order
1377            .iter()
1378            .find(|t| t.key == "ti")
1379            .expect("ti in focus order")
1380            .clone();
1381        state.set_focus(Some(target));
1382        state.apply_to_state();
1383        state.tick_visual_animations(&mut tree, Instant::now());
1384        let caret_alpha = caret_fill_alpha(&tree, &state);
1385        assert_eq!(
1386            caret_alpha,
1387            Some(255),
1388            "focused → caret fully visible (alpha=255)"
1389        );
1390
1391        fn caret_fill_alpha(tree: &El, state: &UiState) -> Option<u8> {
1392            let ops = draw_ops(tree, state);
1393            for op in ops {
1394                if let DrawOp::Quad { id, uniforms, .. } = op
1395                    && id.contains("text_input_caret")
1396                    && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1397                {
1398                    return Some(c.a);
1399                }
1400            }
1401            None
1402        }
1403    }
1404
1405    #[test]
1406    fn caret_blink_alpha_holds_solid_through_grace_then_cycles() {
1407        // The blink helper is deterministic on input duration; this
1408        // test pins the cycle shape we paint with.
1409        use crate::state::caret_blink_alpha_for;
1410        use std::time::Duration;
1411        // Inside the 500ms grace window → solid.
1412        assert_eq!(caret_blink_alpha_for(Duration::from_millis(0)), 1.0);
1413        assert_eq!(caret_blink_alpha_for(Duration::from_millis(499)), 1.0);
1414        // Past grace, first half of the 1060ms period → on.
1415        assert_eq!(caret_blink_alpha_for(Duration::from_millis(500)), 1.0);
1416        assert_eq!(caret_blink_alpha_for(Duration::from_millis(1029)), 1.0);
1417        // Second half → off.
1418        assert_eq!(caret_blink_alpha_for(Duration::from_millis(1030)), 0.0);
1419        assert_eq!(caret_blink_alpha_for(Duration::from_millis(1559)), 0.0);
1420        // Back to on for the next cycle.
1421        assert_eq!(caret_blink_alpha_for(Duration::from_millis(1560)), 1.0);
1422    }
1423
1424    #[test]
1425    fn caret_paint_alpha_blinks_after_focus_in_live_mode() {
1426        // Drive the tick at staged Instants so we hit each phase of
1427        // the blink cycle; verifies the painter actually multiplies
1428        // the caret bar's alpha by ui_state.caret.blink_alpha.
1429        use crate::draw_ops::draw_ops;
1430        use crate::ir::DrawOp;
1431        use crate::shader::UniformValue;
1432        use crate::state::AnimationMode;
1433        use std::time::Duration;
1434
1435        let mut tree =
1436            crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1437        let mut state = UiState::new();
1438        state.set_animation_mode(AnimationMode::Live);
1439        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1440        state.sync_focus_order(&tree);
1441
1442        // Focus the input — set_focus bumps caret activity.
1443        let target = state
1444            .focus
1445            .order
1446            .iter()
1447            .find(|t| t.key == "ti")
1448            .unwrap()
1449            .clone();
1450        state.set_focus(Some(target));
1451        let activity_at = state.caret.activity_at.expect("set_focus bumps activity");
1452        let input_id = tree.children[0].computed_id.clone();
1453
1454        // Pin focus envelope after each tick so the caret's
1455        // focus-fade contribution is out of the picture and we can
1456        // attribute alpha changes purely to the blink.
1457        let pin_focus = |state: &mut UiState| {
1458            state.animation.envelopes.insert(
1459                (input_id.clone(), crate::state::EnvelopeKind::FocusRing),
1460                1.0,
1461            );
1462        };
1463
1464        // t = 0 → grace, on.
1465        state.tick_visual_animations(&mut tree, activity_at);
1466        pin_focus(&mut state);
1467        assert_eq!(caret_alpha(&tree, &state), Some(255));
1468
1469        // t = 1100ms → second half of cycle, off.
1470        state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1100));
1471        pin_focus(&mut state);
1472        assert_eq!(caret_alpha(&tree, &state), Some(0));
1473
1474        // t = 1600ms → back on.
1475        state.tick_visual_animations(&mut tree, activity_at + Duration::from_millis(1600));
1476        pin_focus(&mut state);
1477        assert_eq!(caret_alpha(&tree, &state), Some(255));
1478
1479        fn caret_alpha(tree: &El, state: &UiState) -> Option<u8> {
1480            for op in draw_ops(tree, state) {
1481                if let DrawOp::Quad { id, uniforms, .. } = op
1482                    && id.contains("text_input_caret")
1483                    && let Some(UniformValue::Color(c)) = uniforms.get("fill")
1484                {
1485                    return Some(c.a);
1486                }
1487            }
1488            None
1489        }
1490    }
1491
1492    #[test]
1493    fn caret_blink_resumes_solid_after_selection_change() {
1494        // Editing (selection change) bumps activity, which puts the
1495        // caret back into the grace window even mid-cycle.
1496        use crate::state::AnimationMode;
1497        use std::time::Duration;
1498        use web_time::Instant;
1499
1500        let mut tree =
1501            crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1502        let mut state = UiState::new();
1503        state.set_animation_mode(AnimationMode::Live);
1504        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1505        state.sync_focus_order(&tree);
1506
1507        // Drive activity to deep into the off phase.
1508        let t0 = Instant::now();
1509        state.bump_caret_activity(t0);
1510        state.tick_visual_animations(&mut tree, t0 + Duration::from_millis(1100));
1511        assert_eq!(state.caret.blink_alpha, 0.0, "deep in off phase");
1512
1513        // Re-bump (e.g. user typed) — alpha snaps back to solid.
1514        state.bump_caret_activity(t0 + Duration::from_millis(1100));
1515        assert_eq!(state.caret.blink_alpha, 1.0, "fresh activity → solid");
1516    }
1517
1518    #[test]
1519    fn caret_tick_requests_redraw_while_capture_keys_node_focused() {
1520        // Without this, the host's animation loop wouldn't keep
1521        // pumping frames during idle, and the caret would freeze
1522        // mid-blink.
1523        use crate::state::AnimationMode;
1524        use web_time::Instant;
1525
1526        let mut tree =
1527            crate::column([text_input("hi", TextSelection::caret(0)).key("ti")]).padding(20.0);
1528        let mut state = UiState::new();
1529        state.set_animation_mode(AnimationMode::Live);
1530        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
1531        state.sync_focus_order(&tree);
1532
1533        // No focus → no redraw demand from blink.
1534        let no_focus = state.tick_visual_animations(&mut tree, Instant::now());
1535        assert!(!no_focus, "without focus, blink doesn't request redraws");
1536
1537        // Focus the input → tick should keep requesting redraws so
1538        // the on/off cycle keeps animating.
1539        let target = state
1540            .focus
1541            .order
1542            .iter()
1543            .find(|t| t.key == "ti")
1544            .unwrap()
1545            .clone();
1546        state.set_focus(Some(target));
1547        let focused = state.tick_visual_animations(&mut tree, Instant::now());
1548        assert!(focused, "focused capture_keys node → tick demands redraws");
1549    }
1550
1551    #[test]
1552    fn apply_text_input_inserts_at_caret_when_collapsed() {
1553        let mut value = String::from("ho");
1554        let mut sel = TextSelection::caret(1);
1555        assert!(apply_event(&mut value, &mut sel, &ev_text("i, t")));
1556        assert_eq!(value, "hi, to");
1557        assert_eq!(sel, TextSelection::caret(5));
1558    }
1559
1560    #[test]
1561    fn apply_text_input_replaces_selection() {
1562        let mut value = String::from("hello world");
1563        let mut sel = TextSelection::range(6, 11); // "world"
1564        assert!(apply_event(&mut value, &mut sel, &ev_text("kit")));
1565        assert_eq!(value, "hello kit");
1566        assert_eq!(sel, TextSelection::caret(9));
1567    }
1568
1569    #[test]
1570    fn apply_backspace_removes_selection_when_non_empty() {
1571        let mut value = String::from("hello world");
1572        let mut sel = TextSelection::range(6, 11);
1573        assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Backspace)));
1574        assert_eq!(value, "hello ");
1575        assert_eq!(sel, TextSelection::caret(6));
1576    }
1577
1578    #[test]
1579    fn apply_delete_removes_selection_when_non_empty() {
1580        let mut value = String::from("hello world");
1581        let mut sel = TextSelection::range(0, 6); // "hello "
1582        assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Delete)));
1583        assert_eq!(value, "world");
1584        assert_eq!(sel, TextSelection::caret(0));
1585    }
1586
1587    #[test]
1588    fn apply_escape_collapses_selection_without_editing() {
1589        let mut value = String::from("hello");
1590        let mut sel = TextSelection::range(1, 4);
1591        assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1592        assert_eq!(value, "hello");
1593        assert_eq!(sel, TextSelection::caret(4));
1594        assert!(!apply_event(&mut value, &mut sel, &ev_key(UiKey::Escape)));
1595    }
1596
1597    #[test]
1598    fn apply_backspace_collapsed_at_start_is_noop() {
1599        let mut value = String::from("hi");
1600        let mut sel = TextSelection::caret(0);
1601        assert!(!apply_event(
1602            &mut value,
1603            &mut sel,
1604            &ev_key(UiKey::Backspace)
1605        ));
1606    }
1607
1608    #[test]
1609    fn apply_arrow_walks_utf8_boundaries() {
1610        let mut value = String::from("aé");
1611        let mut sel = TextSelection::caret(0);
1612        apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1613        assert_eq!(sel.head, 1);
1614        apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowRight));
1615        assert_eq!(sel.head, 3);
1616        assert!(!apply_event(
1617            &mut value,
1618            &mut sel,
1619            &ev_key(UiKey::ArrowRight)
1620        ));
1621        apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft));
1622        assert_eq!(sel.head, 1);
1623    }
1624
1625    #[test]
1626    fn apply_arrow_collapses_selection_without_shift() {
1627        let mut value = String::from("hello");
1628        let mut sel = TextSelection::range(1, 4); // "ell"
1629        // ArrowLeft (no shift) collapses to the LEFT edge of the
1630        // selection (the smaller of anchor/head).
1631        assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::ArrowLeft)));
1632        assert_eq!(sel, TextSelection::caret(1));
1633
1634        let mut sel = TextSelection::range(1, 4);
1635        // ArrowRight (no shift) collapses to the RIGHT edge.
1636        assert!(apply_event(
1637            &mut value,
1638            &mut sel,
1639            &ev_key(UiKey::ArrowRight)
1640        ));
1641        assert_eq!(sel, TextSelection::caret(4));
1642    }
1643
1644    #[test]
1645    fn apply_shift_arrow_extends_selection() {
1646        let mut value = String::from("hello");
1647        let mut sel = TextSelection::caret(2);
1648        let shift = KeyModifiers {
1649            shift: true,
1650            ..Default::default()
1651        };
1652        assert!(apply_event(
1653            &mut value,
1654            &mut sel,
1655            &ev_key_with_mods(UiKey::ArrowRight, shift)
1656        ));
1657        assert_eq!(sel, TextSelection::range(2, 3));
1658        assert!(apply_event(
1659            &mut value,
1660            &mut sel,
1661            &ev_key_with_mods(UiKey::ArrowRight, shift)
1662        ));
1663        assert_eq!(sel, TextSelection::range(2, 4));
1664        // Shift+ArrowLeft retreats the head, anchor stays.
1665        assert!(apply_event(
1666            &mut value,
1667            &mut sel,
1668            &ev_key_with_mods(UiKey::ArrowLeft, shift)
1669        ));
1670        assert_eq!(sel, TextSelection::range(2, 3));
1671    }
1672
1673    #[test]
1674    fn apply_home_end_collapse_or_extend() {
1675        let mut value = String::from("hello");
1676        let mut sel = TextSelection::caret(2);
1677        assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::End)));
1678        assert_eq!(sel, TextSelection::caret(5));
1679        assert!(apply_event(&mut value, &mut sel, &ev_key(UiKey::Home)));
1680        assert_eq!(sel, TextSelection::caret(0));
1681
1682        // Shift+End extends.
1683        let shift = KeyModifiers {
1684            shift: true,
1685            ..Default::default()
1686        };
1687        let mut sel = TextSelection::caret(2);
1688        assert!(apply_event(
1689            &mut value,
1690            &mut sel,
1691            &ev_key_with_mods(UiKey::End, shift)
1692        ));
1693        assert_eq!(sel, TextSelection::range(2, 5));
1694    }
1695
1696    #[test]
1697    fn apply_ctrl_a_selects_all() {
1698        let mut value = String::from("hello");
1699        let mut sel = TextSelection::caret(2);
1700        let ctrl = KeyModifiers {
1701            ctrl: true,
1702            ..Default::default()
1703        };
1704        assert!(apply_event(
1705            &mut value,
1706            &mut sel,
1707            &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1708        ));
1709        assert_eq!(sel, TextSelection::range(0, 5));
1710        // A second Ctrl+A is a no-op.
1711        assert!(!apply_event(
1712            &mut value,
1713            &mut sel,
1714            &ev_key_with_mods(UiKey::Character("a".into()), ctrl)
1715        ));
1716    }
1717
1718    #[test]
1719    fn apply_pointer_down_sets_anchor_and_head() {
1720        let mut value = String::from("hello");
1721        let mut sel = TextSelection::range(0, 5);
1722        // Click far-left should collapse to caret=0.
1723        let down = ev_pointer_down(
1724            ti_target(),
1725            (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1726            KeyModifiers::default(),
1727        );
1728        assert!(apply_event(&mut value, &mut sel, &down));
1729        assert_eq!(sel, TextSelection::caret(0));
1730    }
1731
1732    #[test]
1733    fn apply_double_click_selects_word_at_caret() {
1734        let mut value = String::from("hello world");
1735        let mut sel = TextSelection::caret(0);
1736        // Click somewhere inside "world" with click_count = 2.
1737        let target = ti_target();
1738        let click_x = target.rect.x
1739            + tokens::SPACE_3
1740            + crate::text::metrics::line_width(
1741                "hello w",
1742                tokens::TEXT_SM.size,
1743                FontWeight::Regular,
1744                false,
1745            );
1746        let down = ev_pointer_down_with_count(
1747            target.clone(),
1748            (click_x, target.rect.y + 18.0),
1749            KeyModifiers::default(),
1750            2,
1751        );
1752        assert!(apply_event(&mut value, &mut sel, &down));
1753        // "world" sits at bytes 6..11.
1754        assert_eq!(sel.anchor, 6);
1755        assert_eq!(sel.head, 11);
1756    }
1757
1758    #[test]
1759    fn apply_triple_click_selects_all() {
1760        let mut value = String::from("hello world");
1761        let mut sel = TextSelection::caret(0);
1762        let target = ti_target();
1763        let down = ev_pointer_down_with_count(
1764            target.clone(),
1765            (target.rect.x + 1.0, target.rect.y + 18.0),
1766            KeyModifiers::default(),
1767            3,
1768        );
1769        assert!(apply_event(&mut value, &mut sel, &down));
1770        assert_eq!(sel.anchor, 0);
1771        assert_eq!(sel.head, value.len());
1772    }
1773
1774    #[test]
1775    fn apply_shift_double_click_falls_back_to_extend_not_word_select() {
1776        // Shift + double-click extends the existing selection rather
1777        // than replacing it with the word — matching browser behavior.
1778        let mut value = String::from("hello world");
1779        let mut sel = TextSelection::caret(0);
1780        let target = ti_target();
1781        let click_x = target.rect.x
1782            + tokens::SPACE_3
1783            + crate::text::metrics::line_width(
1784                "hello w",
1785                tokens::TEXT_SM.size,
1786                FontWeight::Regular,
1787                false,
1788            );
1789        let shift = KeyModifiers {
1790            shift: true,
1791            ..Default::default()
1792        };
1793        let down =
1794            ev_pointer_down_with_count(target.clone(), (click_x, target.rect.y + 18.0), shift, 2);
1795        assert!(apply_event(&mut value, &mut sel, &down));
1796        // anchor unchanged at 0; head moved to the click position.
1797        assert_eq!(sel.anchor, 0);
1798        assert!(sel.head > 0 && sel.head < value.len());
1799    }
1800
1801    #[test]
1802    fn apply_shift_pointer_down_only_moves_head() {
1803        let mut value = String::from("hello");
1804        let mut sel = TextSelection::caret(2);
1805        let shift = KeyModifiers {
1806            shift: true,
1807            ..Default::default()
1808        };
1809        // Click far-right with shift: head goes to end, anchor stays.
1810        let down = ev_pointer_down(
1811            ti_target(),
1812            (
1813                ti_target().rect.x + ti_target().rect.w - 4.0,
1814                ti_target().rect.y + 18.0,
1815            ),
1816            shift,
1817        );
1818        assert!(apply_event(&mut value, &mut sel, &down));
1819        assert_eq!(sel.anchor, 2);
1820        assert_eq!(sel.head, value.len());
1821    }
1822
1823    #[test]
1824    fn apply_drag_extends_head_only() {
1825        let mut value = String::from("hello world");
1826        let mut sel = TextSelection::caret(0);
1827        // First, pointer-down at the start.
1828        let down = ev_pointer_down(
1829            ti_target(),
1830            (ti_target().rect.x + 1.0, ti_target().rect.y + 18.0),
1831            KeyModifiers::default(),
1832        );
1833        apply_event(&mut value, &mut sel, &down);
1834        assert_eq!(sel, TextSelection::caret(0));
1835        // Drag to the right edge — head extends, anchor stays at 0.
1836        let drag = ev_drag(
1837            ti_target(),
1838            (
1839                ti_target().rect.x + ti_target().rect.w - 4.0,
1840                ti_target().rect.y + 18.0,
1841            ),
1842        );
1843        assert!(apply_event(&mut value, &mut sel, &drag));
1844        assert_eq!(sel.anchor, 0);
1845        assert_eq!(sel.head, value.len());
1846    }
1847
1848    #[test]
1849    fn double_click_hold_drag_inside_word_keeps_word_selected() {
1850        let mut value = String::from("hello world");
1851        let mut sel = TextSelection::caret(0);
1852        let target = ti_target();
1853        let click_x = target.rect.x
1854            + tokens::SPACE_3
1855            + crate::text::metrics::line_width(
1856                "hello w",
1857                tokens::TEXT_SM.size,
1858                FontWeight::Regular,
1859                false,
1860            );
1861        let down = ev_pointer_down_with_count(
1862            target.clone(),
1863            (click_x, target.rect.y + 18.0),
1864            KeyModifiers::default(),
1865            2,
1866        );
1867        assert!(apply_event(&mut value, &mut sel, &down));
1868        assert_eq!(sel, TextSelection::range(6, 11));
1869
1870        let drag = ev_drag_with_count(target.clone(), (click_x + 1.0, target.rect.y + 18.0), 2);
1871        assert!(apply_event(&mut value, &mut sel, &drag));
1872        assert_eq!(sel, TextSelection::range(6, 11));
1873    }
1874
1875    #[test]
1876    fn apply_click_is_noop_for_selection() {
1877        // Click fires after a drag — handling it would clobber the
1878        // selection drag established. We deliberately ignore Click in
1879        // text_input.
1880        let mut value = String::from("hello");
1881        let mut sel = TextSelection::range(0, 5);
1882        let click = UiEvent {
1883            path: None,
1884            key: Some("ti".into()),
1885            target: Some(ti_target()),
1886            pointer: Some((ti_target().rect.x + 1.0, ti_target().rect.y + 18.0)),
1887            key_press: None,
1888            text: None,
1889            selection: None,
1890            modifiers: KeyModifiers::default(),
1891            click_count: 1,
1892            kind: UiEventKind::Click,
1893        };
1894        assert!(!apply_event(&mut value, &mut sel, &click));
1895        assert_eq!(sel, TextSelection::range(0, 5));
1896    }
1897
1898    #[test]
1899    fn apply_middle_click_inserts_event_text_at_pointer() {
1900        let mut value = String::from("world");
1901        let mut sel = TextSelection::caret(value.len());
1902        let target = ti_target();
1903        let pointer = (
1904            target.rect.x + tokens::SPACE_3,
1905            target.rect.y + target.rect.h * 0.5,
1906        );
1907        let event = ev_middle_click(target, pointer, Some("hello "));
1908        assert!(apply_event(&mut value, &mut sel, &event));
1909        assert_eq!(value, "hello world");
1910        assert_eq!(sel, TextSelection::caret("hello ".len()));
1911    }
1912
1913    #[test]
1914    fn helpers_selected_text_and_replace_selection() {
1915        let value = String::from("hello world");
1916        let sel = TextSelection::range(6, 11);
1917        assert_eq!(selected_text(&value, sel), "world");
1918
1919        let mut value = value;
1920        let mut sel = sel;
1921        replace_selection(&mut value, &mut sel, "kit");
1922        assert_eq!(value, "hello kit");
1923        assert_eq!(sel, TextSelection::caret(9));
1924
1925        assert_eq!(select_all(&value), TextSelection::range(0, value.len()));
1926    }
1927
1928    #[test]
1929    fn apply_text_input_filters_control_chars() {
1930        // winit emits "\u{8}" alongside the named Backspace key event.
1931        // The TextInput branch must reject it so only the KeyDown
1932        // handler edits the value.
1933        let mut value = String::from("hi");
1934        let mut sel = TextSelection::caret(2);
1935        for ctrl in ["\u{8}", "\u{7f}", "\r", "\n", "\u{1b}", "\t"] {
1936            assert!(
1937                !apply_event(&mut value, &mut sel, &ev_text(ctrl)),
1938                "expected {ctrl:?} to be filtered"
1939            );
1940            assert_eq!(value, "hi");
1941            assert_eq!(sel, TextSelection::caret(2));
1942        }
1943        // Mixed input — printable parts come through, control parts drop.
1944        assert!(apply_event(&mut value, &mut sel, &ev_text("a\u{8}b")));
1945        assert_eq!(value, "hiab");
1946        assert_eq!(sel, TextSelection::caret(4));
1947    }
1948
1949    #[test]
1950    fn apply_text_input_drops_when_ctrl_or_cmd_is_held() {
1951        // winit emits TextInput("c") alongside KeyDown(Ctrl+C) on some
1952        // platforms. The clipboard handler consumes the KeyDown; the
1953        // TextInput must be ignored, otherwise the literal 'c'
1954        // replaces the selection right after the copy.
1955        let mut value = String::from("hello");
1956        let mut sel = TextSelection::range(0, 5);
1957        let ctrl = KeyModifiers {
1958            ctrl: true,
1959            ..Default::default()
1960        };
1961        let cmd = KeyModifiers {
1962            logo: true,
1963            ..Default::default()
1964        };
1965        assert!(!apply_event(
1966            &mut value,
1967            &mut sel,
1968            &ev_text_with_mods("c", ctrl)
1969        ));
1970        assert_eq!(value, "hello");
1971        assert!(!apply_event(
1972            &mut value,
1973            &mut sel,
1974            &ev_text_with_mods("v", cmd)
1975        ));
1976        assert_eq!(value, "hello");
1977        // AltGr (Ctrl+Alt) on Windows still produces text — exempt it.
1978        let altgr = KeyModifiers {
1979            ctrl: true,
1980            alt: true,
1981            ..Default::default()
1982        };
1983        let mut value = String::from("");
1984        let mut sel = TextSelection::caret(0);
1985        assert!(apply_event(
1986            &mut value,
1987            &mut sel,
1988            &ev_text_with_mods("é", altgr)
1989        ));
1990        assert_eq!(value, "é");
1991    }
1992
1993    #[test]
1994    fn text_input_value_emits_a_single_glyph_run() {
1995        // Regression test against a kerning bug: splitting the value
1996        // into [prefix, suffix] across the caret meant cosmic-text
1997        // shaped each substring independently, breaking kerning and
1998        // causing glyphs to "jump" left/right as the caret moved.
1999        // The fix renders the value as one shaped run.
2000        use crate::draw_ops::draw_ops;
2001        use crate::ir::DrawOp;
2002        let mut tree =
2003            crate::column([text_input("Type", TextSelection::caret(1)).key("ti")]).padding(20.0);
2004        let mut state = UiState::new();
2005        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2006
2007        let ops = draw_ops(&tree, &state);
2008        let glyph_runs = ops
2009            .iter()
2010            .filter(|op| matches!(op, DrawOp::GlyphRun { id, .. } if id.contains("text_input[ti]")))
2011            .count();
2012        assert_eq!(
2013            glyph_runs, 1,
2014            "value should shape as one run; got {glyph_runs}"
2015        );
2016    }
2017
2018    #[test]
2019    fn clipboard_request_detects_ctrl_c_x_v() {
2020        let ctrl = KeyModifiers {
2021            ctrl: true,
2022            ..Default::default()
2023        };
2024        let cases = [
2025            ("c", ClipboardKind::Copy),
2026            ("C", ClipboardKind::Copy),
2027            ("x", ClipboardKind::Cut),
2028            ("v", ClipboardKind::Paste),
2029        ];
2030        for (ch, expected) in cases {
2031            let e = ev_key_with_mods(UiKey::Character(ch.into()), ctrl);
2032            assert_eq!(clipboard_request(&e), Some(expected), "char {ch:?}");
2033        }
2034    }
2035
2036    #[test]
2037    fn clipboard_request_accepts_cmd_on_macos() {
2038        // winit reports Cmd as Logo. Apps should get the same behavior
2039        // on Linux/Windows (Ctrl) and macOS (Logo).
2040        let logo = KeyModifiers {
2041            logo: true,
2042            ..Default::default()
2043        };
2044        let e = ev_key_with_mods(UiKey::Character("c".into()), logo);
2045        assert_eq!(clipboard_request(&e), Some(ClipboardKind::Copy));
2046    }
2047
2048    #[test]
2049    fn clipboard_request_rejects_with_shift_or_alt() {
2050        // Ctrl+Shift+C is browser devtools, not Copy.
2051        let e = ev_key_with_mods(
2052            UiKey::Character("c".into()),
2053            KeyModifiers {
2054                ctrl: true,
2055                shift: true,
2056                ..Default::default()
2057            },
2058        );
2059        assert_eq!(clipboard_request(&e), None);
2060
2061        let e = ev_key_with_mods(
2062            UiKey::Character("v".into()),
2063            KeyModifiers {
2064                ctrl: true,
2065                alt: true,
2066                ..Default::default()
2067            },
2068        );
2069        assert_eq!(clipboard_request(&e), None);
2070    }
2071
2072    #[test]
2073    fn clipboard_request_ignores_other_keys_and_event_kinds() {
2074        // Plain "c" without modifiers is just text input.
2075        let e = ev_key(UiKey::Character("c".into()));
2076        assert_eq!(clipboard_request(&e), None);
2077        // Ctrl+A is select-all (handled by apply_event), not clipboard.
2078        let e = ev_key_with_mods(
2079            UiKey::Character("a".into()),
2080            KeyModifiers {
2081                ctrl: true,
2082                ..Default::default()
2083            },
2084        );
2085        assert_eq!(clipboard_request(&e), None);
2086        // TextInput events never report a clipboard request.
2087        assert_eq!(clipboard_request(&ev_text("c")), None);
2088    }
2089
2090    fn password_opts() -> TextInputOpts<'static> {
2091        TextInputOpts::default().password()
2092    }
2093
2094    #[test]
2095    fn password_input_renders_value_as_bullets_not_plaintext() {
2096        // The text leaf should never expose the original characters in
2097        // a password field. One bullet per scalar.
2098        let el = text_input_with("hunter2", TextSelection::caret(0), password_opts());
2099        let leaf = content_children(&el)
2100            .iter()
2101            .find(|c| matches!(c.kind, Kind::Text))
2102            .expect("text leaf");
2103        assert_eq!(leaf.text.as_deref(), Some("•••••••"));
2104    }
2105
2106    #[test]
2107    fn password_input_caret_position_uses_masked_widths() {
2108        // Caret offset must come from the rendered (masked) prefix
2109        // width, not the original-string prefix width — otherwise the
2110        // caret drifts away from the dots.
2111        use crate::text::metrics::line_width;
2112        let value = "abc";
2113        let head = 2;
2114        let el = text_input_with(value, TextSelection::caret(head), password_opts());
2115        let caret = content_children(&el)
2116            .iter()
2117            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2118            .expect("caret child");
2119        // Two bullets of prefix.
2120        let expected = line_width("••", tokens::TEXT_SM.size, FontWeight::Regular, false);
2121        assert!(
2122            (caret.translate.0 - expected).abs() < 0.01,
2123            "caret translate.x = {}, expected {}",
2124            caret.translate.0,
2125            expected
2126        );
2127    }
2128
2129    #[test]
2130    fn password_pointer_click_maps_back_to_original_byte() {
2131        // A pointer at the right edge of a 5-char password should
2132        // place the caret at byte index value.len() (=5 for ASCII).
2133        let mut value = String::from("abcde");
2134        let mut sel = TextSelection::default();
2135        let target = ti_target();
2136        let down = ev_pointer_down(
2137            target.clone(),
2138            (target.rect.x + target.rect.w - 4.0, target.rect.y + 18.0),
2139            KeyModifiers::default(),
2140        );
2141        assert!(apply_event_with(
2142            &mut value,
2143            &mut sel,
2144            &down,
2145            &password_opts()
2146        ));
2147        assert_eq!(sel.head, value.len());
2148    }
2149
2150    #[test]
2151    fn password_pointer_click_with_multibyte_value() {
2152        // Mask is one bullet per scalar; the returned byte index must
2153        // be a valid boundary in the (multi-byte) original value.
2154        // 'é' is 2 bytes; "éé" is 4 bytes total.
2155        let mut value = String::from("éé");
2156        let mut sel = TextSelection::default();
2157        let target = ti_target();
2158        // Click at a position that should land between the two bullets.
2159        let bullet_w = metrics::line_width("•", tokens::TEXT_SM.size, FontWeight::Regular, false);
2160        let click_x = target.rect.x + tokens::SPACE_3 + bullet_w * 1.4;
2161        let down = ev_pointer_down(
2162            target,
2163            (click_x, ti_target().rect.y + 18.0),
2164            KeyModifiers::default(),
2165        );
2166        assert!(apply_event_with(
2167            &mut value,
2168            &mut sel,
2169            &down,
2170            &password_opts()
2171        ));
2172        // After 1 scalar in "éé" the byte offset is 2 (or 4 if the hit
2173        // landed past the second bullet). Either way, must be a char
2174        // boundary in `value`.
2175        assert!(
2176            value.is_char_boundary(sel.head),
2177            "head={} not on a char boundary in {value:?}",
2178            sel.head
2179        );
2180        assert!(sel.head == 2 || sel.head == 4, "head={}", sel.head);
2181    }
2182
2183    #[test]
2184    fn password_clipboard_request_suppresses_copy_and_cut_only() {
2185        let ctrl = KeyModifiers {
2186            ctrl: true,
2187            ..Default::default()
2188        };
2189        let opts = password_opts();
2190        let copy = ev_key_with_mods(UiKey::Character("c".into()), ctrl);
2191        let cut = ev_key_with_mods(UiKey::Character("x".into()), ctrl);
2192        let paste = ev_key_with_mods(UiKey::Character("v".into()), ctrl);
2193        assert_eq!(clipboard_request_for(&copy, &opts), None);
2194        assert_eq!(clipboard_request_for(&cut, &opts), None);
2195        assert_eq!(
2196            clipboard_request_for(&paste, &opts),
2197            Some(ClipboardKind::Paste)
2198        );
2199        // Plain (non-masked) opts behave like the legacy entry point.
2200        let plain = TextInputOpts::default();
2201        assert_eq!(
2202            clipboard_request_for(&copy, &plain),
2203            Some(ClipboardKind::Copy)
2204        );
2205    }
2206
2207    #[test]
2208    fn placeholder_renders_only_when_value_is_empty() {
2209        let opts = TextInputOpts::default().placeholder("Email");
2210        let empty = text_input_with("", TextSelection::default(), opts);
2211        let muted_leaf = content_children(&empty)
2212            .iter()
2213            .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2214        assert!(muted_leaf.is_some(), "placeholder leaf should be present");
2215
2216        let nonempty = text_input_with("hi", TextSelection::caret(2), opts);
2217        let muted_leaf = content_children(&nonempty)
2218            .iter()
2219            .find(|c| matches!(c.kind, Kind::Text) && c.text.as_deref() == Some("Email"));
2220        assert!(
2221            muted_leaf.is_none(),
2222            "placeholder should not render once the field has a value"
2223        );
2224    }
2225
2226    #[test]
2227    fn long_value_with_caret_at_end_shifts_content_left_to_keep_caret_in_view() {
2228        // Regression: when value width exceeds the viewport, the
2229        // inner clip group's `layout_override` shifts content left
2230        // by `head_px - viewport_w` so the caret pins to the right
2231        // edge of the visible area. Verify by laying out a long
2232        // value in a narrow text_input and checking the text
2233        // leaf's painted rect extends left of the outer's content
2234        // origin (i.e. negative-x relative to the outer's content
2235        // rect).
2236        use crate::tree::Size;
2237        let value = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2);
2238        let mut root = super::text_input(
2239            &value,
2240            &as_selection_in("ti", TextSelection::caret(value.len())),
2241            "ti",
2242        )
2243        .width(Size::Fixed(120.0));
2244        let mut ui_state = crate::state::UiState::new();
2245        crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2246
2247        // Find the text leaf (the Kind::Text under the inner Group).
2248        let inner = &root.children[0];
2249        let text_leaf = inner
2250            .children
2251            .iter()
2252            .find(|c| matches!(c.kind, Kind::Text))
2253            .expect("text leaf");
2254        let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2255
2256        // The leaf's x must be left of the inner's content origin
2257        // (i.e. negative-relative) because the long content has
2258        // been scrolled left to keep the caret on the right edge.
2259        let inner_rect = ui_state.rect(&inner.computed_id);
2260        assert!(
2261            leaf_rect.x < inner_rect.x,
2262            "text leaf rect.x={} should be left of inner rect.x={} after \
2263             horizontal caret-into-view; layout did not shift content",
2264            leaf_rect.x,
2265            inner_rect.x,
2266        );
2267    }
2268
2269    #[test]
2270    fn short_value_does_not_shift_content() {
2271        // Counter-test: when value fits inside the viewport, no
2272        // x_offset is applied and the text leaf sits at the
2273        // inner's content origin.
2274        use crate::tree::Size;
2275        let mut root =
2276            super::text_input("hi", &as_selection_in("ti", TextSelection::caret(2)), "ti")
2277                .width(Size::Fixed(120.0));
2278        let mut ui_state = crate::state::UiState::new();
2279        crate::layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 120.0, 40.0));
2280
2281        let inner = &root.children[0];
2282        let text_leaf = inner
2283            .children
2284            .iter()
2285            .find(|c| matches!(c.kind, Kind::Text))
2286            .expect("text leaf");
2287        let leaf_rect = ui_state.rect(&text_leaf.computed_id);
2288        let inner_rect = ui_state.rect(&inner.computed_id);
2289        assert!(
2290            (leaf_rect.x - inner_rect.x).abs() < 0.5,
2291            "short value should not shift; got leaf.x={} inner.x={}",
2292            leaf_rect.x,
2293            inner_rect.x
2294        );
2295    }
2296
2297    /// Test helper: build a `Selection` with `(anchor, head)` under
2298    /// a single key.
2299    fn as_selection_in(key: &str, sel: TextSelection) -> Selection {
2300        Selection {
2301            range: Some(SelectionRange {
2302                anchor: SelectionPoint::new(key, sel.anchor),
2303                head: SelectionPoint::new(key, sel.head),
2304            }),
2305        }
2306    }
2307
2308    #[test]
2309    fn max_length_truncates_text_input_inserts() {
2310        let mut value = String::from("ab");
2311        let mut sel = TextSelection::caret(2);
2312        let opts = TextInputOpts::default().max_length(4);
2313        // "cdef" would push to 6 chars; only "cd" fits.
2314        assert!(apply_event_with(
2315            &mut value,
2316            &mut sel,
2317            &ev_text("cdef"),
2318            &opts
2319        ));
2320        assert_eq!(value, "abcd");
2321        assert_eq!(sel, TextSelection::caret(4));
2322        // A further insert is refused — there's no room.
2323        assert!(!apply_event_with(
2324            &mut value,
2325            &mut sel,
2326            &ev_text("z"),
2327            &opts
2328        ));
2329        assert_eq!(value, "abcd");
2330    }
2331
2332    #[test]
2333    fn max_length_replaces_selection_with_capacity_freed_by_removal() {
2334        // Replacing 3 chars with 5 chars at a 4-char cap: post_other = 0,
2335        // allowed = 4, replacement truncated to 4.
2336        let mut value = String::from("abc");
2337        let mut sel = TextSelection::range(0, 3); // whole value selected
2338        let opts = TextInputOpts::default().max_length(4);
2339        assert!(apply_event_with(
2340            &mut value,
2341            &mut sel,
2342            &ev_text("12345"),
2343            &opts
2344        ));
2345        assert_eq!(value, "1234");
2346        assert_eq!(sel, TextSelection::caret(4));
2347    }
2348
2349    #[test]
2350    fn replace_selection_with_max_length_clips_a_paste() {
2351        let mut value = String::from("ab");
2352        let mut sel = TextSelection::caret(2);
2353        let opts = TextInputOpts::default().max_length(5);
2354        // Paste 10 chars into a value already at 2/5; only 3 fit.
2355        let inserted = replace_selection_with(&mut value, &mut sel, "0123456789", &opts);
2356        assert_eq!(value, "ab012");
2357        assert_eq!(inserted, 3);
2358        assert_eq!(sel, TextSelection::caret(5));
2359    }
2360
2361    #[test]
2362    fn max_length_does_not_shrink_an_already_overlong_value() {
2363        // Caller is allowed to pass a value already longer than the cap;
2364        // the cap only constrains future inserts. Existing chars stay.
2365        let mut value = String::from("abcdef");
2366        let mut sel = TextSelection::caret(6);
2367        let opts = TextInputOpts::default().max_length(3);
2368        // No room for a new char.
2369        assert!(!apply_event_with(
2370            &mut value,
2371            &mut sel,
2372            &ev_text("z"),
2373            &opts
2374        ));
2375        assert_eq!(value, "abcdef");
2376        // But a delete still works — apply_event_with isn't gating
2377        // removals on max_length.
2378        assert!(apply_event_with(
2379            &mut value,
2380            &mut sel,
2381            &ev_key(UiKey::Backspace),
2382            &opts
2383        ));
2384        assert_eq!(value, "abcde");
2385    }
2386
2387    #[test]
2388    fn end_to_end_drag_select_through_runner_core() {
2389        // Lay out a tree with one text_input keyed "ti". Drive a
2390        // pointer_down + drag + pointer_up sequence through RunnerCore;
2391        // verify the resulting events fold into a non-empty selection.
2392        let mut value = String::from("hello world");
2393        let mut sel = TextSelection::default();
2394        let mut tree = crate::column([text_input(&value, sel).key("ti")]).padding(20.0);
2395        let mut core = RunnerCore::new();
2396        let mut state = UiState::new();
2397        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2398        core.ui_state = state;
2399        core.snapshot(&tree, &mut Default::default());
2400
2401        let rect = core.rect_of_key("ti").expect("ti rect");
2402        let down_x = rect.x + 8.0;
2403        let drag_x = rect.x + 80.0;
2404        let cy = rect.y + rect.h * 0.5;
2405
2406        core.pointer_moved(down_x, cy);
2407        let down = core
2408            .pointer_down(down_x, cy, PointerButton::Primary)
2409            .into_iter()
2410            .find(|e| e.kind == UiEventKind::PointerDown)
2411            .expect("pointer_down emits PointerDown");
2412        assert!(apply_event(&mut value, &mut sel, &down));
2413
2414        let drag = core
2415            .pointer_moved(drag_x, cy)
2416            .events
2417            .into_iter()
2418            .find(|e| e.kind == UiEventKind::Drag)
2419            .expect("Drag while pressed");
2420        assert!(apply_event(&mut value, &mut sel, &drag));
2421
2422        let events = core.pointer_up(drag_x, cy, PointerButton::Primary);
2423        for e in &events {
2424            apply_event(&mut value, &mut sel, e);
2425        }
2426        assert!(
2427            !sel.is_collapsed(),
2428            "expected drag-select to leave a non-empty selection"
2429        );
2430        assert_eq!(
2431            sel.anchor, 0,
2432            "anchor should sit at the down position (caret 0)"
2433        );
2434        assert!(
2435            sel.head > 0 && sel.head <= value.len(),
2436            "head={} value.len={}",
2437            sel.head,
2438            value.len()
2439        );
2440    }
2441
2442    // ---- Global-Selection integration ----
2443    //
2444    // The shimmed tests above exercise the local edit logic via the
2445    // `(value, &mut Selection, key, event)` API by routing through a
2446    // single fixed test key. The tests here verify the *integration*
2447    // semantics that only the post-migration API can express.
2448
2449    #[test]
2450    fn apply_event_writes_back_under_the_inputs_key() {
2451        // Type a character: the resulting range lives under "name".
2452        let mut value = String::new();
2453        let mut sel = Selection::default();
2454        let event = ev_text("h");
2455        assert!(super::apply_event(&mut value, &mut sel, "name", &event));
2456        assert_eq!(value, "h");
2457        let r = sel.range.as_ref().expect("selection set");
2458        assert_eq!(r.anchor.key, "name");
2459        assert_eq!(r.head.key, "name");
2460        assert_eq!(r.head.byte, 1);
2461    }
2462
2463    #[test]
2464    fn apply_event_claims_selection_when_event_routed_from_elsewhere() {
2465        // Selection is currently in another key (e.g. a static text
2466        // paragraph). The user is focused on the "email" input and
2467        // types — the event arrives because the runtime routes
2468        // capture_keys events to the focused element. apply_event
2469        // claims the selection by writing back into the input's key.
2470        let mut value = String::new();
2471        let mut sel = Selection {
2472            range: Some(SelectionRange {
2473                anchor: SelectionPoint::new("para-a", 0),
2474                head: SelectionPoint::new("para-a", 5),
2475            }),
2476        };
2477        let event = ev_text("x");
2478        assert!(super::apply_event(&mut value, &mut sel, "email", &event));
2479        assert_eq!(value, "x");
2480        let r = sel.range.as_ref().unwrap();
2481        assert_eq!(r.anchor.key, "email", "selection ownership migrated");
2482        assert_eq!(r.head.byte, 1);
2483    }
2484
2485    #[test]
2486    fn apply_event_leaves_selection_alone_when_event_is_unhandled() {
2487        // A KeyDown the input doesn't recognize (e.g. F-key) should
2488        // not perturb the global selection — even if it lives in
2489        // another key. apply_event returns false; we don't write back.
2490        let mut value = String::from("hi");
2491        let mut sel = Selection {
2492            range: Some(SelectionRange {
2493                anchor: SelectionPoint::new("para-a", 0),
2494                head: SelectionPoint::new("para-a", 3),
2495            }),
2496        };
2497        let event = ev_key(UiKey::Other("F1".into()));
2498        assert!(!super::apply_event(&mut value, &mut sel, "name", &event));
2499        // Selection unchanged.
2500        let r = sel.range.as_ref().unwrap();
2501        assert_eq!(r.anchor.key, "para-a");
2502        assert_eq!(r.head.byte, 3);
2503    }
2504
2505    #[test]
2506    fn text_input_renders_caret_at_local_byte_when_selection_is_within_key() {
2507        let sel = Selection::caret("name", 2);
2508        let el = super::text_input("hello", &sel, "name");
2509        // Builder set the El's key.
2510        assert_eq!(el.key.as_deref(), Some("name"));
2511        // Caret child translates to the prefix width of "he".
2512        let caret = content_children(&el)
2513            .iter()
2514            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")))
2515            .expect("caret child");
2516        let expected = metrics::line_width("he", tokens::TEXT_SM.size, FontWeight::Regular, false);
2517        assert!(
2518            (caret.translate.0 - expected).abs() < 0.01,
2519            "caret.x={} expected {}",
2520            caret.translate.0,
2521            expected
2522        );
2523    }
2524
2525    #[test]
2526    fn text_input_omits_caret_when_selection_lives_elsewhere() {
2527        // When the active selection lives in another widget, this
2528        // input emits neither a band nor a caret. Without the caret
2529        // gate, blurring an input by clicking into another would
2530        // visibly snap this caret to byte 0 for the duration of the
2531        // focus-envelope fade-out — read by the user as the caret
2532        // jumping home before vanishing.
2533        let sel = Selection {
2534            range: Some(SelectionRange {
2535                anchor: SelectionPoint::new("other", 0),
2536                head: SelectionPoint::new("other", 5),
2537            }),
2538        };
2539        let el = super::text_input("hello", &sel, "name");
2540        let band = el
2541            .children
2542            .iter()
2543            .find(|c| matches!(c.kind, Kind::Custom("text_input_selection")));
2544        assert!(band.is_none(), "no band when selection lives elsewhere");
2545        let caret = el
2546            .children
2547            .iter()
2548            .find(|c| matches!(c.kind, Kind::Custom("text_input_caret")));
2549        assert!(
2550            caret.is_none(),
2551            "no caret when selection lives elsewhere — focus-fade has nothing to bring back to byte 0"
2552        );
2553    }
2554}