Skip to main content

damascene_core/widgets/
text_input.rs

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