Skip to main content

teksilo_widgets/primitives/
text_input_field.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TextInputField` — editable single-line text surface primitive.
5//!
6//! This is the raw editing primitive that powers the styled
7//! [`TextInput`](crate::text_input::TextInput) composite and any
8//! other widget that needs inline editable text — [`SpinBox`] being
9//! the primary second consumer.
10//!
11//! Unlike `TextInput`, `TextInputField` paints no frame, no
12//! placeholder overlay, no validation border, and hosts no trailing
13//! slots: it is the focusable text area only. Compose it yourself
14//! with `RectWidget`, `Padding`, icons, clear buttons, etc. to
15//! build a styled control. Focus indication is the composite's
16//! responsibility — the Int UI convention is to thicken the
17//! enclosing frame's border to `focus_ring_width` and recolor it
18//! to the accent focus-ring color.
19//!
20//! Features:
21//! - Bound `Signal<String>` for two-way text binding.
22//! - Full keyboard editing (arrow keys, Home/End, Backspace/Delete,
23//!   Ctrl+X/C/V, Ctrl+A, Ctrl+Z/Y), IME commit, and pointer caret
24//!   positioning and drag-select.
25//! - Optional per-character input filter
26//!   ([`TextInputField::char_filter`]), max-length cap
27//!   ([`TextInputField::max_length`]), and read-only mode
28//!   ([`TextInputField::read_only`]).
29//! - Commit hooks: Enter fires
30//!   [`on_submit_fn`](TextInputField::on_submit_fn) and focus loss
31//!   fires [`on_blur_fn`](TextInputField::on_blur_fn).
32//! - Non-editable trailing
33//!   [`suffix`](TextInputField::suffix), rendered flush-right inside
34//!   the field's bounds (Qt's `QSpinBox::suffix`). Caret cannot
35//!   enter it; clicks past the text end clamp to the last
36//!   character.
37//! - Right-click context menu (Cut / Copy / Paste / Select All).
38//! - AccessKit `Role::TextInput` with value, selection, and
39//!   character/word boundary metadata.
40//!
41//! # Example
42//!
43//! ```ignore
44//! let text = ctx.signal(String::new());
45//! ctx.add(
46//!     TextInputField::new(text.clone())
47//!         .placeholder("Enter a name…")
48//!         .char_filter(|c| !c.is_ascii_digit())
49//!         .on_submit_fn(|ctx| ctx.send_intent(MyIntent::Save)),
50//! );
51//! ```
52//!
53//! [`SpinBox`]: crate::spin_box::SpinBox
54
55mod keyboard;
56pub mod mask;
57mod mouse;
58pub(crate) mod state;
59pub(crate) mod touch;
60pub mod validator;
61mod widget_impl;
62
63use std::rc::Rc;
64use teksilo_i18n::tr_widget;
65
66use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
67use teksilo_core::accessibility::AccessNodeBuilder;
68use teksilo_core::accessibility::text_runs::{RetainedText, TextRunSource, push_text_runs};
69use teksilo_core::build_context::BuildContext;
70use teksilo_core::event::{EventResponse, Key};
71use teksilo_core::shortcut::KeyStroke;
72use teksilo_core::signal::{Prop, Signal};
73use teksilo_core::widget::{
74    CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
75};
76use teksilo_core::widget_builder::HandlerSet;
77use teksilo_core::widget_id::WidgetId;
78use teksilo_text::text_document::{SelectionType, TextDocument};
79use teksilo_text::{CursorAffinity, CursorDisplay, RichTextEngine, SharedTypesetter};
80use teksilo_tokens::{InputTokens, TextStyle};
81
82use crate::button::InteractionState;
83use crate::keystroke_format::format_keystroke;
84use crate::menu_item::MenuItem;
85use crate::menu_list::{MenuList, MenuSeparator};
86use crate::rich_text::paint::{PaintParams, paint_frame};
87
88pub(crate) use self::state::{CharFilter, CommandFactory};
89use self::state::{SharedState, TextInputConfig, TextInputState, sync_cursor_signals};
90
91pub use self::mask::{InputMask, MaskClass, MaskError, MaskPosition};
92pub use self::validator::{ValidationFeedback, ValidationOutcome, ValidatorFn};
93
94// The caret blink period and the debounce window are shared with every other
95// text surface — see `common::editor_runtime`. They used to be re-declared
96// here as private constants ("same as RichTextEditor", said the comment),
97// which is exactly the kind of duplication that drifts silently: two carets
98// blinking at different rates is invisible to tests and obvious to users.
99use crate::common::editor_runtime::CaretPolicy;
100use teksilo_core::styles::density::spacing;
101
102/// Horizontal scroll margin in pixels. The caret stays at least this
103/// far from the left/right edge of the viewport.
104const SCROLL_MARGIN: f32 = 4.0;
105
106/// [`SCROLL_MARGIN`] scaled by the density's `spacing_factor`
107/// (1.00 / 1.15 / 1.30).
108fn scroll_margin(tokens: &InputTokens) -> f32 {
109    spacing(SCROLL_MARGIN, tokens)
110}
111
112/// Default text-area height when the caller does not override it
113/// via [`TextInputField::text_height`]. Sits close to the Int UI
114/// `TEXT_FIELD_HEIGHT` recipe constant (in
115/// `crate::styles::recipe_text_input_style`) minus its border and
116/// vertical padding — the 18 dp the `TextInput` composite passes —
117/// so a bare `TextInputField` added to a tree without its composite
118/// still looks right.
119const DEFAULT_TEXT_HEIGHT: f32 = 20.0;
120
121/// The semantic purpose of a text field, surfaced to assistive technology as
122/// a specialised AccessKit role (WCAG 1.3.5 Identify Input Purpose / EN 301 549).
123///
124/// This is the in-framework-achievable part of SC 1.3.5: a screen reader
125/// announces "email, edit text" instead of a generic "edit text". The FULL
126/// HTML `autocomplete`-token vocabulary (`given-name`, `postal-code`,
127/// `cc-number`, …) that drives OS/browser autofill has **no representation in
128/// AccessKit 0.24** and therefore cannot be exposed from Teksilo — see
129/// `docs/a11y/a11y_issues.md`. Password entry is configured via
130/// [`TextInputField::secure`], not here.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum InputPurpose {
133    /// Ordinary free text (`Role::TextInput`).
134    #[default]
135    Normal,
136    /// Email address (`Role::EmailInput`).
137    Email,
138    /// Telephone number (`Role::PhoneNumberInput`).
139    Phone,
140    /// URL (`Role::UrlInput`).
141    Url,
142    /// Numeric entry — e.g. a quantity or code (`Role::NumberInput`).
143    Number,
144    /// Search query (`Role::SearchInput`).
145    Search,
146}
147
148impl InputPurpose {
149    /// The AccessKit role for a non-secure field with this purpose.
150    pub(crate) fn to_role(self) -> teksilo_core::accesskit::Role {
151        use teksilo_core::accesskit::Role;
152        match self {
153            InputPurpose::Normal => Role::TextInput,
154            InputPurpose::Email => Role::EmailInput,
155            InputPurpose::Phone => Role::PhoneNumberInput,
156            InputPurpose::Url => Role::UrlInput,
157            InputPurpose::Number => Role::NumberInput,
158            InputPurpose::Search => Role::SearchInput,
159        }
160    }
161}
162
163/// How a secure ([`TextInputField::secure`]) field echoes typed
164/// characters. Mirrors Qt's `QLineEdit::EchoMode`.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub enum EchoMode {
167    /// Replace every character with the echo glyph (default `'•'`).
168    /// The plaintext stays in the bound `Signal<String>` but never
169    /// reaches the text engine while masked.
170    #[default]
171    Masked,
172    /// Show nothing at all — not even the length. The caret stays at
173    /// the start. Qt's `NoEcho`.
174    NoEcho,
175    /// Show plaintext while the field is focused (being edited) and
176    /// re-mask on blur. Qt's `PasswordEchoOnEdit`.
177    RevealWhileTyping,
178}
179
180/// How a *revealed* secure field reports to assistive technology.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182pub enum AtRevealPolicy {
183    /// When revealed, expose the field as a normal `Role::TextInput`
184    /// carrying the plaintext value — matching what is visibly on
185    /// screen and the web `type=password ↔ type=text` swap. When
186    /// masked, it reverts to `Role::PasswordInput`. (Default.)
187    #[default]
188    SwapRole,
189    /// Always report `Role::PasswordInput` and never expose plaintext
190    /// to assistive tech, even while visually revealed. Higher
191    /// confidentiality at the cost of consistency with the screen.
192    AlwaysProtected,
193}
194
195/// Editable single-line text surface primitive.
196///
197/// See the [module docs](self) for the full feature list and a
198/// compositional example.
199pub struct TextInputField {
200    // ── Configuration (builder methods, consumed in build) ───────────
201    text: Signal<String>,
202    /// Enabled state, static or reactive; forwarded to the arena at build
203    /// time.
204    enabled: Prop<bool>,
205    read_only: bool,
206    max_length: Option<usize>,
207    placeholder: String,
208    on_submit: Option<CommandFactory>,
209    on_access_set_value: Option<super::text_input_field::state::AccessSetValue>,
210    on_blur: Option<CommandFactory>,
211    char_filter: Option<CharFilter>,
212    /// Fixed trailing label rendered inside the field's border.
213    /// Accepts both plain strings and `Signal<String>` — when bound,
214    /// the field re-measures the suffix and relayouts each time the
215    /// signal fires, so composites like `SpinBox` can derive the
216    /// suffix from the widget state (e.g. hide it while
217    /// `special_value_text` is active).
218    suffix: Prop<String>,
219    text_height: Option<f32>,
220    external_interaction: Option<Signal<InteractionState>>,
221
222    /// Optional input mask. When set, the field auto-derives a
223    /// placeholder template (`__/__/____` for `99/99/9999`) and
224    /// rejects non-fitting characters via a position-aware filter
225    /// composed with the user's `char_filter`. See [`InputMask`] for
226    /// the grammar.
227    mask: Option<InputMask>,
228    /// Visible char used for unfilled editable positions in the mask
229    /// template. Defaults to the `TEXT_FIELD_MASK_PLACEHOLDER_CHAR`
230    /// recipe constant (in `crate::styles::recipe_text_input_style`,
231    /// `_`).
232    mask_placeholder_override: Option<char>,
233    /// Validator closure called on every commit (Enter, Tab-out,
234    /// blur). Returns a [`ValidationOutcome`] that drives
235    /// [`feedback`](Self::validation_feedback_signal).
236    validator: Option<ValidatorFn>,
237    /// Published feedback signal. Composites bind to this to render
238    /// the inline validation strip below the field.
239    feedback: Signal<ValidationFeedback>,
240
241    // ── Secure / password masking (set via `secure`) ────────────────
242    secure: bool,
243    echo_mode: EchoMode,
244    echo_char: char,
245    revealed: Option<Signal<bool>>,
246    at_reveal_policy: AtRevealPolicy,
247    allow_copy: bool,
248
249    /// Semantic purpose → specialised AT role (WCAG 1.3.5). Ignored while the
250    /// field is `secure` (password role wins).
251    input_purpose: InputPurpose,
252
253    /// ARIA combobox wiring — see [`active_descendant`](Self::active_descendant).
254    active_descendant: Option<Signal<Option<WidgetId>>>,
255    /// The listbox this field drives, if any — see
256    /// [`controls`](Self::controls).
257    controls: Option<Signal<Option<WidgetId>>>,
258
259    // ── Internal (set during build) ─────────────────────────────────
260    state: Option<SharedState>,
261    /// Interaction signal actually used at runtime. Either the one
262    /// supplied by a wrapping composite via
263    /// [`TextInputField::interaction_signal`] or a fresh one owned
264    /// by the field. Read by the focus handler to repaint a
265    /// parent's focus ring / border on gain/loss.
266    interaction: Signal<InteractionState>,
267    /// Mirror of the inner state's `cursor_position` for external
268    /// readers. Wired in `build()` via a `ctx.effect`. Composing
269    /// widgets that need the caret (e.g. `DateEdit` for segment
270    /// stepping) read this via [`TextInputField::caret_position`].
271    caret_position: Signal<usize>,
272    /// Late-bound handle to the inner `SharedState`, populated in
273    /// `build()`. Lets composing widgets capture a `caret_setter`
274    /// closure BEFORE the field is moved into the tree, then call
275    /// it later to programmatically reposition the caret. Required
276    /// because the inner state doesn't exist before `build()` runs,
277    /// but the composing widget loses ownership of `self` once it
278    /// hands the field to `ctx.add(...)`.
279    state_slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>>,
280    /// Minted with the widget, not with its state, so a [`TextFieldHandle`]
281    /// taken before `build` observes the signal the built widget writes.
282    focus_signal: Signal<bool>,
283    /// Touch selection: the controller, and the ids of the two overlays it
284    /// raises. Minted with the widget rather than in `build` so the handle
285    /// survives a rebuild even though the controller inside it is replaced.
286    /// See [`touch`].
287    pub(crate) touch: Rc<touch::FieldTouch>,
288    /// Natural intrinsic width in logical pixels, cached during
289    /// `build()`. When an [`InputMask`] is set, this measures the
290    /// mask's worst-case filled template (e.g. `00/00/0000`, plus a
291    /// safety `M`) in the theme body font and adds a small caret
292    /// slack — so a date / time / phone field reports a width that
293    /// matches its content envelope instead of the generic 200 dp
294    /// fallback. Composing widgets like `DateEdit` rely on this so
295    /// their unconstrained natural width tracks the format pattern.
296    natural_width: f32,
297    /// What the field's text measured, kept for the accessibility pass.
298    ///
299    /// A reader reviewing the field by character, word or line needs
300    /// per-character extents, and the editing engine's own layout is not
301    /// reachable from `accessibility()`. Written by `place_children` — the
302    /// first pass that knows the final width — and again by `paint`, because
303    /// a keystroke dirties the field at `RepaintOnly` / `AccessibilityOnly`
304    /// and never relayouts: geometry taken from `place_children` alone would
305    /// be one edit stale for as long as anyone is typing.
306    retained: Rc<std::cell::RefCell<Option<RetainedText>>>,
307}
308
309impl std::fmt::Debug for TextInputField {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        f.debug_struct("TextInputField")
312            .field("placeholder", &self.placeholder)
313            .field("enabled", &self.enabled.get())
314            .field("read_only", &self.read_only)
315            .finish_non_exhaustive()
316    }
317}
318
319impl TextInputField {
320    /// Construct a new field bound to `text`.
321    pub fn new(text: Signal<String>) -> Self {
322        let state_slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>> =
323            std::rc::Rc::new(std::cell::RefCell::new(None));
324        Self {
325            text,
326            enabled: Prop::Static(true),
327            read_only: false,
328            max_length: None,
329            placeholder: String::new(),
330            on_submit: None,
331            on_access_set_value: None,
332            on_blur: None,
333            char_filter: None,
334            suffix: Prop::Static(String::new()),
335            text_height: None,
336            external_interaction: None,
337            mask: None,
338            mask_placeholder_override: None,
339            validator: None,
340            feedback: Signal::new(ValidationFeedback::Pristine),
341            secure: false,
342            echo_mode: EchoMode::Masked,
343            echo_char: '\u{2022}',
344            revealed: None,
345            at_reveal_policy: AtRevealPolicy::SwapRole,
346            allow_copy: true,
347            input_purpose: InputPurpose::Normal,
348            active_descendant: None,
349            controls: None,
350            state: None,
351            interaction: Signal::new(InteractionState::Idle),
352            caret_position: Signal::new(0),
353            state_slot: state_slot.clone(),
354            focus_signal: Signal::new(false),
355            touch: touch::FieldTouch::new(state_slot),
356            natural_width: 200.0,
357            retained: Rc::new(std::cell::RefCell::new(None)),
358        }
359    }
360
361    /// Declarative placeholder string. The field itself paints
362    /// nothing for placeholder — that visual is the composite
363    /// parent's responsibility (`TextInput` overlays a
364    /// `TextWidget`). The string is still stored here and published
365    /// via AccessKit's `placeholder` property so screen readers
366    /// announce it.
367    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
368        self.placeholder = text.into();
369        self
370    }
371
372    /// Set the enabled state, statically or reactively. Disabled blocks
373    /// input and AccessKit interaction. Forwarded to the arena at build
374    /// time.
375    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
376        self.enabled = enabled.into();
377        self
378    }
379
380    /// Mark the field read-only. Caret and selection still work;
381    /// inserts, deletes, paste, undo/redo, and cut are all no-ops.
382    pub fn read_only(mut self, read_only: bool) -> Self {
383        self.read_only = read_only;
384        self
385    }
386
387    /// Hard cap on document length in `char`s (grapheme count is
388    /// approximated — each `char` counts as one unit, matching
389    /// `String::chars().count()`).
390    pub fn max_length(mut self, max_length: usize) -> Self {
391        self.max_length = Some(max_length);
392        self
393    }
394
395    /// Closure fired on `Enter`. Unlike `on_blur_fn`, this does
396    /// not move focus — the field stays focused and the caret
397    /// stays where it was.
398    pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
399        self.on_submit = Some(Box::new(f));
400        self
401    }
402
403    /// Handle an assistive technology's whole-value write, given the string it
404    /// set.
405    ///
406    /// Leave it unset for a field whose bound `Signal<String>` **is** the
407    /// value: the write has already landed and there is nothing to derive.
408    ///
409    /// Install one for a field whose text is a *projection* of a typed value,
410    /// as `SpinBox` and the date and time editors are. There the string is
411    /// only a display of the real value, so without this an
412    /// `Action::SetValue` resolved against the inner text node changes what is
413    /// shown, leaves the typed value stale until the next blur, and never
414    /// fires the host's change callback — an assistive technology or an
415    /// automation client sees a success and the wrong value. The composite's
416    /// own node handles `SetValue` properly; this closes the same door on the
417    /// text node beneath it.
418    ///
419    /// The string is handed over rather than read back from the bound signal
420    /// because the document→signal sync is deferred to the next frame tick, so
421    /// a host reading the signal here would parse the text from *before* this
422    /// edit and revert.
423    pub fn on_access_set_value(
424        mut self,
425        f: impl Fn(&str, &mut EventContext) -> bool + 'static,
426    ) -> Self {
427        self.on_access_set_value = Some(std::rc::Rc::new(f));
428        self
429    }
430
431    /// Closure fired once per focus-loss, after selection/scroll
432    /// have been reset. SpinBox-style callers parse and reformat
433    /// here; validators revalidate here.
434    pub fn on_blur_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
435        self.on_blur = Some(Box::new(f));
436        self
437    }
438
439    /// Per-character input-filter predicate. Applied uniformly to
440    /// keyboard input, IME commits, and clipboard paste so a filtered
441    /// field cannot receive disallowed characters through any path.
442    /// Composes with `max_length` and the built-in control/newline
443    /// strip (filter runs after the strip). Whole-string validity
444    /// (e.g. "at most one decimal point") is a commit-time concern
445    /// for `on_blur` / `on_submit`.
446    pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
447        self.char_filter = Some(Rc::new(f));
448        self
449    }
450
451    /// Static non-editable trailing string rendered flush-right
452    /// inside the field's bounds (Qt's `QSpinBox::suffix`). The
453    /// caret cannot enter the suffix; clicks past the text end
454    /// position the caret at the last editable character.
455    ///
456    /// Accepts a static `String`/`&str` or a reactive `Signal<String>` /
457    /// `Prop<String>`; when bound, the field re-measures the suffix glyphs
458    /// and relayouts the editable text viewport each time the signal fires.
459    /// Typical use: a `SpinBox` with `special_value_text` binds an empty
460    /// string to the suffix whenever the value equals `min`, and the
461    /// configured unit string otherwise.
462    pub fn suffix(mut self, text: impl Into<Prop<String>>) -> Self {
463        self.suffix = text.into();
464        self
465    }
466
467    /// Override the intrinsic text-area height. The field is a
468    /// pure leaf with no theme lookup of its own; by default it
469    /// reports `DEFAULT_TEXT_HEIGHT`. A wrapping composite like
470    /// `TextInput` passes the `TEXT_FIELD_HEIGHT` recipe constant
471    /// (in `crate::styles::recipe_text_input_style`) minus
472    /// border + padding here so the visuals line up with the
473    /// rest of the form.
474    pub fn text_height(mut self, height: f32) -> Self {
475        self.text_height = Some(height);
476        self
477    }
478
479    /// Bind an externally-owned `InteractionState` signal. The
480    /// field writes `Focused` on focus gain and `Idle` on loss;
481    /// other states (`Hovered`, `Pressed`, `Disabled`) are the
482    /// composite's responsibility. When unset, the field owns a
483    /// private signal that observers can still read via
484    /// [`interaction`](TextInputField::interaction), but composites
485    /// that drive a focus ring or border color usually want to
486    /// push their own.
487    pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
488        self.external_interaction = Some(signal);
489        self
490    }
491
492    /// Set an input mask (Qt grammar). Constrains accepted characters
493    /// per position, auto-derives the empty-state template
494    /// (`__/__/____` for `99/99/9999`), and routes typed chars
495    /// through the mask's class filter.
496    ///
497    /// Composes with [`char_filter`](Self::char_filter): a char must
498    /// pass *both* the mask's per-position class AND the user's
499    /// `char_filter` to be accepted.
500    ///
501    /// On parse error (only the trailing-backslash case in practice),
502    /// the mask is silently dropped — the field falls back to its
503    /// no-mask behaviour rather than panicking.
504    pub fn input_mask(mut self, mask: impl AsRef<str>) -> Self {
505        match InputMask::parse(mask.as_ref()) {
506            Ok(m) => self.mask = Some(m),
507            Err(_) => self.mask = None,
508        }
509        self
510    }
511
512    /// Override the visible character used for unfilled editable mask
513    /// positions. Default: the `TEXT_FIELD_MASK_PLACEHOLDER_CHAR`
514    /// recipe constant (in `crate::styles::recipe_text_input_style`,
515    /// `_`).
516    pub fn mask_placeholder(mut self, c: char) -> Self {
517        self.mask_placeholder_override = Some(c);
518        self
519    }
520
521    /// Install a validator. The closure runs on every commit (Enter,
522    /// Tab-out, focus loss) and returns a [`ValidationOutcome`] that
523    /// drives [`validation_feedback_signal`](Self::validation_feedback_signal).
524    ///
525    /// **Does not run per-keystroke** — that's [`char_filter`](Self::char_filter)'s
526    /// job. Mixing per-keystroke text rewriting with validation
527    /// produces caret-jump bugs and is explicitly out of scope.
528    pub fn validator(mut self, f: impl Fn(&str) -> ValidationOutcome + 'static) -> Self {
529        self.validator = Some(Rc::new(f));
530        self
531    }
532
533    /// Turn this into a secure (password) field with the given
534    /// [`EchoMode`]. Masking happens at the text-engine layer (one echo
535    /// glyph per source `char`), so the plaintext never reaches the
536    /// shaper or glyph atlas while masked, and caret / selection /
537    /// hit-test stay correct. Also defaults `allow_copy` to `false` and
538    /// declares the focused node an `ImePurpose::Password` surface — the
539    /// OS IME stays enabled so non-Latin passwords can still be composed,
540    /// with the preedit masked on screen and hidden from AT. Pair with
541    /// [`revealed`](Self::revealed) for a reveal toggle.
542    pub fn secure(mut self, echo_mode: EchoMode) -> Self {
543        self.secure = true;
544        self.echo_mode = echo_mode;
545        self.allow_copy = false;
546        self
547    }
548
549    /// Declare the field's semantic [`InputPurpose`] (WCAG 1.3.5), which
550    /// selects a specialised AccessKit role (`EmailInput`, `PhoneNumberInput`,
551    /// …) so screen readers announce the field's kind. Ignored while `secure`
552    /// (the password role wins). Does not change IME behaviour — winit's
553    /// `ImePurpose` has no email/number/url variants — nor drive OS autofill,
554    /// which AccessKit cannot express (see `docs/a11y/a11y_issues.md`).
555    pub fn input_purpose(mut self, purpose: InputPurpose) -> Self {
556        self.input_purpose = purpose;
557        self
558    }
559
560    /// Publish `active_descendant` pointing at the row a *separate* list is
561    /// currently highlighting — the ARIA combobox pattern.
562    ///
563    /// Keyboard focus stays in this field while arrow keys move a highlight
564    /// through a listbox elsewhere in the tree (a command palette, a
565    /// type-ahead picker, a suggestion popup). Assistive technology follows
566    /// the focused node's active descendant, so the announcement has to be
567    /// published **here**, on the node that actually holds focus — not on the
568    /// composite ancestor that owns the list. Without it the arrow keys move a
569    /// highlight that is announced to nobody.
570    ///
571    /// Bound at `AccessibilityOnly`, so moving the highlight re-walks the AT
572    /// tree without a rebuild or a repaint. Pair with [`controls`](Self::controls).
573    pub fn active_descendant(mut self, active: Signal<Option<WidgetId>>) -> Self {
574        self.active_descendant = Some(active);
575        self
576    }
577
578    /// Publish a `controls` relation to the listbox this field drives, so an
579    /// AT client can navigate from the input to the list it is filtering.
580    /// The companion of [`active_descendant`](Self::active_descendant).
581    pub fn controls(mut self, listbox: Signal<Option<WidgetId>>) -> Self {
582        self.controls = Some(listbox);
583        self
584    }
585
586    /// Override the masking glyph (default `'•'`, U+2022). Any
587    /// uniform-width character works; the engine emits exactly one per
588    /// source `char`.
589    pub fn echo_char(mut self, c: char) -> Self {
590        self.echo_char = c;
591        self
592    }
593
594    /// Bind the reveal toggle. When the signal is `true` the field
595    /// shows plaintext regardless of [`EchoMode`]; when `false` it
596    /// masks. Shared with the eye [`IconButton::visibility_toggle`].
597    ///
598    /// [`IconButton::visibility_toggle`]: crate::IconButton::visibility_toggle
599    pub fn revealed(mut self, revealed: Signal<bool>) -> Self {
600        self.revealed = Some(revealed);
601        self
602    }
603
604    /// How a *revealed* secure field reports to assistive tech. Default
605    /// [`AtRevealPolicy::SwapRole`].
606    pub fn at_reveal_policy(mut self, policy: AtRevealPolicy) -> Self {
607        self.at_reveal_policy = policy;
608        self
609    }
610
611    /// Permit (or forbid) copy / cut. Plain fields default `true`;
612    /// [`secure`](Self::secure) flips the default to `false`. Even when
613    /// `false`, copy is allowed while the field is revealed.
614    pub fn allow_copy(mut self, allow: bool) -> Self {
615        self.allow_copy = allow;
616        self
617    }
618
619    /// Reactive handle on the published [`ValidationFeedback`] state.
620    /// Composites bind to this to render the inline feedback strip
621    /// below the field. Always present; reads `Pristine` until the
622    /// first commit (or forever if no validator is installed).
623    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
624        self.feedback.clone()
625    }
626
627    /// The `Signal<String>` this field is bound to.
628    pub fn text(&self) -> Signal<String> {
629        self.text.clone()
630    }
631
632    /// Adopt an existing handle instead of minting one.
633    ///
634    /// For a composing widget — `TextInput` wraps this field — that must hand
635    /// out a handle of its own **before** it builds the field it will delegate
636    /// to. Sharing the slot and the focus signal makes the wrapper's handle and
637    /// the field's the same handle, rather than two that agree by accident.
638    pub fn share_handle(mut self, handle: &TextFieldHandle) -> Self {
639        self.state_slot = handle.slot.clone();
640        self.focus_signal = handle.focus_signal.clone();
641        self
642    }
643
644    /// A live handle on this field, valid before and after `build`.
645    ///
646    /// The counterpart of `RichTextEditor::handle`, and the reason it exists:
647    /// an application that routes Undo, Cut, Copy, Paste and Select All to
648    /// "whichever text surface holds the caret" has to be able to *drive* every
649    /// such surface, not only the rich editors. Without this, a menu built for
650    /// those commands can only grey them out over a rename field or a search
651    /// box while the field's own key handling still works — a menu that lies
652    /// about what the keyboard can do.
653    ///
654    /// Like `caret_setter`, the handle reaches its state through the slot the
655    /// widget late-populates, so it may be taken while the tree is being
656    /// described and used once it is live.
657    pub fn handle(&self) -> TextFieldHandle {
658        TextFieldHandle {
659            slot: self.state_slot.clone(),
660            focus_signal: self.focus_signal.clone(),
661        }
662    }
663
664    /// The interaction signal this field writes on focus changes.
665    /// Call before inserting the field into the tree.
666    pub fn interaction(&self) -> Signal<InteractionState> {
667        self.interaction.clone()
668    }
669
670    /// Reactive caret position in the field's text (in `usize` char
671    /// offsets). Updates after every keyboard or pointer action that
672    /// moves the cursor. Used by composing widgets that need to know
673    /// where the caret is — e.g. `DateEdit` reads this to figure out
674    /// which date segment Up/Down should step.
675    pub fn caret_position(&self) -> Signal<usize> {
676        self.caret_position.clone()
677    }
678
679    /// Returns a callable that programmatically sets the caret
680    /// position (in char offsets) on the field. Capture this on the
681    /// builder BEFORE `ctx.add(...)` consumes the field; call it
682    /// after a programmatic text rewrite to restore the caret to the
683    /// right column instead of leaving it at the document end (the
684    /// default behaviour of `cursor.insert_text`).
685    ///
686    /// The returned closure becomes a no-op until `build()` runs;
687    /// after build it walks the field's inner state and moves the
688    /// document cursor to `position`, clamped to the document
689    /// length. Used by `DateEdit` / `TimeEdit` segment-stepping to
690    /// keep the caret within its current segment after Up/Down.
691    pub fn caret_setter(&self) -> std::rc::Rc<dyn Fn(usize)> {
692        let slot = self.state_slot.clone();
693        std::rc::Rc::new(move |position: usize| {
694            if let Some(state) = slot.borrow().as_ref() {
695                let st = state.borrow();
696                st.cursor
697                    .set_position(position, teksilo_text::text_document::MoveMode::MoveAnchor);
698                let actual = st.cursor.position();
699                if st.cursor_position.get() != actual {
700                    st.cursor_position.set(actual);
701                }
702            }
703        })
704    }
705}
706
707impl TextInputField {
708    /// Shape the field's text once more and keep the geometry.
709    ///
710    /// The whole line is measured with no width cap: the field scrolls
711    /// rather than ellipsizes, so every character has an extent even while
712    /// it sits outside the viewport, and `accessibility` slides the result
713    /// by the scroll offset.
714    fn retain_text_geometry(
715        &self,
716        bounds: Rect,
717        style: &TextStyle,
718        backend: &Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>,
719        base_direction: teksilo_core::accesskit::TextDirection,
720    ) {
721        let Some(state) = self.state.as_ref() else {
722            return;
723        };
724        let (text, masked) = {
725            let st = state.borrow();
726            (
727                st.document.to_plain_text().unwrap_or_default(),
728                st.should_mask(),
729            )
730        };
731        if masked {
732            // Masking happens inside the editing engine precisely so the
733            // secret never reaches the shaper or the glyph atlas; measuring
734            // it here would put it there. A masked field is also
735            // `Role::PasswordInput`, whose branch emits no runs to carry
736            // geometry anyway.
737            *self.retained.borrow_mut() = None;
738            return;
739        }
740        let layout = backend.borrow_mut().layout_single_line(&text, style, None);
741        *self.retained.borrow_mut() = Some(RetainedText {
742            text,
743            geometry: layout.geometry.clone(),
744            bounds,
745            base_direction,
746        });
747    }
748
749    /// Borrow the shared state. Panics if called before `build()`
750    /// has run — the state is allocated in `build()` from the
751    /// builder config.
752    fn state(&self) -> &SharedState {
753        self.state
754            .as_ref()
755            .expect("TextInputField::state called before build")
756    }
757}
758
759/// The reading direction the field's runs are announced with.
760///
761/// The single-line editing engine reports no per-segment direction, so the
762/// ambient layout direction is the only answer available; a right-to-left
763/// field therefore announces right-to-left even for Latin content, which is
764/// what the surrounding UI does too.
765fn base_text_direction(
766    direction: teksilo_core::environment::LayoutDirection,
767) -> teksilo_core::accesskit::TextDirection {
768    match direction {
769        teksilo_core::environment::LayoutDirection::RightToLeft => {
770            teksilo_core::accesskit::TextDirection::RightToLeft
771        }
772        _ => teksilo_core::accesskit::TextDirection::LeftToRight,
773    }
774}
775
776/// Adjust `scroll_x` so the caret stays within the visible viewport.
777///
778/// `text_viewport_width` is the portion of the viewport reserved for
779/// editable text, i.e. `viewport_width - suffix_width`. Callers pass
780/// the reduced width explicitly so the scroll never slides text
781/// behind the non-editable suffix.
782fn ensure_caret_visible_h(st: &mut TextInputState, text_viewport_width: f32, tokens: &InputTokens) {
783    if !st.engine.has_full_layout() || text_viewport_width <= 0.0 {
784        return;
785    }
786    let pos = st.cursor.position();
787    // Single-line input: no wrap, affinity is a no-op.
788    let caret = st.engine.caret_rect(pos, CursorAffinity::Downstream);
789    let caret_x = caret[0];
790    let caret_w = caret[2].max(1.0);
791    let vw = text_viewport_width;
792
793    let margin = scroll_margin(tokens);
794    if caret_x - st.scroll_x < margin {
795        st.scroll_x = (caret_x - margin).max(0.0);
796    } else if caret_x + caret_w - st.scroll_x > vw - margin {
797        st.scroll_x = caret_x + caret_w - vw + margin;
798    }
799}
800
801/// Update the cached suffix text and re-run layout on the suffix
802/// engine. Called from `build()` for the initial value and from
803/// the reactive effect when the bound suffix signal fires.
804fn relayout_suffix(state: &SharedState, new_text: &str) {
805    let mut st = state.borrow_mut();
806    st.suffix = new_text.to_string();
807    if new_text.is_empty() {
808        st.suffix_width = 0.0;
809        // Leave the engine in place (cheap to reuse) but don't
810        // lay out — paint skips the suffix when width is zero.
811        return;
812    }
813    let Some(engine) = st.suffix_engine.as_mut() else {
814        // No engine allocated (pure-static path that started
815        // empty and never became non-empty). Nothing to lay out —
816        // `build()` allocates the engine eagerly for every bound
817        // suffix, so a late signal flip never reaches this branch.
818        return;
819    };
820    let doc = TextDocument::new();
821    let _ = doc.set_plain_text(new_text);
822    let flow = doc.snapshot_flow();
823    engine.layout_full(&flow);
824    st.suffix_width = engine.max_content_width();
825}
826
827/// Paint glyphs from a pre-laid-out suffix `RenderFrame` at a fixed
828/// origin. Decorations, selection rectangles, and caret are ignored —
829/// the suffix is plain non-editable text, so only the glyph pass is
830/// needed. Kept inline (rather than reusing `paint_frame`) to avoid
831/// the `TextDocument` / `ImageCache` parameters `paint_frame`
832/// requires for inline images the suffix never contains.
833fn paint_suffix_glyphs(canvas: &mut Canvas, frame: &teksilo_text::RenderFrame, origin: Point) {
834    use teksilo_canvas::GlyphQuad as CanvasGlyphQuad;
835    for g in frame.glyphs.iter() {
836        let quad = CanvasGlyphQuad {
837            screen: [
838                g.screen[0] + origin.x,
839                g.screen[1] + origin.y,
840                g.screen[2],
841                g.screen[3],
842            ],
843            atlas: g.atlas,
844            color: g.color,
845            is_color: g.is_color,
846        };
847        canvas.draw_glyph_quad(quad);
848    }
849}
850
851/// The band a selection is painted in. Two axes decide it, and they do *not*
852/// decide it the same way:
853///
854/// | field focus | window | band |
855/// | --- | --- | --- |
856/// | focused | active | vivid `selection_bg_active` |
857/// | focused | inactive | muted `selection_bg_inactive` |
858/// | not focused | either | nothing — fully transparent |
859///
860/// **An entry that does not hold focus paints no selection**, which is what
861/// every native single-line field does. A Win32 edit control hides the
862/// selection on focus-out unless it was created with `ES_NOHIDESEL`, and
863/// WinForms spells the same default `TextBoxBase.HideSelection = true`.
864/// `QLineEdit::focusOutEvent` goes further and calls `deselect()` outright for
865/// every focus reason except `ActiveWindowFocusReason` and `PopupFocusReason`.
866/// On macOS an `NSTextField` that stops being first responder has its shared
867/// field editor detached, so there is no selection left to draw. GTK's entry is
868/// the one toolkit that keeps a defocused selection lit, and that has been
869/// filed against it as a papercut rather than defended as a design.
870///
871/// The *window* axis is the one where dimming, not hiding, is correct — and
872/// the same three toolkits say so: Qt's carve-out for `ActiveWindowFocusReason`
873/// exists precisely so a focused field keeps its selection when the window goes
874/// to the background, and AppKit renders it there in
875/// `unemphasizedSelectedTextBackgroundColor`. Losing the window is not the same
876/// event as losing the caret.
877///
878/// Multi-line editors (`RichTextEditor`, `CodeEditor`, `LogView`) are
879/// deliberately **not** on this rule: `QTextEdit` / `NSTextView` / every code
880/// editor keep a visible selection in a blurred view, because there the
881/// selection is a region of a document the user is working with rather than a
882/// transient edit state.
883///
884/// The selection *state* survives blur either way — the `on_focus(false)` arm
885/// spells out why (the right-click Copy path needs it) — this decides only what
886/// is drawn.
887fn field_selection_color(
888    colors: &teksilo_tokens::ColorTokens,
889    window_active: bool,
890    has_focus: bool,
891) -> [f32; 4] {
892    match (has_focus, window_active) {
893        (false, _) => [0.0; 4],
894        (true, true) => colors.selection_bg_active.to_array(),
895        (true, false) => colors.selection_bg_inactive.to_array(),
896    }
897}
898
899/// Simplified frame-loop tick for single-line text input.
900fn tick(state: &mut TextInputState, delta: f32) -> bool {
901    if !state.pending_chars.is_empty() {
902        let batch = std::mem::take(&mut state.pending_chars);
903        let _ = state.cursor.insert_text(&batch);
904        state.pending_text_changed = true;
905    }
906
907    let had_events = state.drain_events();
908
909    // Blink only when focused AND the host window is active — the caret hides
910    // in an inactive window (the universal desktop convention). The shared
911    // blink machine's inactive arm then turns it off, since `!caret_active`
912    // now also covers the window-inactive case.
913    let caret_active = state.has_focus && state.window_active;
914    let caret_visible = state.caret_visible.clone();
915    let wake = state.frame_wake_at.clone();
916    // A single-line field always blinks (no read-only/static presets), so it
917    // hands the shared machine a fixed `Blinking` policy.
918    state.blink.tick(
919        CaretPolicy::Blinking,
920        caret_active,
921        &caret_visible,
922        wake.as_ref(),
923    );
924
925    if state.needs_full_layout && state.viewport_width > 0.0 {
926        state.layout_full_masked();
927        state.needs_full_layout = false;
928        state.content_dirty = true;
929    }
930
931    if state.pending_text_changed {
932        let new_text = state.document.to_plain_text().unwrap_or_default();
933        if state.text_signal.get() != new_text {
934            state.deferred_text_update = Some(new_text);
935        }
936    }
937
938    if state.debounce.tick(delta) {
939        if state.pending_text_changed {
940            state.pending_text_changed = false;
941        }
942        if let Some((cu, cr)) = state.pending_undo_redo.take() {
943            if state.can_undo.get() != cu {
944                state.can_undo.set(cu);
945            }
946            if state.can_redo.get() != cr {
947                state.can_redo.set(cr);
948            }
949        }
950    }
951    let debounce_work = state.pending_text_changed || state.pending_undo_redo.is_some();
952
953    had_events || debounce_work
954}
955
956/// Handle AccessKit actions (SetValue, SetTextSelection, Focus).
957fn handle_access_action(
958    state: &SharedState,
959    action: teksilo_core::accesskit::Action,
960    data: Option<teksilo_core::accesskit::ActionData>,
961    ctx: &mut EventContext,
962) -> EventResponse {
963    use teksilo_core::accesskit::{Action, ActionData};
964
965    match (action, data) {
966        (Action::SetTextSelection, Some(ActionData::SetTextSelection(sel))) => {
967            let st = state.borrow();
968            st.cursor.set_position(
969                sel.anchor.character_index,
970                teksilo_text::text_document::MoveMode::MoveAnchor,
971            );
972            st.cursor.set_position(
973                sel.focus.character_index,
974                teksilo_text::text_document::MoveMode::KeepAnchor,
975            );
976            drop(st);
977            sync_cursor_signals(state);
978            ctx.request_frame();
979            EventResponse::Handled
980        }
981        // Both write arms are gated on the field being editable. The node
982        // advertises neither action while `read_only` is set, but an adapter
983        // dispatches what the technology asks for rather than what the node
984        // offered — AT-SPI publishes `EditableText` off the interface set, not
985        // off the action list — so a read-only `SpinBox`, `DateEdit`,
986        // `TimeEdit`, `DateTimeEdit` or `DateRangeEdit` had its value rewritten
987        // by anything that tried. Refusing here is the only place that covers
988        // every host at once, and it reports the refusal instead of a silent
989        // no-op.
990        (Action::SetValue | Action::ReplaceSelectedText, _) if state.borrow().read_only => {
991            EventResponse::Ignored
992        }
993        (Action::SetValue, Some(ActionData::Value(value))) => {
994            // Snapshot the document before overwriting it. A host that refuses
995            // the string has to be able to put the field back, and it cannot do
996            // it from its own side: its revert writes the bound
997            // `Signal<String>`, which still holds the *pre-edit* display at
998            // this point (the document→signal sync is deferred to the next
999            // frame tick), so the write is a no-op and the rejected string is
1000            // what the deferred sync then publishes.
1001            let before = {
1002                let st = state.borrow();
1003                st.document.to_plain_text().unwrap_or_default()
1004            };
1005            let st = state.borrow();
1006            st.cursor.select(SelectionType::Document);
1007            let _ = st.cursor.insert_text(value.as_ref());
1008            // A composite whose text only *projects* a typed value handles the
1009            // write itself, because an assistive technology's `SetValue` is a
1010            // finished edit, not a keystroke: without it a `SpinBox` would
1011            // show the new number, keep the old value until the next blur, and
1012            // never fire `on_value_changed`. It is handed the string, not left
1013            // to read the bound signal, which this edit has not synced yet.
1014            let host = st.on_access_set_value.clone();
1015            drop(st);
1016            sync_cursor_signals(state);
1017            ctx.request_frame();
1018            // The host owns the verdict: it parses, clamps and — on a string it
1019            // cannot read — reverts the display to the value the composite
1020            // still holds. Reporting `Handled` regardless told the technology a
1021            // write had landed when the field had just thrown it away, so
1022            // Orca's value entry and macOS's `setAccessibilityValue:` both read
1023            // back success on `"twelve"`.
1024            match host {
1025                Some(host) if !host(value.as_ref(), ctx) => {
1026                    // Refused, so the field must not keep the refused string:
1027                    // reporting `Ignored` over a document still showing it is
1028                    // the same lie the other way round.
1029                    let st = state.borrow();
1030                    st.cursor.select(SelectionType::Document);
1031                    let _ = st.cursor.insert_text(&before);
1032                    drop(st);
1033                    sync_cursor_signals(state);
1034                    EventResponse::Ignored
1035                }
1036                _ => EventResponse::Handled,
1037            }
1038        }
1039        (Action::ReplaceSelectedText, Some(ActionData::Value(value))) => {
1040            // Insert at the caret, replacing the active selection (if
1041            // any) — NOT the whole document like `SetValue`. This is the
1042            // AT-SPI (Linux) / UIA (Windows) braille-keyboard and
1043            // dictation insertion path; macOS routes insertion through
1044            // `SetValue` instead, so this never fires there. We advertise
1045            // the action in `accessibility()`, so we must service it.
1046            let st = state.borrow();
1047            let _ = st.cursor.insert_text(value.as_ref());
1048            drop(st);
1049            sync_cursor_signals(state);
1050            ctx.request_frame();
1051            EventResponse::Handled
1052        }
1053        (Action::Focus, _) => {
1054            if let Some(id) = state.borrow().field_widget_id {
1055                ctx.request_focus(id);
1056            }
1057            EventResponse::Handled
1058        }
1059        _ => EventResponse::Ignored,
1060    }
1061}
1062
1063/// Build a fresh right-click context menu widget. Called from the
1064/// `.context_menu(...)` factory on every right-click, so each open
1065/// reads live `has_selection` / `is_empty` state when computing each
1066/// item's enabled flag.
1067fn build_context_menu_widget(state: &SharedState) -> Box<dyn Widget> {
1068    let st = state.borrow();
1069    let has_selection = st.cursor.has_selection();
1070    let doc_non_empty = !st.document.to_plain_text().unwrap_or_default().is_empty();
1071    // Secure fields suppress Cut / Copy while masked (still allowed when
1072    // revealed or when the developer opted in via `allow_copy`).
1073    let copy_allowed = st.copy_allowed();
1074    drop(st);
1075
1076    Box::new(
1077        MenuList::new()
1078            .item(menu_row_cut(state).enabled(has_selection && copy_allowed))
1079            .item(menu_row_copy(state).enabled(has_selection && copy_allowed))
1080            .item(menu_row_paste(state))
1081            .item(MenuSeparator)
1082            .item(menu_row_select_all(state).enabled(doc_non_empty)),
1083    )
1084}
1085
1086// The four command rows, shared by the right-click menu above and the touch
1087// selection toolbar in `touch.rs`. One implementation of each command, reached
1088// two ways — the menu decides enablement from a snapshot taken as it opens, the
1089// toolbar decides *visibility* from the controller's derived
1090// `ClipboardActions`, and neither owns the command itself.
1091
1092pub(crate) fn menu_row_cut(state: &SharedState) -> MenuItem {
1093    let state = state.clone();
1094    MenuItem::new(tr_widget!(menu_cut()))
1095        .shortcut_label(format_keystroke(KeyStroke::command(Key::X)))
1096        .on_activate_fn(move |ctx| {
1097            {
1098                let mut st = state.borrow_mut();
1099                keyboard::clipboard_cut(&mut st, ctx);
1100            }
1101            sync_cursor_signals(&state);
1102            ctx.request_frame();
1103        })
1104}
1105
1106pub(crate) fn menu_row_copy(state: &SharedState) -> MenuItem {
1107    let state = state.clone();
1108    MenuItem::new(tr_widget!(menu_copy()))
1109        .shortcut_label(format_keystroke(KeyStroke::command(Key::C)))
1110        .on_activate_fn(move |ctx| {
1111            let mut st = state.borrow_mut();
1112            keyboard::clipboard_copy(&mut st, ctx);
1113        })
1114}
1115
1116pub(crate) fn menu_row_paste(state: &SharedState) -> MenuItem {
1117    let state = state.clone();
1118    MenuItem::new(tr_widget!(menu_paste()))
1119        .shortcut_label(format_keystroke(KeyStroke::command(Key::V)))
1120        .on_activate_fn(move |ctx| {
1121            {
1122                let mut st = state.borrow_mut();
1123                keyboard::clipboard_paste(&mut st, ctx);
1124            }
1125            sync_cursor_signals(&state);
1126            ctx.request_frame();
1127        })
1128}
1129
1130pub(crate) fn menu_row_select_all(state: &SharedState) -> MenuItem {
1131    let state = state.clone();
1132    MenuItem::new(tr_widget!(menu_select_all()))
1133        .shortcut_label(format_keystroke(KeyStroke::command(Key::A)))
1134        .on_activate_fn(move |ctx| {
1135            {
1136                let st = state.borrow();
1137                st.cursor.select(SelectionType::Document);
1138            }
1139            sync_cursor_signals(&state);
1140            ctx.request_frame();
1141        })
1142}
1143
1144/// Run the validator on the bound text and update the feedback signal.
1145///
1146/// On `Corrected`, also writes the corrected text back to the bound
1147/// signal — the field's external→internal sync effect picks this up
1148/// and rewrites the document in the next frame. On `Invalid`, the
1149/// text is left as-typed; composites that want a "revert on invalid"
1150/// behaviour observe the feedback signal and rewrite the text from
1151/// their own source of truth (e.g., `DateEdit` reformats from its
1152/// `Signal<Option<Date>>`).
1153fn run_validator_and_apply(
1154    validator: &ValidatorFn,
1155    bound_text: &Signal<String>,
1156    feedback: &Signal<ValidationFeedback>,
1157) {
1158    let raw = bound_text.get();
1159    match validator(&raw) {
1160        ValidationOutcome::Valid => {
1161            feedback.set(ValidationFeedback::Valid);
1162        }
1163        ValidationOutcome::Corrected { corrected, message } => {
1164            // Write the corrected text first so observers of the
1165            // bound signal see the new value before the feedback
1166            // signal flips. Composites that bind to BOTH signals
1167            // (rare) will see a consistent pair: text + correction
1168            // notice describing the change.
1169            if bound_text.get() != corrected {
1170                bound_text.set(corrected);
1171            }
1172            feedback.set(ValidationFeedback::Corrected {
1173                message,
1174                since: std::time::Instant::now(),
1175            });
1176        }
1177        ValidationOutcome::Invalid { message } => {
1178            feedback.set(ValidationFeedback::Invalid { message });
1179        }
1180    }
1181}
1182
1183/// Build the worst-case-glyph version of an [`InputMask`] for
1184/// natural-width measurement: every editable slot holds the widest
1185/// plausible character its class can accept, and every fixed slot
1186/// holds its literal. Used by `build()` to size the field's
1187/// intrinsic envelope so a fully-typed value never overflows the
1188/// reported natural width.
1189///
1190/// Per-class worst-case glyph (Inter and most UI sans-serifs):
1191/// - `Digit` → `0` (tabular figures are constant-width, but `0` is
1192///   representative for fonts that aren't)
1193/// - `Letter` / `Alphanumeric` / `Any` → `M` (widest cap glyph)
1194/// - `HexDigit` → `0`
1195fn worst_case_template(mask: &InputMask) -> String {
1196    let mut s = String::with_capacity(mask.len());
1197    for pos in mask.positions() {
1198        match pos {
1199            MaskPosition::Editable { class, .. } => {
1200                s.push(match class {
1201                    MaskClass::Digit | MaskClass::HexDigit => '0',
1202                    MaskClass::Letter | MaskClass::Alphanumeric | MaskClass::Any => 'M',
1203                });
1204            }
1205            MaskPosition::Fixed(c) => s.push(*c),
1206        }
1207    }
1208    s
1209}
1210
1211/// Measure the advance width of `text` in logical pixels using the
1212/// app-wide `SharedTypesetter` (the same backend the field paints
1213/// with). Falls back to a per-character-class heuristic when no
1214/// typesetter is installed (headless tests) so the caller still gets
1215/// a non-zero width and any natural-width / cap logic behaves
1216/// reasonably even there. The fallback weights match Inter's body
1217/// proportions closely enough that the difference between an
1218/// underscore and a wide cap glyph (`M`) shows up in headless tests
1219/// — important for verifying the worst-case-glyph mask measurement
1220/// without booting a typesetter.
1221fn measure_width_px(ctx: &mut BuildContext, text: &str, style: &TextStyle) -> f32 {
1222    if text.is_empty() {
1223        return 0.0;
1224    }
1225    if let Some(ts) = ctx.app_state::<SharedTypesetter>() {
1226        let backend = ts.as_text_backend();
1227        let layout = backend.borrow_mut().layout_single_line(text, style, None);
1228        return layout.width;
1229    }
1230    let em = style.size;
1231    text.chars()
1232        .map(|c| match c {
1233            ' ' => 0.30,
1234            '_' => 0.45,
1235            ':' | '.' | ',' | ';' | '/' | '|' | '!' | 'i' | 'l' | 'I' => 0.30,
1236            '0'..='9' => 0.55,
1237            'M' | 'W' | 'm' | 'w' => 0.85,
1238            'A'..='Z' => 0.65,
1239            'a'..='z' => 0.50,
1240            _ => 0.55,
1241        })
1242        .map(|w: f32| w * em)
1243        .sum()
1244}
1245
1246#[cfg(test)]
1247mod text_run_tests {
1248    use super::*;
1249    use std::cell::RefCell;
1250    use teksilo_canvas::{MockTextBackend, SizeProposal};
1251    use teksilo_core::accesskit::Role;
1252    use teksilo_core::signal::Signal;
1253    use teksilo_core::widget_id::WidgetId;
1254    use teksilo_core::widget_tree::WidgetTree;
1255    use teksilo_text::text_document::MoveMode;
1256
1257    fn tree_with_mock_backend() -> WidgetTree {
1258        WidgetTree::new()
1259            .with_theme(teksilo_core::presets::intui::light())
1260            .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
1261    }
1262
1263    fn state_of(tree: &WidgetTree, id: WidgetId) -> SharedState {
1264        tree.widget_as_any(id)
1265            .and_then(|w| w.downcast_ref::<TextInputField>())
1266            .map(|field| field.state().clone())
1267            .expect("a built TextInputField")
1268    }
1269
1270    #[test]
1271    fn a_caret_past_255_chars_lands_in_the_second_chunk() {
1272        // `accesskit_consumer` probes a position's character index as a `u8`,
1273        // so the emitter splits a long line into 255-character runs. The
1274        // field still speaks in document character offsets, and a caret at
1275        // 260 has to resolve to the run that holds it — reporting it against
1276        // the first run would put the caret 255 characters behind the text.
1277        let mut tree = tree_with_mock_backend();
1278        let id = tree.add(TextInputField::new(Signal::new("a".repeat(300))));
1279        tree.layout(SizeProposal::exact(200.0, 20.0));
1280
1281        state_of(&tree, id)
1282            .borrow_mut()
1283            .cursor
1284            .set_position(260, MoveMode::MoveAnchor);
1285
1286        let update = tree.sync_accessibility();
1287        let (_, input) = update
1288            .nodes
1289            .iter()
1290            .find(|(_, node)| node.role() == Role::TextInput)
1291            .expect("the field reports a text-input node");
1292        let runs = input.children();
1293        assert_eq!(runs.len(), 2, "300 characters split at the 255 cap");
1294
1295        let selection = input
1296            .text_selection()
1297            .expect("the field exposes its caret to assistive technology");
1298        assert_eq!(selection.focus.node, runs[1]);
1299        assert_eq!(selection.focus.character_index, 5);
1300    }
1301
1302    #[test]
1303    fn a_protected_field_emits_no_text_runs() {
1304        // A run publishes the character count, the per-character extents and
1305        // the word boundaries of what it carries. On a masked field that is a
1306        // description of the password, so the protected branch emits the
1307        // bullet string and nothing else.
1308        let mut tree = tree_with_mock_backend();
1309        let _id = tree
1310            .add(TextInputField::new(Signal::new("hunter2".to_string())).secure(EchoMode::Masked));
1311        tree.layout(SizeProposal::exact(200.0, 20.0));
1312
1313        let update = tree.sync_accessibility();
1314        let (_, field) = update
1315            .nodes
1316            .iter()
1317            .find(|(_, node)| node.role() == Role::PasswordInput)
1318            .expect("a masked field reports Role::PasswordInput");
1319        assert_eq!(field.value(), Some("•••••••"));
1320        assert!(
1321            field.children().is_empty(),
1322            "a masked field must own no text runs"
1323        );
1324        assert!(
1325            !update
1326                .nodes
1327                .iter()
1328                .any(|(_, node)| node.role() == Role::TextRun),
1329            "no text run may be emitted anywhere for a masked field"
1330        );
1331        assert!(
1332            field.text_selection().is_none(),
1333            "the caret model stays opaque so no structure about the secret leaks"
1334        );
1335    }
1336}
1337
1338#[cfg(test)]
1339mod window_active_tests;
1340
1341/// **Pointer editing, both devices.** The mouse half is a baseline the touch
1342/// work needed before it could change `mouse.rs`: nothing in this stack
1343/// dispatched a press-move-release pair or a multi-click at a field, so
1344/// "the suite still passes" was satisfiable with drag-select broken.
1345#[cfg(test)]
1346mod pointer_tests;
1347
1348/// **A key the platform decorates with control text must still bubble.**
1349///
1350/// These dispatch `KeyDown` with the `text` a real keyboard carries. Every
1351/// synthetic helper in the workspace sends `text: None`, which skips the branch
1352/// under test entirely — so a test written with `press_key` passes on the bug.
1353#[cfg(test)]
1354mod key_text_bubbling_tests;
1355
1356/// A live handle on a [`TextInputField`] — its text-editing commands, for a
1357/// caller outside the widget.
1358///
1359/// Every method is a no-op before the field is built (and after it is
1360/// destroyed), which is the honest answer rather than a panic: a menu row bound
1361/// to a field that is no longer on screen should do nothing, not crash.
1362#[derive(Clone)]
1363pub struct TextFieldHandle {
1364    slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>>,
1365    focus_signal: Signal<bool>,
1366}
1367
1368impl std::fmt::Debug for TextFieldHandle {
1369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1370        f.debug_struct("TextFieldHandle")
1371            .field("live", &self.slot.borrow().is_some())
1372            .field("focused", &self.focus_signal.get())
1373            .finish()
1374    }
1375}
1376
1377impl TextFieldHandle {
1378    /// A handle not yet attached to any field — for a composing widget that
1379    /// hands one out before building the field it will delegate to. Every
1380    /// method answers "nothing" until [`TextInputField::share_handle`] binds it.
1381    pub fn detached() -> Self {
1382        Self {
1383            slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
1384            focus_signal: Signal::new(false),
1385        }
1386    }
1387
1388    /// `true` while this field holds the keyboard focus. Observable, so a
1389    /// router can follow the caret without polling.
1390    pub fn focused_signal(&self) -> Signal<bool> {
1391        self.focus_signal.clone()
1392    }
1393
1394    /// Is the widget built and still alive?
1395    pub fn is_live(&self) -> bool {
1396        self.slot.borrow().is_some()
1397    }
1398
1399    fn with<R>(&self, f: impl FnOnce(&mut TextInputState) -> R) -> Option<R> {
1400        let slot = self.slot.borrow();
1401        let state = slot.as_ref()?;
1402        let mut st = state.borrow_mut();
1403        Some(f(&mut st))
1404    }
1405
1406    /// The field's current text.
1407    pub fn text(&self) -> String {
1408        self.with(|st| st.document.to_plain_text().unwrap_or_default())
1409            .unwrap_or_default()
1410    }
1411
1412    /// Is any text selected right now?
1413    pub fn has_selection(&self) -> bool {
1414        self.with(|st| st.cursor.has_selection()).unwrap_or(false)
1415    }
1416
1417    /// May this field's content be copied at all? A password field says no —
1418    /// see [`TextInputField::allow_copy`].
1419    pub fn allows_copy(&self) -> bool {
1420        self.with(|st| st.allow_copy).unwrap_or(false)
1421    }
1422
1423    /// Is the field refusing edits? Cut and Paste are meaningless when it is.
1424    pub fn is_read_only(&self) -> bool {
1425        self.with(|st| st.read_only).unwrap_or(true)
1426    }
1427
1428    /// Select the whole field.
1429    pub fn select_all(&self) {
1430        self.with(|st| st.cursor.select(SelectionType::Document));
1431    }
1432
1433    /// Copy the selection to the clipboard.
1434    pub fn copy(&self, ctx: &EventContext) {
1435        self.with(|st| keyboard::clipboard_copy(st, ctx));
1436    }
1437
1438    /// Cut the selection to the clipboard.
1439    pub fn cut(&self, ctx: &EventContext) {
1440        self.with(|st| keyboard::clipboard_cut(st, ctx));
1441    }
1442
1443    /// Paste over the selection.
1444    pub fn paste(&self, ctx: &EventContext) {
1445        self.with(|st| keyboard::clipboard_paste(st, ctx));
1446    }
1447
1448    /// Undo this field's own last edit.
1449    pub fn undo(&self) {
1450        self.with(|st| {
1451            let _ = st.document.undo();
1452        });
1453    }
1454
1455    /// Redo this field's own last undone edit.
1456    pub fn redo(&self) {
1457        self.with(|st| {
1458            let _ = st.document.redo();
1459        });
1460    }
1461
1462    /// Is there anything to undo? Debounced like the editor's twin.
1463    pub fn can_undo(&self) -> Signal<bool> {
1464        self.with(|st| st.can_undo.clone())
1465            .unwrap_or_else(|| Signal::new(false))
1466    }
1467
1468    /// Is there anything to redo?
1469    pub fn can_redo(&self) -> Signal<bool> {
1470        self.with(|st| st.can_redo.clone())
1471            .unwrap_or_else(|| Signal::new(false))
1472    }
1473}
1474
1475// ── The framework's uniform view of a text-editing widget ────────────────────
1476
1477impl teksilo_core::text_surface::TextSurface for TextFieldHandle {
1478    fn can_undo(&self) -> bool {
1479        TextFieldHandle::can_undo(self).get()
1480    }
1481
1482    fn can_redo(&self) -> bool {
1483        TextFieldHandle::can_redo(self).get()
1484    }
1485
1486    fn undo(&self) {
1487        TextFieldHandle::undo(self);
1488    }
1489
1490    fn redo(&self) {
1491        TextFieldHandle::redo(self);
1492    }
1493
1494    fn has_selection(&self) -> bool {
1495        TextFieldHandle::has_selection(self)
1496    }
1497
1498    fn is_read_only(&self) -> bool {
1499        TextFieldHandle::is_read_only(self)
1500    }
1501
1502    fn allows_copy(&self) -> bool {
1503        TextFieldHandle::allows_copy(self)
1504    }
1505
1506    fn cut(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
1507        TextFieldHandle::cut(self, ctx);
1508    }
1509
1510    fn copy(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
1511        TextFieldHandle::copy(self, ctx);
1512    }
1513
1514    fn paste(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
1515        TextFieldHandle::paste(self, ctx);
1516    }
1517
1518    /// A one-line field carries no formatting to strip, so the plain paste
1519    /// *is* the paste. Answering "nothing" here would make Edit ▸ Paste without
1520    /// formatting silently dead over a rename box.
1521    fn paste_plain(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
1522        TextFieldHandle::paste(self, ctx);
1523    }
1524
1525    fn select_all(&self) {
1526        TextFieldHandle::select_all(self);
1527    }
1528}