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