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