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 mod validator;
60
61use std::rc::Rc;
62use teksilo_i18n::tr_widget;
63
64use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
65use teksilo_core::accessibility::AccessNodeBuilder;
66use teksilo_core::build_context::BuildContext;
67use teksilo_core::event::{EventResponse, Key};
68use teksilo_core::shortcut::KeyStroke;
69use teksilo_core::signal::{Prop, Signal};
70use teksilo_core::widget::{
71    CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
72};
73use teksilo_core::widget_builder::HandlerSet;
74use teksilo_core::widget_id::WidgetId;
75use teksilo_text::text_document::{SelectionType, TextDocument};
76use teksilo_text::{CursorAffinity, CursorDisplay, RichTextEngine, SharedTypesetter};
77use teksilo_tokens::TextStyle;
78
79use crate::button::InteractionState;
80use crate::keystroke_format::format_keystroke;
81use crate::menu_item::MenuItem;
82use crate::menu_list::{MenuList, MenuSeparator};
83use crate::rich_text::paint::{PaintParams, paint_frame};
84
85pub(crate) use self::state::{CharFilter, CommandFactory};
86use self::state::{SharedState, TextInputConfig, TextInputState, sync_cursor_signals};
87
88pub use self::mask::{InputMask, MaskClass, MaskError, MaskPosition};
89pub use self::validator::{ValidationFeedback, ValidationOutcome, ValidatorFn};
90
91// The caret blink period and the debounce window are shared with every other
92// text surface — see `common::editor_runtime`. They used to be re-declared
93// here as private constants ("same as RichTextEditor", said the comment),
94// which is exactly the kind of duplication that drifts silently: two carets
95// blinking at different rates is invisible to tests and obvious to users.
96use crate::common::editor_runtime::CaretPolicy;
97
98/// Horizontal scroll margin in pixels. The caret stays at least this
99/// far from the left/right edge of the viewport.
100const SCROLL_MARGIN: f32 = 4.0;
101
102/// Default text-area height when the caller does not override it
103/// via [`TextInputField::text_height`]. Picked to match the Int UI
104/// `text_field.height` token minus 2×border — the value the
105/// `TextInput` composite reports — so a bare `TextInputField`
106/// added to a tree without its composite still looks right.
107const DEFAULT_TEXT_HEIGHT: f32 = 20.0;
108
109/// The semantic purpose of a text field, surfaced to assistive technology as
110/// a specialised AccessKit role (WCAG 1.3.5 Identify Input Purpose / EN 301 549).
111///
112/// This is the in-framework-achievable part of SC 1.3.5: a screen reader
113/// announces "email, edit text" instead of a generic "edit text". The FULL
114/// HTML `autocomplete`-token vocabulary (`given-name`, `postal-code`,
115/// `cc-number`, …) that drives OS/browser autofill has **no representation in
116/// AccessKit 0.24** and therefore cannot be exposed from Teksilo — see
117/// `docs/a11y/a11y_issues.md`. Password entry is configured via
118/// [`TextInputField::secure`], not here.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120pub enum InputPurpose {
121    /// Ordinary free text (`Role::TextInput`).
122    #[default]
123    Normal,
124    /// Email address (`Role::EmailInput`).
125    Email,
126    /// Telephone number (`Role::PhoneNumberInput`).
127    Phone,
128    /// URL (`Role::UrlInput`).
129    Url,
130    /// Numeric entry — e.g. a quantity or code (`Role::NumberInput`).
131    Number,
132    /// Search query (`Role::SearchInput`).
133    Search,
134}
135
136impl InputPurpose {
137    /// The AccessKit role for a non-secure field with this purpose.
138    pub(crate) fn to_role(self) -> teksilo_core::accesskit::Role {
139        use teksilo_core::accesskit::Role;
140        match self {
141            InputPurpose::Normal => Role::TextInput,
142            InputPurpose::Email => Role::EmailInput,
143            InputPurpose::Phone => Role::PhoneNumberInput,
144            InputPurpose::Url => Role::UrlInput,
145            InputPurpose::Number => Role::NumberInput,
146            InputPurpose::Search => Role::SearchInput,
147        }
148    }
149}
150
151/// How a secure ([`TextInputField::secure`]) field echoes typed
152/// characters. Mirrors Qt's `QLineEdit::EchoMode`.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
154pub enum EchoMode {
155    /// Replace every character with the echo glyph (default `'•'`).
156    /// The plaintext stays in the bound `Signal<String>` but never
157    /// reaches the text engine while masked.
158    #[default]
159    Masked,
160    /// Show nothing at all — not even the length. The caret stays at
161    /// the start. Qt's `NoEcho`.
162    NoEcho,
163    /// Show plaintext while the field is focused (being edited) and
164    /// re-mask on blur. Qt's `PasswordEchoOnEdit`.
165    RevealWhileTyping,
166}
167
168/// How a *revealed* secure field reports to assistive technology.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
170pub enum AtRevealPolicy {
171    /// When revealed, expose the field as a normal `Role::TextInput`
172    /// carrying the plaintext value — matching what is visibly on
173    /// screen and the web `type=password ↔ type=text` swap. When
174    /// masked, it reverts to `Role::PasswordInput`. (Default.)
175    #[default]
176    SwapRole,
177    /// Always report `Role::PasswordInput` and never expose plaintext
178    /// to assistive tech, even while visually revealed. Higher
179    /// confidentiality at the cost of consistency with the screen.
180    AlwaysProtected,
181}
182
183/// Editable single-line text surface primitive.
184///
185/// See the [module docs](self) for the full feature list and a
186/// compositional example.
187pub struct TextInputField {
188    // ── Configuration (builder methods, consumed in build) ───────────
189    text: Signal<String>,
190    /// Enabled state, static or reactive; forwarded to the arena at build
191    /// time.
192    enabled: Prop<bool>,
193    read_only: bool,
194    max_length: Option<usize>,
195    placeholder: String,
196    on_submit: Option<CommandFactory>,
197    on_blur: Option<CommandFactory>,
198    char_filter: Option<CharFilter>,
199    /// Fixed trailing label rendered inside the field's border.
200    /// Accepts both plain strings and `Signal<String>` — when bound,
201    /// the field re-measures the suffix and relayouts each time the
202    /// signal fires, so composites like `SpinBox` can derive the
203    /// suffix from the widget state (e.g. hide it while
204    /// `special_value_text` is active).
205    suffix: Prop<String>,
206    text_height: Option<f32>,
207    external_interaction: Option<Signal<InteractionState>>,
208
209    /// Optional input mask. When set, the field auto-derives a
210    /// placeholder template (`__/__/____` for `99/99/9999`) and
211    /// rejects non-fitting characters via a position-aware filter
212    /// composed with the user's `char_filter`. See [`InputMask`] for
213    /// the grammar.
214    mask: Option<InputMask>,
215    /// Visible char used for unfilled editable positions in the mask
216    /// template. Defaults to the theme's
217    /// `text_field.mask_placeholder_char` (typically `_`).
218    mask_placeholder_override: Option<char>,
219    /// Validator closure called on every commit (Enter, Tab-out,
220    /// blur). Returns a [`ValidationOutcome`] that drives
221    /// [`feedback`](Self::validation_feedback_signal).
222    validator: Option<ValidatorFn>,
223    /// Published feedback signal. Composites bind to this to render
224    /// the inline validation strip below the field.
225    feedback: Signal<ValidationFeedback>,
226
227    // ── Secure / password masking (set via `secure`) ────────────────
228    secure: bool,
229    echo_mode: EchoMode,
230    echo_char: char,
231    revealed: Option<Signal<bool>>,
232    at_reveal_policy: AtRevealPolicy,
233    allow_copy: bool,
234
235    /// Semantic purpose → specialised AT role (WCAG 1.3.5). Ignored while the
236    /// field is `secure` (password role wins).
237    input_purpose: InputPurpose,
238
239    /// ARIA combobox wiring — see [`active_descendant`](Self::active_descendant).
240    active_descendant: Option<Signal<Option<WidgetId>>>,
241    /// The listbox this field drives, if any — see
242    /// [`controls`](Self::controls).
243    controls: Option<Signal<Option<WidgetId>>>,
244
245    // ── Internal (set during build) ─────────────────────────────────
246    state: Option<SharedState>,
247    /// Interaction signal actually used at runtime. Either the one
248    /// supplied by a wrapping composite via
249    /// [`TextInputField::interaction_signal`] or a fresh one owned
250    /// by the field. Read by the focus handler to repaint a
251    /// parent's focus ring / border on gain/loss.
252    interaction: Signal<InteractionState>,
253    /// Mirror of the inner state's `cursor_position` for external
254    /// readers. Wired in `build()` via a `ctx.effect`. Composing
255    /// widgets that need the caret (e.g. `DateEdit` for segment
256    /// stepping) read this via [`TextInputField::caret_position`].
257    caret_position: Signal<usize>,
258    /// Late-bound handle to the inner `SharedState`, populated in
259    /// `build()`. Lets composing widgets capture a `caret_setter`
260    /// closure BEFORE the field is moved into the tree, then call
261    /// it later to programmatically reposition the caret. Required
262    /// because the inner state doesn't exist before `build()` runs,
263    /// but the composing widget loses ownership of `self` once it
264    /// hands the field to `ctx.add(...)`.
265    state_slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>>,
266    /// Minted with the widget, not with its state, so a [`TextFieldHandle`]
267    /// taken before `build` observes the signal the built widget writes.
268    focus_signal: Signal<bool>,
269    /// Natural intrinsic width in logical pixels, cached at the end
270    /// of `build()`. When an [`InputMask`] is set, this measures the
271    /// mask's empty template (e.g. `__/__/____`) in the theme body
272    /// font and adds a small caret slack — so a date / time / phone
273    /// field reports a width that matches its content envelope
274    /// instead of the generic 200 dp fallback. Composing widgets
275    /// like `DateEdit` rely on this so their unconstrained natural
276    /// width tracks the format pattern.
277    natural_width: f32,
278}
279
280impl std::fmt::Debug for TextInputField {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        f.debug_struct("TextInputField")
283            .field("placeholder", &self.placeholder)
284            .field("enabled", &self.enabled.get())
285            .field("read_only", &self.read_only)
286            .finish_non_exhaustive()
287    }
288}
289
290impl TextInputField {
291    /// Construct a new field bound to `text`.
292    pub fn new(text: Signal<String>) -> Self {
293        Self {
294            text,
295            enabled: Prop::Static(true),
296            read_only: false,
297            max_length: None,
298            placeholder: String::new(),
299            on_submit: None,
300            on_blur: None,
301            char_filter: None,
302            suffix: Prop::Static(String::new()),
303            text_height: None,
304            external_interaction: None,
305            mask: None,
306            mask_placeholder_override: None,
307            validator: None,
308            feedback: Signal::new(ValidationFeedback::Pristine),
309            secure: false,
310            echo_mode: EchoMode::Masked,
311            echo_char: '\u{2022}',
312            revealed: None,
313            at_reveal_policy: AtRevealPolicy::SwapRole,
314            allow_copy: true,
315            input_purpose: InputPurpose::Normal,
316            active_descendant: None,
317            controls: None,
318            state: None,
319            interaction: Signal::new(InteractionState::Idle),
320            caret_position: Signal::new(0),
321            state_slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
322            focus_signal: Signal::new(false),
323            natural_width: 200.0,
324        }
325    }
326
327    /// Declarative placeholder string. The field itself paints
328    /// nothing for placeholder — that visual is the composite
329    /// parent's responsibility (`TextInput` overlays a
330    /// `TextWidget`). The string is still stored here and published
331    /// via AccessKit's `placeholder` property so screen readers
332    /// announce it.
333    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
334        self.placeholder = text.into();
335        self
336    }
337
338    /// Set the enabled state, statically or reactively. Disabled blocks
339    /// input and AccessKit interaction. Forwarded to the arena at build
340    /// time.
341    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
342        self.enabled = enabled.into();
343        self
344    }
345
346    /// Mark the field read-only. Caret and selection still work;
347    /// inserts, deletes, paste, undo/redo, and cut are all no-ops.
348    pub fn read_only(mut self, read_only: bool) -> Self {
349        self.read_only = read_only;
350        self
351    }
352
353    /// Hard cap on document length in `char`s (grapheme count is
354    /// approximated — each `char` counts as one unit, matching
355    /// `String::chars().count()`).
356    pub fn max_length(mut self, max_length: usize) -> Self {
357        self.max_length = Some(max_length);
358        self
359    }
360
361    /// Closure fired on `Enter`. Unlike `on_blur_fn`, this does
362    /// not move focus — the field stays focused and the caret
363    /// stays where it was.
364    pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
365        self.on_submit = Some(Box::new(f));
366        self
367    }
368
369    /// Closure fired once per focus-loss, after selection/scroll
370    /// have been reset. SpinBox-style callers parse and reformat
371    /// here; validators revalidate here.
372    pub fn on_blur_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
373        self.on_blur = Some(Box::new(f));
374        self
375    }
376
377    /// Per-character input-filter predicate. Applied uniformly to
378    /// keyboard input, IME commits, and clipboard paste so a filtered
379    /// field cannot receive disallowed characters through any path.
380    /// Composes with `max_length` and the built-in control/newline
381    /// strip (filter runs after the strip). Whole-string validity
382    /// (e.g. "at most one decimal point") is a commit-time concern
383    /// for `on_blur` / `on_submit`.
384    pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
385        self.char_filter = Some(Rc::new(f));
386        self
387    }
388
389    /// Static non-editable trailing string rendered flush-right
390    /// inside the field's bounds (Qt's `QSpinBox::suffix`). The
391    /// caret cannot enter the suffix; clicks past the text end
392    /// position the caret at the last editable character.
393    ///
394    /// Accepts a static `String`/`&str` or a reactive `Signal<String>` /
395    /// `Prop<String>`; when bound, the field re-measures the suffix glyphs
396    /// and relayouts the editable text viewport each time the signal fires.
397    /// Typical use: a `SpinBox` with `special_value_text` binds an empty
398    /// string to the suffix whenever the value equals `min`, and the
399    /// configured unit string otherwise.
400    pub fn suffix(mut self, text: impl Into<Prop<String>>) -> Self {
401        self.suffix = text.into();
402        self
403    }
404
405    /// Override the intrinsic text-area height. The field is a
406    /// pure leaf with no theme lookup of its own; by default it
407    /// reports `DEFAULT_TEXT_HEIGHT`. A wrapping composite like
408    /// `TextInput` passes its theme's `text_field.height` minus
409    /// border + padding here so the visuals line up with the
410    /// rest of the form.
411    pub fn text_height(mut self, height: f32) -> Self {
412        self.text_height = Some(height);
413        self
414    }
415
416    /// Bind an externally-owned `InteractionState` signal. The
417    /// field writes `Focused` on focus gain and `Idle` on loss;
418    /// other states (`Hovered`, `Pressed`, `Disabled`) are the
419    /// composite's responsibility. When unset, the field owns a
420    /// private signal that observers can still read via
421    /// [`interaction`](TextInputField::interaction), but composites
422    /// that drive a focus ring or border color usually want to
423    /// push their own.
424    pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
425        self.external_interaction = Some(signal);
426        self
427    }
428
429    /// Set an input mask (Qt grammar). Constrains accepted characters
430    /// per position, auto-derives the empty-state template
431    /// (`__/__/____` for `99/99/9999`), and routes typed chars
432    /// through the mask's class filter.
433    ///
434    /// Composes with [`char_filter`](Self::char_filter): a char must
435    /// pass *both* the mask's per-position class AND the user's
436    /// `char_filter` to be accepted.
437    ///
438    /// On parse error (only the trailing-backslash case in practice),
439    /// the mask is silently dropped — the field falls back to its
440    /// no-mask behaviour rather than panicking.
441    pub fn input_mask(mut self, mask: impl AsRef<str>) -> Self {
442        match InputMask::parse(mask.as_ref()) {
443            Ok(m) => self.mask = Some(m),
444            Err(_) => self.mask = None,
445        }
446        self
447    }
448
449    /// Override the visible character used for unfilled editable mask
450    /// positions. Default: the theme's
451    /// `text_field.mask_placeholder_char` (typically `_`).
452    pub fn mask_placeholder(mut self, c: char) -> Self {
453        self.mask_placeholder_override = Some(c);
454        self
455    }
456
457    /// Install a validator. The closure runs on every commit (Enter,
458    /// Tab-out, focus loss) and returns a [`ValidationOutcome`] that
459    /// drives [`validation_feedback_signal`](Self::validation_feedback_signal).
460    ///
461    /// **Does not run per-keystroke** — that's [`char_filter`](Self::char_filter)'s
462    /// job. Mixing per-keystroke text rewriting with validation
463    /// produces caret-jump bugs and is explicitly out of scope.
464    pub fn validator(mut self, f: impl Fn(&str) -> ValidationOutcome + 'static) -> Self {
465        self.validator = Some(Rc::new(f));
466        self
467    }
468
469    /// Turn this into a secure (password) field with the given
470    /// [`EchoMode`]. Masking happens at the text-engine layer (one echo
471    /// glyph per source `char`), so the plaintext never reaches the
472    /// shaper or glyph atlas while masked, and caret / selection /
473    /// hit-test stay correct. Also defaults `allow_copy` to `false` and
474    /// opts the focused node out of OS IME composition. Pair with
475    /// [`revealed`](Self::revealed) for a reveal toggle.
476    pub fn secure(mut self, echo_mode: EchoMode) -> Self {
477        self.secure = true;
478        self.echo_mode = echo_mode;
479        self.allow_copy = false;
480        self
481    }
482
483    /// Declare the field's semantic [`InputPurpose`] (WCAG 1.3.5), which
484    /// selects a specialised AccessKit role (`EmailInput`, `PhoneNumberInput`,
485    /// …) so screen readers announce the field's kind. Ignored while `secure`
486    /// (the password role wins). Does not change IME behaviour — winit's
487    /// `ImePurpose` has no email/number/url variants — nor drive OS autofill,
488    /// which AccessKit cannot express (see `docs/a11y/a11y_issues.md`).
489    pub fn input_purpose(mut self, purpose: InputPurpose) -> Self {
490        self.input_purpose = purpose;
491        self
492    }
493
494    /// Publish `active_descendant` pointing at the row a *separate* list is
495    /// currently highlighting — the ARIA combobox pattern.
496    ///
497    /// Keyboard focus stays in this field while arrow keys move a highlight
498    /// through a listbox elsewhere in the tree (a command palette, a
499    /// type-ahead picker, a suggestion popup). Assistive technology follows
500    /// the focused node's active descendant, so the announcement has to be
501    /// published **here**, on the node that actually holds focus — not on the
502    /// composite ancestor that owns the list. Without it the arrow keys move a
503    /// highlight that is announced to nobody.
504    ///
505    /// Bound at `AccessibilityOnly`, so moving the highlight re-walks the AT
506    /// tree without a rebuild or a repaint. Pair with [`controls`](Self::controls).
507    pub fn active_descendant(mut self, active: Signal<Option<WidgetId>>) -> Self {
508        self.active_descendant = Some(active);
509        self
510    }
511
512    /// Publish a `controls` relation to the listbox this field drives, so an
513    /// AT client can navigate from the input to the list it is filtering.
514    /// The companion of [`active_descendant`](Self::active_descendant).
515    pub fn controls(mut self, listbox: Signal<Option<WidgetId>>) -> Self {
516        self.controls = Some(listbox);
517        self
518    }
519
520    /// Override the masking glyph (default `'•'`, U+2022). Any
521    /// uniform-width character works; the engine emits exactly one per
522    /// source `char`.
523    pub fn echo_char(mut self, c: char) -> Self {
524        self.echo_char = c;
525        self
526    }
527
528    /// Bind the reveal toggle. When the signal is `true` the field
529    /// shows plaintext regardless of [`EchoMode`]; when `false` it
530    /// masks. Shared with the eye [`IconButton::visibility_toggle`].
531    ///
532    /// [`IconButton::visibility_toggle`]: crate::IconButton::visibility_toggle
533    pub fn revealed(mut self, revealed: Signal<bool>) -> Self {
534        self.revealed = Some(revealed);
535        self
536    }
537
538    /// How a *revealed* secure field reports to assistive tech. Default
539    /// [`AtRevealPolicy::SwapRole`].
540    pub fn at_reveal_policy(mut self, policy: AtRevealPolicy) -> Self {
541        self.at_reveal_policy = policy;
542        self
543    }
544
545    /// Permit (or forbid) copy / cut. Plain fields default `true`;
546    /// [`secure`](Self::secure) flips the default to `false`. Even when
547    /// `false`, copy is allowed while the field is revealed.
548    pub fn allow_copy(mut self, allow: bool) -> Self {
549        self.allow_copy = allow;
550        self
551    }
552
553    /// Reactive handle on the published [`ValidationFeedback`] state.
554    /// Composites bind to this to render the inline feedback strip
555    /// below the field. Always present; reads `Pristine` until the
556    /// first commit (or forever if no validator is installed).
557    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
558        self.feedback.clone()
559    }
560
561    /// The `Signal<String>` this field is bound to.
562    pub fn text(&self) -> Signal<String> {
563        self.text.clone()
564    }
565
566    /// Adopt an existing handle instead of minting one.
567    ///
568    /// For a composing widget — `TextInput` wraps this field — that must hand
569    /// out a handle of its own **before** it builds the field it will delegate
570    /// to. Sharing the slot and the focus signal makes the wrapper's handle and
571    /// the field's the same handle, rather than two that agree by accident.
572    pub fn share_handle(mut self, handle: &TextFieldHandle) -> Self {
573        self.state_slot = handle.slot.clone();
574        self.focus_signal = handle.focus_signal.clone();
575        self
576    }
577
578    /// A live handle on this field, valid before and after `build`.
579    ///
580    /// The counterpart of `RichTextEditor::handle`, and the reason it exists:
581    /// an application that routes Undo, Cut, Copy, Paste and Select All to
582    /// "whichever text surface holds the caret" has to be able to *drive* every
583    /// such surface, not only the rich editors. Without this, a menu built for
584    /// those commands can only grey them out over a rename field or a search
585    /// box while the field's own key handling still works — a menu that lies
586    /// about what the keyboard can do.
587    ///
588    /// Like `caret_setter`, the handle reaches its state through the slot the
589    /// widget late-populates, so it may be taken while the tree is being
590    /// described and used once it is live.
591    pub fn handle(&self) -> TextFieldHandle {
592        TextFieldHandle {
593            slot: self.state_slot.clone(),
594            focus_signal: self.focus_signal.clone(),
595        }
596    }
597
598    /// The interaction signal this field writes on focus changes.
599    /// Call before inserting the field into the tree.
600    pub fn interaction(&self) -> Signal<InteractionState> {
601        self.interaction.clone()
602    }
603
604    /// Reactive caret position in the field's text (in `usize` char
605    /// offsets). Updates after every keyboard or pointer action that
606    /// moves the cursor. Used by composing widgets that need to know
607    /// where the caret is — e.g. `DateEdit` reads this to figure out
608    /// which date segment Up/Down should step.
609    pub fn caret_position(&self) -> Signal<usize> {
610        self.caret_position.clone()
611    }
612
613    /// Returns a callable that programmatically sets the caret
614    /// position (in char offsets) on the field. Capture this on the
615    /// builder BEFORE `ctx.add(...)` consumes the field; call it
616    /// after a programmatic text rewrite to restore the caret to the
617    /// right column instead of leaving it at the document end (the
618    /// default behaviour of `cursor.insert_text`).
619    ///
620    /// The returned closure becomes a no-op until `build()` runs;
621    /// after build it walks the field's inner state and moves the
622    /// document cursor to `position`, clamped to the document
623    /// length. Used by `DateEdit` / `TimeEdit` segment-stepping to
624    /// keep the caret within its current segment after Up/Down.
625    pub fn caret_setter(&self) -> std::rc::Rc<dyn Fn(usize)> {
626        let slot = self.state_slot.clone();
627        std::rc::Rc::new(move |position: usize| {
628            if let Some(state) = slot.borrow().as_ref() {
629                let st = state.borrow();
630                st.cursor
631                    .set_position(position, teksilo_text::text_document::MoveMode::MoveAnchor);
632                let actual = st.cursor.position();
633                if st.cursor_position.get() != actual {
634                    st.cursor_position.set(actual);
635                }
636            }
637        })
638    }
639}
640
641impl Widget for TextInputField {
642    fn as_any(&self) -> Option<&dyn std::any::Any> {
643        Some(self)
644    }
645
646    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
647        // Tell the framework this widget edits text.
648        //
649        // What it buys: an application may take `Ctrl+Z`, `Ctrl+C` and friends
650        // for itself — a single Undo command over the whole app has to — and
651        // registered shortcuts resolve before any widget sees the raw key. This
652        // is how the host can tell that the caret is *here*, and either drive
653        // this surface or step aside so it keeps its own keys. Without it, an
654        // application that routes those chords silently breaks every text
655        // widget it does not personally know about. See
656        // `teksilo_core::text_surface`.
657        ctx.register_text_surface(std::rc::Rc::new(self.handle()));
658        // Resolve the interaction signal (external override wins).
659        if let Some(signal) = self.external_interaction.take() {
660            self.interaction = signal;
661        }
662
663        // Resolve the mask placeholder character. Caller override wins;
664        // otherwise pull from the recipe constant. The theme snapshot
665        // is still captured for downstream typography reads below.
666        let theme_snapshot = ctx.theme_signal().get();
667        let mask_placeholder_char = self
668            .mask_placeholder_override
669            .unwrap_or(crate::styles::recipe_text_input_style::TEXT_FIELD_MASK_PLACEHOLDER_CHAR);
670
671        // Auto-derive placeholder from mask when none was explicitly
672        // set: an empty masked field paints `__/__/____` rather than
673        // a blank surface, giving the user a self-documenting template.
674        if self.placeholder.is_empty()
675            && let Some(ref m) = self.mask
676        {
677            self.placeholder = m.empty_template(mask_placeholder_char);
678        }
679
680        // Cache mask-aware natural width. When a mask is set, the
681        // visual content envelope is the FILLED template — every
682        // editable position holding its widest plausible glyph
683        // (`0` for digits, `M` for letters, etc.) and every fixed
684        // position holding its literal. Measuring the empty
685        // (`__/__/____`) template instead would shortchange the
686        // field by the difference between an underscore and a real
687        // glyph: ~2 dp per digit slot for `0`, ~5 dp per letter
688        // slot for `M`, which adds up to a multi-character shortfall
689        // for date / 12h time fields. We want the natural width to
690        // hold the fully-typed value without overflow.
691        //
692        // Without a mask the 200 dp fallback (set in `new()`) stays.
693        if let Some(ref m) = self.mask {
694            // Measure the worst-case glyph row PLUS one extra `M` of
695            // safety: one for caret breathing room past the last
696            // position, plus a defensive cushion for any per-glyph
697            // measurement variance between our heuristic fallback
698            // and the real glyph shaper. Without this safety char,
699            // dates were observed to clip the trailing 2 characters
700            // and 12h time fields clipped the AM/PM letters.
701            let mut widest = worst_case_template(m);
702            widest.push('M');
703            let style = &theme_snapshot.typography.body;
704            let measured = measure_width_px(ctx, &widest, style);
705            let slack = style.size;
706            self.natural_width = measured + slack;
707        }
708
709        // Compose the user's char_filter with the mask's class filter.
710        // The mask doesn't know the cursor position here (this is a
711        // pre-position filter), so it accepts any char that fits *any*
712        // editable position class — a permissive gate that catches
713        // gross mismatches (typing "a" into a digits-only mask) without
714        // requiring per-keystroke position tracking. Per-position
715        // gating happens at commit time via the validator.
716        if let Some(ref mask) = self.mask {
717            let mask_for_filter = mask.clone();
718            let user_filter = self.char_filter.take();
719            let combined: CharFilter = Rc::new(move |c: char| {
720                // Always allow fixed-separator characters (they're
721                // legitimate input even if user types them — the
722                // formatter consumes them).
723                let in_mask_class = mask_for_filter.positions().any(|p| match p {
724                    MaskPosition::Editable { class, .. } => class.accepts(c),
725                    MaskPosition::Fixed(sep) => *sep == c,
726                });
727                if !in_mask_class {
728                    return false;
729                }
730                match user_filter.as_ref() {
731                    Some(f) => f(c),
732                    None => true,
733                }
734            });
735            self.char_filter = Some(combined);
736        }
737
738        // Build the shared state from the configured builder values.
739        let mut on_submit = self.on_submit.take().map(Rc::new);
740        let mut on_blur = self.on_blur.take().map(Rc::new);
741
742        // Wrap commit callbacks with the validator pipeline. The
743        // wrapping closure: snapshots the bound text, runs the
744        // validator, applies the outcome (writes feedback, mutates
745        // text on `Corrected`), then chains the user's callback so
746        // composites can react to the now-updated state.
747        if let Some(validator) = self.validator.clone() {
748            let bound_text = self.text.clone();
749            let feedback = self.feedback.clone();
750            let prev_on_blur = on_blur.take();
751            on_blur = Some(Rc::new(Box::new({
752                let validator = validator.clone();
753                let feedback = feedback.clone();
754                let bound_text = bound_text.clone();
755                move |evt_ctx: &mut EventContext| {
756                    run_validator_and_apply(&validator, &bound_text, &feedback);
757                    if let Some(cb) = prev_on_blur.as_ref() {
758                        cb(evt_ctx);
759                    }
760                }
761            }) as CommandFactory));
762            let prev_on_submit = on_submit.take();
763            on_submit = Some(Rc::new(Box::new({
764                let validator = validator.clone();
765                let feedback = feedback.clone();
766                let bound_text = bound_text.clone();
767                move |evt_ctx: &mut EventContext| {
768                    run_validator_and_apply(&validator, &bound_text, &feedback);
769                    if let Some(cb) = prev_on_submit.as_ref() {
770                        cb(evt_ctx);
771                    }
772                }
773            }) as CommandFactory));
774        }
775
776        let initial_text = self.text.get();
777        // `read_only_effective` snapshots the build-time state so the
778        // shared TextInputState's read-only mode is set once. Disabled
779        // is now arena-driven and propagates per-paint via
780        // `effective_enabled`; the field's interaction handlers also
781        // check `ctx.is_enabled(self_id)` for keystroke gating. The
782        // shared state's read_only stays a separate, document-level
783        // concept (allows selection / no edits).
784        let read_only_effective = self.read_only || !self.enabled.get();
785
786        let initial_suffix = self.suffix.get();
787        let shared_state = TextInputState::new(TextInputConfig {
788            initial_text,
789            max_length: self.max_length,
790            read_only: read_only_effective,
791            on_submit,
792            on_blur,
793            char_filter: self.char_filter.take(),
794            placeholder: self.placeholder.clone(),
795            suffix: initial_suffix,
796            secure: self.secure,
797            echo_mode: self.echo_mode,
798            echo_char: self.echo_char,
799            revealed: self.revealed.clone(),
800            at_reveal_policy: self.at_reveal_policy,
801            allow_copy: self.allow_copy,
802            focus_signal: self.focus_signal.clone(),
803        });
804        self.state = Some(shared_state.clone());
805        // Late-populate the slot so `caret_setter()` closures captured
806        // before build can now reach the inner state. Idempotent on
807        // rebuild — overwrites the slot with the freshly created
808        // SharedState.
809        *self.state_slot.borrow_mut() = Some(shared_state.clone());
810
811        // Reset feedback to Pristine whenever the user types — prior
812        // Invalid / Corrected announcements should clear as soon as
813        // the user starts editing again so they don't shout stale
814        // errors at someone trying to fix them.
815        {
816            let feedback = self.feedback.clone();
817            ctx.effect(&self.text, move |_| {
818                if !matches!(feedback.get(), ValidationFeedback::Pristine) {
819                    feedback.set(ValidationFeedback::Pristine);
820                }
821            });
822        }
823
824        // Mirror the inner state's `cursor_position` onto the field's
825        // public `caret_position` so callers of `caret_position()` see
826        // live caret updates. The state's signal is keyed by the
827        // shared state's identity (created in `TextInputState::new`),
828        // not by the field's; this effect bridges the two.
829        {
830            let inner = shared_state.borrow().cursor_position.clone();
831            let outer = self.caret_position.clone();
832            outer.set(inner.get());
833            ctx.effect(&inner, move |pos| {
834                if outer.get() != *pos {
835                    outer.set(*pos);
836                }
837            });
838        }
839
840        // Bind feedback at AccessibilityOnly so the field's AT node
841        // refreshes its `set_invalid` state when feedback changes.
842        {
843            let self_id = ctx.self_id();
844            self.feedback.bind_to(
845                self_id,
846                ctx.binding_registry(),
847                teksilo_core::binding::BindingLevel::AccessibilityOnly,
848            );
849        }
850
851        // Combobox wiring: a moved highlight in the list this field drives must
852        // re-walk the AT tree so the new `active_descendant` is announced.
853        // AccessibilityOnly — nothing about this field's own pixels changed.
854        for sig in [self.active_descendant.as_ref(), self.controls.as_ref()]
855            .into_iter()
856            .flatten()
857        {
858            sig.bind_to(
859                ctx.self_id(),
860                ctx.binding_registry(),
861                teksilo_core::binding::BindingLevel::AccessibilityOnly,
862            );
863        }
864
865        // Secure fields: flipping the reveal toggle must repaint AND
866        // refresh AT. `RepaintOnly` dirties this node for the render
867        // walker so `paint()` runs and re-lays-out the masked/unmasked
868        // glyphs via the `needs_full_layout` flag the effect below sets
869        // — without it the flag is set but nothing calls `paint()`, so
870        // the visual only updates on the next unrelated repaint
871        // (hover / focus). This mirrors how `text_signal` is bound for
872        // edits. The parallel `AccessibilityOnly` bind swaps the AT
873        // role/value (PasswordInput ↔ TextInput under SwapRole); it lives
874        // in its own bucket and does not imply repaint, so both are
875        // required.
876        if self.secure
877            && let Some(revealed) = self.revealed.clone()
878        {
879            let id = ctx.self_id();
880            let reg = ctx.binding_registry();
881            revealed.bind_to(id, reg, teksilo_core::binding::BindingLevel::RepaintOnly);
882            revealed.bind_to(
883                id,
884                reg,
885                teksilo_core::binding::BindingLevel::AccessibilityOnly,
886            );
887        }
888
889        let text_signal = shared_state.borrow().text_signal.clone();
890
891        // Sync external text signal → internal state. A programmatic
892        // update on the bound signal rewrites the document; the
893        // caret ends up at the end of the inserted text (cursor
894        // behavior is documented in
895        // `text_document::TextCursor::insert_text`).
896        //
897        // `insert_text` only enqueues a `ContentsChanged` document
898        // event — `tick()` drains it on the next frame and propagates
899        // the new text to `text_signal`. Frames are demand-driven, so
900        // we ping `frame_request` here to guarantee a tick runs even
901        // when the external writer (e.g. an HSV-canvas drag feeding a
902        // spinner / hex bridge) is the only thing changing on screen.
903        // Without it, the document stays in sync with the bound signal
904        // but the visible glyphs lag until something else (focus, a
905        // keystroke, an animation frame) wakes the loop.
906        {
907            let ext = self.text.clone();
908            let state_for_sync = shared_state.clone();
909            ctx.effect(&ext, move |new_text| {
910                let st = state_for_sync.borrow();
911                let current = st.document.to_plain_text().unwrap_or_default();
912                if current != *new_text {
913                    st.cursor.select(SelectionType::Document);
914                    let _ = st.cursor.insert_text(new_text);
915                    if let Some(handle) = &st.frame_request {
916                        handle.set(true);
917                    }
918                }
919            });
920        }
921
922        // Sync internal text signal → external. Every edit that
923        // reaches `text_signal` also updates the caller-owned
924        // signal, so observers bound to it see every keystroke
925        // (after the debounce in `tick`).
926        {
927            let ext = self.text.clone();
928            ctx.effect(&text_signal, move |new_text| {
929                if ext.get() != *new_text {
930                    ext.set(new_text.clone());
931                }
932            });
933        }
934
935        // Secure reveal toggle: flipping the bound `revealed` signal
936        // swaps the laid-out glyphs wholesale (bullets ↔ plaintext), so
937        // mark the layout dirty and ping the frame loop to re-lay-out.
938        if self.secure
939            && let Some(revealed) = self.revealed.clone()
940        {
941            let state_for_reveal = shared_state.clone();
942            ctx.effect(&revealed, move |_| {
943                let mut st = state_for_reveal.borrow_mut();
944                st.needs_full_layout = true;
945                if let Some(handle) = &st.frame_request {
946                    handle.set(true);
947                }
948            });
949        }
950
951        // Swap the private engine for one sharing the app's
952        // `SharedTypesetter` so glyphs land in the atlas
953        // teksilo-render uploads to the GPU. When no typesetter is
954        // installed (headless tests), the pre-built private
955        // engine stays in place.
956        if let Some(shared) = ctx.app_state::<SharedTypesetter>() {
957            let mut st = self.state().borrow_mut();
958            let mut engine = RichTextEngine::from_shared(shared.clone());
959            engine.set_wrap_mode(teksilo_text::WrapMode::None);
960            st.engine = engine;
961            st.needs_full_layout = true;
962        }
963
964        // Apply theme colors to the (possibly freshly swapped-in) engine.
965        // Setting them before the swap would be lost. The rich-text
966        // engine stores colors in GPU-ready form, so we register an
967        // effect on the theme signal that re-applies the palette on
968        // every theme switch instead of capturing a single snapshot.
969        //
970        // The text / caret / suffix *foreground* colours are deliberately
971        // NOT set here — `paint` owns them, because they depend on the
972        // effective enabled state as well as the theme (see the resolve
973        // block there). Selection is theme + window-active only, so it
974        // stays on this effect path.
975        let theme_signal = ctx.theme_signal();
976        // The selection colour is also window-active-aware. `ctx.effect` can
977        // only observe *mutable* signals (a derived `theme.zip(window_active)`
978        // would panic), so the theme effect reads the live window-active value
979        // via `.get()`, and the separate window-active effect (below, near the
980        // frame handles) re-applies the selection colour reading the live
981        // theme. Between them, a change to either axis re-applies correctly.
982        {
983            let theme = theme_signal.get();
984            let colors = &theme.colors;
985            let mut st = self.state().borrow_mut();
986            let tint = field_selection_color(colors, ctx.window_active(), st.has_focus);
987            st.selection_tint = tint;
988            st.engine.set_selection_color(tint);
989        }
990        {
991            let state = self.state().clone();
992            let wa_signal = ctx.window_active_signal();
993            ctx.effect(&theme_signal, move |theme| {
994                let colors = &theme.colors;
995                let mut st = state.borrow_mut();
996                let tint = field_selection_color(colors, wa_signal.get(), st.has_focus);
997                st.selection_tint = tint;
998                st.engine.set_selection_color(tint);
999            });
1000        }
1001
1002        // Suffix engine: second independent `RichTextEngine` used
1003        // to paint the non-editable trailing string (Qt's
1004        // `QSpinBox` `suffix`). Shares the app's typesetter when
1005        // available so glyphs land in the same atlas as the main
1006        // document; falls back to a private engine under headless
1007        // tests.
1008        //
1009        // `suffix_width` is cached on `TextInputState` and drives
1010        // both the effective text viewport (so the scroll logic
1011        // keeps the caret visible without sliding text behind the
1012        // suffix) and the suffix paint origin at the right edge
1013        // of the field. When the suffix is bound to a signal, a
1014        // reactive effect below re-lays the engine out each time
1015        // the signal fires.
1016        let text_area_height = self.text_height.unwrap_or(DEFAULT_TEXT_HEIGHT).max(1.0);
1017        let needs_suffix_engine = matches!(self.suffix, Prop::Bound(_)) || {
1018            let st = self.state().borrow();
1019            !st.suffix.is_empty()
1020        };
1021        if needs_suffix_engine {
1022            let mut suffix_engine = if let Some(shared) = ctx.app_state::<SharedTypesetter>() {
1023                RichTextEngine::from_shared(shared.clone())
1024            } else {
1025                RichTextEngine::private_default()
1026            };
1027            suffix_engine.set_wrap_mode(teksilo_text::WrapMode::None);
1028            {
1029                let theme = theme_signal.get();
1030                let secondary = theme.colors.text_secondary.to_array();
1031                suffix_engine.set_text_color(secondary);
1032                suffix_engine.set_cursor_color(secondary);
1033                suffix_engine.set_selection_color([0.0, 0.0, 0.0, 0.0]);
1034            }
1035            suffix_engine.set_viewport(10_000.0, text_area_height);
1036
1037            {
1038                let mut st = self.state().borrow_mut();
1039                st.suffix_engine = Some(suffix_engine);
1040            }
1041            // Initial layout from the current suffix value.
1042            let initial = self.state().borrow().suffix.clone();
1043            relayout_suffix(self.state(), &initial);
1044        }
1045
1046        // Reactive suffix: observe the signal and re-lay out on
1047        // every change. `Relayout` dirty-tracking ensures the
1048        // surrounding layout sees the new `suffix_width` and the
1049        // text viewport narrows/widens accordingly.
1050        if let Prop::Bound(signal) = &self.suffix {
1051            let self_id = ctx.self_id();
1052            signal.bind_to(
1053                self_id,
1054                ctx.binding_registry(),
1055                teksilo_core::binding::BindingLevel::Relayout,
1056            );
1057            let state_for_effect = self.state().clone();
1058            ctx.effect(signal, move |new_text| {
1059                relayout_suffix(&state_for_effect, new_text);
1060            });
1061        }
1062
1063        // Bind caret_visible for repaint.
1064        {
1065            let st = self.state().borrow();
1066            let caret_visible = st.caret_visible.clone();
1067            drop(st);
1068            let self_id = ctx.self_id();
1069            caret_visible.bind_to(
1070                self_id,
1071                ctx.binding_registry(),
1072                teksilo_core::binding::BindingLevel::RepaintOnly,
1073            );
1074        }
1075
1076        // Bind text_signal at RepaintOnly AND AccessibilityOnly.
1077        //
1078        // RepaintOnly: when the text changes by any route — local
1079        // typing, IME, clipboard paste, the ext→internal sync
1080        // effect firing because a composite parent (SpinBox etc.)
1081        // drove the bound signal — the field must redraw. During
1082        // typing the caret-blink signal already keeps the widget
1083        // repainting, which used to mask a missing repaint trigger
1084        // on programmatic text changes to an unfocused field. With
1085        // the explicit bind, no path depends on blink.
1086        //
1087        // AccessibilityOnly: screen readers see edits as soon as
1088        // the text signal updates, independent of whether a paint
1089        // happens this frame.
1090        {
1091            let st = self.state().borrow();
1092            let text_signal = st.text_signal.clone();
1093            drop(st);
1094            let self_id = ctx.self_id();
1095            let registry = ctx.binding_registry();
1096            text_signal.bind_to(
1097                self_id,
1098                registry,
1099                teksilo_core::binding::BindingLevel::RepaintOnly,
1100            );
1101            text_signal.bind_to(
1102                self_id,
1103                registry,
1104                teksilo_core::binding::BindingLevel::AccessibilityOnly,
1105            );
1106        }
1107
1108        // Stash frame infrastructure handles and self_id.
1109        {
1110            let mut st = self.state().borrow_mut();
1111            st.frame_request = Some(ctx.frame_request_handle());
1112            st.frame_wake_at = Some(ctx.wake_at_handle());
1113            st.field_widget_id = Some(ctx.self_id());
1114        }
1115
1116        // Same dormancy discipline as `RichTextEditor`: a field parked in a
1117        // non-selected `Switcher` / `visible_when(false)` branch must not
1118        // keep the event loop awake (caret `wake_at`, frame-tick work,
1119        // window-active re-arm). See that widget's build for the full story.
1120        let activation = ctx.activation_signal(ctx.self_id());
1121        if activation.get() {
1122            ctx.request_frame();
1123        }
1124
1125        {
1126            let state = self.state().clone();
1127            let interaction = self.interaction.clone();
1128            ctx.effect(&activation, move |&active| {
1129                if active {
1130                    // **Re-activated** — re-arm the frame loop. The dormant branch
1131                    // below does not re-arm `frame_request` (a parked surface has
1132                    // nothing to paint) and the frame-tick effect is skipped
1133                    // entirely while dormant, so nothing restarts the tick on the
1134                    // way back. Same defect and same fix as `RichTextEditor` /
1135                    // `CodeEditor`: the in-tree modal path builds content, parks it
1136                    // dormant, mounts it, activates it and *then* moves focus in
1137                    // (`present_in_tree_modal_request`), so without this a field in
1138                    // a dialog draws no caret at all.
1139                    let st = state.borrow();
1140                    if let Some(handle) = &st.frame_request {
1141                        handle.set(true);
1142                    }
1143                    return;
1144                }
1145                let mut st = state.borrow_mut();
1146                if st.has_focus {
1147                    st.has_focus = false;
1148                    st.focus_signal.set(false);
1149                    // Mirror the on_focus(false) interaction write so a
1150                    // Focused chrome style doesn't stick on a parked field.
1151                    interaction.set(InteractionState::Idle);
1152                }
1153                if st.caret_visible.get() {
1154                    st.caret_visible.set(false);
1155                }
1156                st.blink.reset();
1157            });
1158        }
1159
1160        // Frame-tick effect: flushes pending chars, drains document
1161        // events, drives the caret blink, and debounces undo/redo
1162        // state changes.
1163        //
1164        // IMPORTANT: the mutable borrow must be dropped BEFORE
1165        // setting `text_signal`. Setting it fires observers
1166        // synchronously, which chain into the ext→internal sync
1167        // effect that borrows the same state. Holding `borrow_mut`
1168        // across `signal.set()` would panic.
1169        {
1170            let state = self.state().clone();
1171            let active = activation.clone();
1172            let tick_signal = ctx.frame_tick();
1173            ctx.effect(&tick_signal, move |delta| {
1174                if !active.get() {
1175                    return;
1176                }
1177                let (more, pending_text) = {
1178                    let mut st = state.borrow_mut();
1179                    let more = tick(&mut st, *delta);
1180                    st.has_selection.set(st.cursor.has_selection());
1181                    let pending = st.deferred_text_update.take();
1182                    (more, pending)
1183                };
1184                if let Some(text) = pending_text {
1185                    let st = state.borrow();
1186                    if st.text_signal.get() != text {
1187                        st.text_signal.set(text);
1188                    }
1189                }
1190                if more {
1191                    let st = state.borrow();
1192                    if let Some(handle) = &st.frame_request {
1193                        handle.set(true);
1194                    }
1195                }
1196            });
1197        }
1198
1199        // Window-active effect — mirror the tree's window-active state onto the
1200        // field state so the frame loop (no context) can gate the caret, and
1201        // re-apply the window-aware selection colour (reading the live theme,
1202        // since `ctx.effect` can't observe a derived theme×active signal). The
1203        // loop may not tick while the window is inactive (animation scheduler
1204        // parked), so on deactivation hide the caret synchronously here and
1205        // request a frame so it reaches a paint pass — only while this field
1206        // is itself active (a dormant field must not re-arm the loop).
1207        {
1208            let state = self.state().clone();
1209            let active = activation.clone();
1210            let wa_signal = ctx.window_active_signal();
1211            let theme_for_sel = theme_signal.clone();
1212            ctx.effect(&wa_signal, move |&window_active| {
1213                let mut st = state.borrow_mut();
1214                st.window_active = window_active;
1215                let theme = theme_for_sel.get();
1216                let tint = field_selection_color(&theme.colors, window_active, st.has_focus);
1217                st.selection_tint = tint;
1218                st.engine.set_selection_color(tint);
1219                if window_active {
1220                    // Reactivated: show the caret immediately if still focused
1221                    // (restart the blink phase), rather than waiting one interval.
1222                    if st.has_focus && !st.caret_visible.get() {
1223                        st.caret_visible.set(true);
1224                    }
1225                    st.blink.reset();
1226                } else {
1227                    // Deactivated: hide the caret synchronously (the frame loop
1228                    // may not tick while the window is inactive).
1229                    if st.caret_visible.get() {
1230                        st.caret_visible.set(false);
1231                    }
1232                    st.blink.reset();
1233                }
1234                if active.get()
1235                    && let Some(handle) = &st.frame_request
1236                {
1237                    handle.set(true);
1238                }
1239            });
1240        }
1241
1242        // Forward the enabled state into the arena. Disabled state no
1243        // longer seeded into the interaction signal — the framework's
1244        // arena enabled-state is the single source of truth (events
1245        // gated, leaves resolve Disabled role).
1246        let self_id = ctx.self_id();
1247        ctx.enabled_when(self_id, self.enabled.clone());
1248
1249        // Attach handlers. Focus-origin inference mirrors the
1250        // `Slider` pattern: hover cached, focus event checks hover
1251        // to distinguish keyboard vs pointer origin for the
1252        // select-all-on-keyboard-focus rule.
1253        let hovered = std::rc::Rc::new(std::cell::Cell::new(false));
1254        let hovered_for_focus = hovered.clone();
1255        let hovered_for_hover = hovered.clone();
1256
1257        let state_for_focus = self.state().clone();
1258        let interaction_for_focus = self.interaction.clone();
1259        // The selection band's tint depends on focus, so the focus handler has
1260        // to re-apply it — and needs the live theme to do so.
1261        let theme_for_focus = theme_signal.clone();
1262        let state_for_pointer = self.state().clone();
1263        let state_for_key = self.state().clone();
1264        let state_for_double = self.state().clone();
1265        let state_for_triple = self.state().clone();
1266        let state_for_access = self.state().clone();
1267        let state_for_menu = self.state().clone();
1268
1269        let handlers = HandlerSet::new()
1270            .focusable(true)
1271            .cursor(CursorIcon::Text)
1272            // Secure fields opt the focused node out of OS IME
1273            // composition so the preedit / candidate window can't
1274            // surface plaintext. Read by the platform IME layer at
1275            // focus-change time (default `true` for plain fields).
1276            .ime_input(if self.secure {
1277                teksilo_core::ime::ImeContext::password()
1278            } else {
1279                teksilo_core::ime::ImeContext::text()
1280            })
1281            .on_hover(move |entered, _ctx| {
1282                hovered_for_hover.set(entered);
1283            })
1284            .on_focus(move |gained, ctx| {
1285                interaction_for_focus.set(if gained {
1286                    InteractionState::Focused
1287                } else {
1288                    InteractionState::Idle
1289                });
1290
1291                let mut st = state_for_focus.borrow_mut();
1292                st.has_focus = gained;
1293                st.focus_signal.set(gained);
1294                // Re-tint the selection band: `has_focus` is half of what
1295                // decides it, so losing focus inside an active window has to
1296                // re-apply just as losing the window does.
1297                let sel_theme = theme_for_focus.get();
1298                let tint = field_selection_color(&sel_theme.colors, st.window_active, gained);
1299                st.selection_tint = tint;
1300                st.engine.set_selection_color(tint);
1301                // RevealWhileTyping shows plaintext while focused and
1302                // re-masks on blur — both transitions need a relayout.
1303                if st.secure && st.echo_mode == EchoMode::RevealWhileTyping {
1304                    st.needs_full_layout = true;
1305                }
1306                let mut blur_callback: Option<Rc<CommandFactory>> = None;
1307                if gained {
1308                    st.blink.restart();
1309                    st.caret_visible.set(true);
1310                    let is_keyboard = !hovered_for_focus.get();
1311                    drop(st);
1312                    if is_keyboard {
1313                        let st = state_for_focus.borrow();
1314                        st.cursor.select(SelectionType::Document);
1315                        drop(st);
1316                        sync_cursor_signals(&state_for_focus);
1317                    }
1318                    // Seed the OS IME candidate area at the caret so the
1319                    // first composition appears in the right place.
1320                    keyboard::report_ime_cursor_area(&state_for_focus, ctx);
1321                } else {
1322                    // Preserve `cursor`'s selection across focus loss
1323                    // — clearing it here breaks the right-click
1324                    // context menu path (the framework focuses the
1325                    // newly-mounted menu, which dispatches `FocusLost`
1326                    // here, and `Cut` / `Copy` invoked from the menu
1327                    // afterwards find an empty selection). Native
1328                    // macOS / Windows text fields keep the selection
1329                    // on blur too — typically the visual is dimmed
1330                    // but the selection state is preserved so the
1331                    // next focus-gain or context-menu invocation
1332                    // still operates on it.
1333                    st.scroll_x = 0.0;
1334                    st.caret_visible.set(false);
1335                    st.drag_state = state::DragState::Idle;
1336                    // Drop the IME-area dedup cache. The OS candidate area is a
1337                    // single per-window resource a sibling field may re-point
1338                    // while we are unfocused; clearing this forces the next
1339                    // focus-gain report to re-seed it instead of being deduped.
1340                    st.last_ime_area = None;
1341                    blur_callback = st.on_blur.clone();
1342                    drop(st);
1343                    // Abandon any in-progress composition on blur — remove
1344                    // the tentative preedit text from the document.
1345                    keyboard::clear_ime_preedit(&state_for_focus);
1346                    sync_cursor_signals(&state_for_focus);
1347                }
1348                if let Some(cb) = blur_callback {
1349                    cb(ctx);
1350                }
1351                ctx.request_frame();
1352            })
1353            .on_pointer_event(move |event, ctx| {
1354                mouse::handle_pointer_event(&state_for_pointer, event, ctx)
1355            })
1356            .on_key(move |event, ctx| keyboard::handle_key(&state_for_key, event, ctx))
1357            .on_double_tap(move |event, ctx| {
1358                mouse::handle_double_tap(&state_for_double, event.position, ctx)
1359            })
1360            .on_triple_tap(move |event, ctx| {
1361                mouse::handle_triple_tap(&state_for_triple, event.position, ctx)
1362            })
1363            .on_access_action_request(move |action, _target_node, data, ctx| {
1364                handle_access_action(&state_for_access, action, data, ctx)
1365            })
1366            // Right-click context menu — built fresh per click so the
1367            // enabled state of each item reflects the live selection /
1368            // clipboard state at the moment the menu opens. The framework
1369            // handles overlay placement, focus restoration, and dismissal.
1370            .context_menu(move |position, ctx| {
1371                let _ = ctx;
1372                // Framework gates pointer events on `arena.is_enabled`
1373                // before reaching this closure — a disabled field
1374                // never receives the right-click that would open the
1375                // context menu.
1376                // Reposition the caret to the click position when the
1377                // click lands outside the existing selection — the
1378                // platform convention for "right-click then Cut /
1379                // Copy / Paste at the new caret".
1380                mouse::reposition_caret_for_context_menu(&state_for_menu, position);
1381                Some(build_context_menu_widget(&state_for_menu))
1382            });
1383
1384        ctx.apply_self_handlers(handlers);
1385        Vec::new()
1386    }
1387
1388    fn layout_response(
1389        &self,
1390        proposal: SizeProposal,
1391        ctx: &LayoutContext,
1392    ) -> teksilo_core::widget::LayoutResponse {
1393        // Default unwrap is the cached natural width (mask-aware when
1394        // a mask is set; 200 dp fallback otherwise). Composing widgets
1395        // that wrap us in a constraint pass `Some(width)` and we use
1396        // that; the natural width is what surfaces in unconstrained
1397        // intrinsic queries (ZStack measurement with `unspecified()`,
1398        // etc.) so the chain reports a sensible content size.
1399        //
1400        // The cached `natural_width` / `text_height` are 1.0-scale baselines;
1401        // multiply by `ctx.text_scale` so the field box grows with the global
1402        // accessibility text scale (the engine grows the glyphs to match — see
1403        // `paint`). A caller-supplied width constraint is honored as-is.
1404        let scale = ctx.text_scale;
1405        let w = proposal
1406            .width
1407            .unwrap_or(self.natural_width * scale)
1408            .max(0.0);
1409        let h = (self.text_height.unwrap_or(DEFAULT_TEXT_HEIGHT) * scale).max(0.0);
1410        Size::new(w, h).into()
1411    }
1412
1413    fn place_children(
1414        &self,
1415        bounds: Rect,
1416        _proposal: SizeProposal,
1417        _children: &mut [WidgetPlacement],
1418        _ctx: &LayoutContext,
1419    ) {
1420        // Layout runs before paint, so this is the authoritative point to adopt
1421        // the field's viewport. `sync_viewport` welds the width write to the
1422        // `needs_full_layout` flag it also serves as the detector for (see its
1423        // docs); paint calls it again as an idempotent echo.
1424        if let Some(state) = self.state.as_ref() {
1425            state.borrow_mut().sync_viewport(bounds);
1426        }
1427    }
1428
1429    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1430        let Some(state) = self.state.as_ref() else {
1431            return;
1432        };
1433        let mut st = state.borrow_mut();
1434
1435        // Grow the shaped text with the global accessibility scale. Must run
1436        // before the relayout block below so the larger glyphs are shaped this
1437        // frame; no-op when the scale is unchanged.
1438        st.apply_font_scale(ctx.text_scale);
1439        // Idempotent echo — `place_children` already adopted these exact bounds
1440        // during layout, so this is normally a no-op.
1441        st.sync_viewport(bounds);
1442
1443        // Resolve the glyph / caret / suffix colours against the *effective*
1444        // enabled state, exactly as `TextWidget` and `RectWidget` resolve a
1445        // `ColorProp` at paint time. `paint` is the single writer of these:
1446        // the field shapes through a `RichTextEngine`, which takes raw GPU
1447        // colours and so never passes through `ColorProp::resolve` — the
1448        // disabled substitution that greys every role-driven leaf for free
1449        // cannot reach it. Doing it here (rather than as a build-time effect
1450        // on `effective_enabled_signal`) is also the only correct option:
1451        // that signal is *derived* whenever an ancestor binds `enabled`, and
1452        // `Signal::observe` panics on derived signals. Cheap — the engine
1453        // stores the colour and the render-frame builder reads it, so there
1454        // is no relayout and no reshaping.
1455        let text_color = if ctx.effective_enabled {
1456            ctx.theme.colors.text_primary
1457        } else {
1458            ctx.theme.colors.text_disabled
1459        };
1460        st.engine.set_text_color(text_color.to_array());
1461        st.engine.set_cursor_color(text_color.to_array());
1462
1463        let suffix_width = st.suffix_width;
1464        let text_viewport_width = (bounds.width - suffix_width).max(0.0);
1465
1466        st.engine.set_viewport(10_000.0, bounds.height);
1467
1468        if st.needs_full_layout || !st.engine.has_full_layout() {
1469            st.layout_full_masked();
1470            st.needs_full_layout = false;
1471            st.content_dirty = true;
1472        }
1473
1474        // Suppress the caret in an inactive window for every paint — the
1475        // authoritative gate, covering the frame between a window-active flip
1476        // and the build-time effect running.
1477        let caret_on = st.caret_visible.get() && st.has_focus && st.window_active;
1478        // `NoEcho` while masked lays out an *empty* source, so the real
1479        // document cursor (which may sit past 0) must not be handed to
1480        // the engine — pin the displayed caret/selection to the start.
1481        // The real `cursor` still tracks the true position for editing.
1482        let hide_all = st.echo_mode == EchoMode::NoEcho && st.should_mask();
1483        let (disp_pos, disp_anchor) = if hide_all {
1484            (0, 0)
1485        } else {
1486            (st.cursor.position(), st.cursor.anchor())
1487        };
1488        // Single-line input has no wrap → affinity is moot; the
1489        // default Downstream matches pre-affinity behavior.
1490        let cursor_display = CursorDisplay {
1491            position: disp_pos,
1492            anchor: disp_anchor,
1493            affinity: CursorAffinity::Downstream,
1494            visible: caret_on,
1495            selected_cells: Vec::new(),
1496        };
1497        st.engine.set_cursor(&cursor_display);
1498
1499        ensure_caret_visible_h(&mut st, text_viewport_width);
1500
1501        let scroll_x = st.scroll_x;
1502
1503        let text_clip = Rect::new(bounds.x, bounds.y, text_viewport_width, bounds.height);
1504        canvas.set_clip(text_clip);
1505
1506        {
1507            let state_ref: &mut TextInputState = &mut st;
1508            let TextInputState {
1509                ref mut engine,
1510                ref document,
1511                ref mut image_cache,
1512                ..
1513            } = *state_ref;
1514
1515            engine.with_render_frame(|frame| {
1516                paint_frame(
1517                    canvas,
1518                    PaintParams {
1519                        frame,
1520                        origin: Point::new(bounds.x - scroll_x, bounds.y),
1521                        document,
1522                        image_cache,
1523                        // No inline images on this surface, so none can be missing.
1524                        image_resolver: None,
1525                        selection: None,
1526                        selection_color: [0.0; 4],
1527                        selected_image_out: None,
1528                        resize_preview: None,
1529                        draw_caret: caret_on,
1530                    },
1531                );
1532            });
1533        }
1534
1535        // IME preedit underline: a thin line under the composing range so
1536        // the user sees the text is tentative. Single line → one segment;
1537        // on a secure field it sits under the masked bullets. Drawn inside
1538        // the text clip so it never spills past the viewport.
1539        if let Some(range) = st.ime_preedit_range.clone()
1540            && st.engine.has_full_layout()
1541            && range.start < range.end
1542        {
1543            let start_c = st
1544                .engine
1545                .caret_rect(range.start, CursorAffinity::Downstream);
1546            let end_c = st.engine.caret_rect(range.end, CursorAffinity::Downstream);
1547            let x0 = bounds.x - scroll_x + start_c[0];
1548            let x1 = bounds.x - scroll_x + end_c[0];
1549            let y = bounds.y + start_c[1] + start_c[3] - 1.0;
1550            canvas.draw_line(
1551                Point::new(x0, y),
1552                Point::new(x1, y),
1553                ctx.theme.colors.text_primary,
1554                teksilo_canvas::StrokeStyle::solid(1.0),
1555            );
1556        }
1557
1558        canvas.clear_clip();
1559
1560        if suffix_width > 0.0
1561            && let Some(suffix_engine) = st.suffix_engine.as_mut()
1562        {
1563            // The suffix dims with the value it annotates — a crisp " %"
1564            // beside greyed-out digits reads as a rendering bug.
1565            let suffix_color = if ctx.effective_enabled {
1566                ctx.theme.colors.text_secondary
1567            } else {
1568                ctx.theme.colors.text_disabled
1569            };
1570            suffix_engine.set_text_color(suffix_color.to_array());
1571            let suffix_clip = Rect::new(
1572                bounds.x + text_viewport_width,
1573                bounds.y,
1574                suffix_width,
1575                bounds.height,
1576            );
1577            canvas.set_clip(suffix_clip);
1578            let suffix_origin = Point::new(bounds.x + text_viewport_width, bounds.y);
1579            suffix_engine.with_render_frame(|frame| {
1580                paint_suffix_glyphs(canvas, frame, suffix_origin);
1581            });
1582            canvas.clear_clip();
1583        }
1584    }
1585
1586    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1587        use teksilo_core::accesskit::{Action, Role};
1588
1589        let Some(state) = self.state.as_ref() else {
1590            return;
1591        };
1592        let st = state.borrow();
1593
1594        let text = st.document.to_plain_text().unwrap_or_default();
1595
1596        // AT-protection tracks the *explicit* reveal toggle only — not
1597        // the visual `RevealWhileTyping` focus-reveal (a sighted-only
1598        // convenience that a screen reader shouldn't surface as
1599        // plaintext, and that has no AT-dirty trigger on focus). The
1600        // reveal signal is bound at AccessibilityOnly in `build`, so the
1601        // role/value swap reaches AT when it flips. `Role::PasswordInput`
1602        // is the sole mechanism telling AT not to speak the value —
1603        // accesskit has no separate `protected` flag.
1604        let explicitly_revealed = st.revealed.as_ref().is_some_and(|s| s.get());
1605        let protected = st.secure
1606            && match st.at_reveal_policy {
1607                AtRevealPolicy::AlwaysProtected => true,
1608                AtRevealPolicy::SwapRole => !explicitly_revealed,
1609            };
1610
1611        if protected {
1612            builder.set_role(Role::PasswordInput);
1613            // Expose a bullet string of the right length (NoEcho hides
1614            // even that) so AT can announce the character count, never
1615            // the secret. Deliberately omit character lengths, word
1616            // starts, and the text selection: the caret model stays
1617            // opaque so no structure about the secret leaks.
1618            if st.echo_mode != EchoMode::NoEcho {
1619                let count = text.chars().count();
1620                if count > 0 {
1621                    builder.set_value(st.echo_char.to_string().repeat(count));
1622                }
1623            }
1624        } else {
1625            // Plain field, or a revealed field under `SwapRole`: report
1626            // as a text input exposing the real value, mirroring the web
1627            // `type=password ↔ type=text` swap. The specialised role from
1628            // `input_purpose` (WCAG 1.3.5) applies here; `Role::TextInput` is
1629            // the `Normal` default.
1630            builder.set_role(self.input_purpose.to_role());
1631            // Keep the value on the input node so the focus announcement is
1632            // unchanged: accesskit resolves `value()` from `data().value()`
1633            // first, falling back to the TextRun text only when unset.
1634            if !text.is_empty() {
1635                builder.set_value(&text);
1636            }
1637
1638            // Expose the editable content as a child `Role::TextRun`, NOT as
1639            // `character_lengths` on the input node itself. accesskit_consumer's
1640            // `supports_text_ranges()` is false for a childless input that only
1641            // hosts character data on its own node, so the macOS adapter never
1642            // fires `AXSelectedTextChanged` — VoiceOver reads the value once on
1643            // focus but never echoes characters/words while typing. Emit the run
1644            // even when empty so `supports_text_ranges()` is already true before
1645            // the first keystroke (the change-diff's *old* node must support
1646            // ranges too for the notification to fire). `position()` / `anchor()`
1647            // are character indices (text-document is char-space), matching the
1648            // TextRun's `character_index` contract — correct for multibyte text.
1649            let char_lengths: Vec<u8> = text.chars().map(|c| c.len_utf8() as u8).collect();
1650            let word_starts = compute_word_starts(&text);
1651            let word_starts = (!word_starts.is_empty()).then_some(word_starts);
1652            let run_id =
1653                builder.push_text_run_child_on_self(0, text.clone(), char_lengths, word_starts);
1654
1655            // While composing (IME preedit active), expose the composition
1656            // as a selection so screen readers / braille track the tentative
1657            // text — the composing characters are already in `value`. Falls
1658            // back to the live cursor/selection when not composing. (The
1659            // secure branch above never reaches here, so a password preedit
1660            // is never exposed.) Selection now references the TextRun child.
1661            let (anchor, pos) = match st.ime_preedit_range.clone() {
1662                Some(range) => (range.start, range.end),
1663                None => (st.cursor.anchor(), st.cursor.position()),
1664            };
1665            builder.set_text_selection_to((run_id, anchor), (run_id, pos));
1666        }
1667
1668        if !st.placeholder.is_empty() {
1669            builder.set_placeholder(st.placeholder.clone());
1670        }
1671
1672        if st.read_only {
1673            builder.set_read_only();
1674        }
1675
1676        builder.add_action(Action::Focus);
1677        if !st.read_only {
1678            builder.add_action(Action::SetValue);
1679            builder.add_action(Action::ReplaceSelectedText);
1680        }
1681        // Only meaningful when the caret model is exposed to AT.
1682        if !protected {
1683            builder.add_action(Action::SetTextSelection);
1684        }
1685
1686        // Validation feedback → accesskit `aria-invalid`. Surface
1687        // `Invalid` as `Invalid::True`; `Corrected` doesn't carry an
1688        // invalid marker (the data is now valid) but the composite's
1689        // Live region announces the correction. The framework's
1690        // AccessNodeBuilder doesn't yet wrap `set_invalid`, so reach
1691        // through `inner_mut()` which is the documented escape hatch.
1692        if self.feedback.get().is_invalid() {
1693            builder
1694                .inner_mut()
1695                .set_invalid(teksilo_core::accesskit::Invalid::True);
1696        }
1697
1698        // ARIA combobox wiring. This node is the one that actually holds
1699        // keyboard focus, which is why the relation is published here and not
1700        // on whichever composite owns the list — AT follows the *focused*
1701        // node's active descendant.
1702        if let Some(listbox) = self.controls.as_ref().and_then(|s| s.get()) {
1703            builder.push_controlled(teksilo_core::accessibility::widget_id_to_node_id(listbox));
1704        }
1705        if let Some(active) = self.active_descendant.as_ref().and_then(|s| s.get()) {
1706            builder
1707                .inner_mut()
1708                .set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(active));
1709        }
1710    }
1711}
1712
1713impl TextInputField {
1714    /// Borrow the shared state. Panics if called before `build()`
1715    /// has run — the state is allocated in `build()` from the
1716    /// builder config.
1717    fn state(&self) -> &SharedState {
1718        self.state
1719            .as_ref()
1720            .expect("TextInputField::state called before build")
1721    }
1722}
1723
1724/// Adjust `scroll_x` so the caret stays within the visible viewport.
1725///
1726/// `text_viewport_width` is the portion of the viewport reserved for
1727/// editable text, i.e. `viewport_width - suffix_width`. Callers pass
1728/// the reduced width explicitly so the scroll never slides text
1729/// behind the non-editable suffix.
1730fn ensure_caret_visible_h(st: &mut TextInputState, text_viewport_width: f32) {
1731    if !st.engine.has_full_layout() || text_viewport_width <= 0.0 {
1732        return;
1733    }
1734    let pos = st.cursor.position();
1735    // Single-line input: no wrap, affinity is a no-op.
1736    let caret = st.engine.caret_rect(pos, CursorAffinity::Downstream);
1737    let caret_x = caret[0];
1738    let caret_w = caret[2].max(1.0);
1739    let vw = text_viewport_width;
1740
1741    if caret_x - st.scroll_x < SCROLL_MARGIN {
1742        st.scroll_x = (caret_x - SCROLL_MARGIN).max(0.0);
1743    } else if caret_x + caret_w - st.scroll_x > vw - SCROLL_MARGIN {
1744        st.scroll_x = caret_x + caret_w - vw + SCROLL_MARGIN;
1745    }
1746}
1747
1748/// Update the cached suffix text and re-run layout on the suffix
1749/// engine. Called from `build()` for the initial value and from
1750/// the reactive effect when the bound suffix signal fires.
1751fn relayout_suffix(state: &SharedState, new_text: &str) {
1752    let mut st = state.borrow_mut();
1753    st.suffix = new_text.to_string();
1754    if new_text.is_empty() {
1755        st.suffix_width = 0.0;
1756        // Leave the engine in place (cheap to reuse) but don't
1757        // lay out — paint skips the suffix when width is zero.
1758        return;
1759    }
1760    let Some(engine) = st.suffix_engine.as_mut() else {
1761        // No engine allocated (pure-static path that started
1762        // empty and never became non-empty). Allocate lazily so
1763        // late signal flips still render.
1764        return;
1765    };
1766    let doc = TextDocument::new();
1767    let _ = doc.set_plain_text(new_text);
1768    let flow = doc.snapshot_flow();
1769    engine.layout_full(&flow);
1770    st.suffix_width = engine.max_content_width();
1771}
1772
1773/// Paint glyphs from a pre-laid-out suffix `RenderFrame` at a fixed
1774/// origin. Decorations, selection rectangles, and caret are ignored —
1775/// the suffix is plain non-editable text, so only the glyph pass is
1776/// needed. Kept inline (rather than reusing `paint_frame`) to avoid
1777/// the `TextDocument` / `ImageCache` parameters `paint_frame`
1778/// requires for inline images the suffix never contains.
1779fn paint_suffix_glyphs(canvas: &mut Canvas, frame: &teksilo_text::RenderFrame, origin: Point) {
1780    use teksilo_canvas::GlyphQuad as CanvasGlyphQuad;
1781    for g in frame.glyphs.iter() {
1782        let quad = CanvasGlyphQuad {
1783            screen: [
1784                g.screen[0] + origin.x,
1785                g.screen[1] + origin.y,
1786                g.screen[2],
1787                g.screen[3],
1788            ],
1789            atlas: g.atlas,
1790            color: g.color,
1791            is_color: g.is_color,
1792        };
1793        canvas.draw_glyph_quad(quad);
1794    }
1795}
1796
1797/// Selection-highlight colour for a text field: the vivid `selection_bg_active`
1798/// while the host window is active, the muted `selection_bg_inactive` while it
1799/// is inactive — so a field's selection desaturates in a background window
1800/// (the universal desktop convention; the same `selection_bg_inactive` the OS
1801/// uses for unfocused selection).
1802/// The band a selection is painted in — the **active** tint only while this
1803/// field is the one the keystrokes would go to.
1804///
1805/// Two axes, and both are needed. The window losing focus was already handled;
1806/// what was missing is the field losing it *within* an active window, which is
1807/// the common case: click into a cell editor, then click a button, and the
1808/// field went on showing a fully-lit selection as though it were still taking
1809/// input. Two fields on screen could both look focused at once.
1810///
1811/// The selection *state* is deliberately kept across blur — see the
1812/// `on_focus(false)` arm, which spells out why (the right-click Copy path needs
1813/// it, and native fields keep it too). This is the other half of that same
1814/// sentence: the state is preserved, the **visual is dimmed**. Only the second
1815/// half was implemented.
1816fn field_selection_color(
1817    colors: &teksilo_tokens::ColorTokens,
1818    window_active: bool,
1819    has_focus: bool,
1820) -> [f32; 4] {
1821    if window_active && has_focus {
1822        colors.selection_bg_active.to_array()
1823    } else {
1824        colors.selection_bg_inactive.to_array()
1825    }
1826}
1827
1828/// Simplified frame-loop tick for single-line text input.
1829fn tick(state: &mut TextInputState, delta: f32) -> bool {
1830    if !state.pending_chars.is_empty() {
1831        let batch = std::mem::take(&mut state.pending_chars);
1832        let _ = state.cursor.insert_text(&batch);
1833        state.pending_text_changed = true;
1834    }
1835
1836    let had_events = state.drain_events();
1837
1838    // Blink only when focused AND the host window is active — the caret hides
1839    // in an inactive window (the universal desktop convention). The else-branch
1840    // below then turns it off, since `!blinking_active` now also covers the
1841    // window-inactive case.
1842    let caret_active = state.has_focus && state.window_active;
1843    let caret_visible = state.caret_visible.clone();
1844    let wake = state.frame_wake_at.clone();
1845    // A single-line field always blinks (no read-only/static presets), so it
1846    // hands the shared machine a fixed `Blinking` policy.
1847    state.blink.tick(
1848        CaretPolicy::Blinking,
1849        caret_active,
1850        &caret_visible,
1851        wake.as_ref(),
1852    );
1853
1854    if state.needs_full_layout && state.viewport_width > 0.0 {
1855        state.layout_full_masked();
1856        state.needs_full_layout = false;
1857        state.content_dirty = true;
1858    }
1859
1860    if state.pending_text_changed {
1861        let new_text = state.document.to_plain_text().unwrap_or_default();
1862        if state.text_signal.get() != new_text {
1863            state.deferred_text_update = Some(new_text);
1864        }
1865    }
1866
1867    if state.debounce.tick(delta) {
1868        if state.pending_text_changed {
1869            state.pending_text_changed = false;
1870        }
1871        if let Some((cu, cr)) = state.pending_undo_redo.take() {
1872            if state.can_undo.get() != cu {
1873                state.can_undo.set(cu);
1874            }
1875            if state.can_redo.get() != cr {
1876                state.can_redo.set(cr);
1877            }
1878        }
1879    }
1880    let debounce_work = state.pending_text_changed || state.pending_undo_redo.is_some();
1881
1882    had_events || debounce_work
1883}
1884
1885/// Handle AccessKit actions (SetValue, SetTextSelection, Focus).
1886fn handle_access_action(
1887    state: &SharedState,
1888    action: teksilo_core::accesskit::Action,
1889    data: Option<teksilo_core::accesskit::ActionData>,
1890    ctx: &mut EventContext,
1891) -> EventResponse {
1892    use teksilo_core::accesskit::{Action, ActionData};
1893
1894    match (action, data) {
1895        (Action::SetTextSelection, Some(ActionData::SetTextSelection(sel))) => {
1896            let st = state.borrow();
1897            st.cursor.set_position(
1898                sel.anchor.character_index,
1899                teksilo_text::text_document::MoveMode::MoveAnchor,
1900            );
1901            st.cursor.set_position(
1902                sel.focus.character_index,
1903                teksilo_text::text_document::MoveMode::KeepAnchor,
1904            );
1905            drop(st);
1906            sync_cursor_signals(state);
1907            ctx.request_frame();
1908            EventResponse::Handled
1909        }
1910        (Action::SetValue, Some(ActionData::Value(value))) => {
1911            let st = state.borrow();
1912            st.cursor.select(SelectionType::Document);
1913            let _ = st.cursor.insert_text(value.as_ref());
1914            drop(st);
1915            sync_cursor_signals(state);
1916            ctx.request_frame();
1917            EventResponse::Handled
1918        }
1919        (Action::ReplaceSelectedText, Some(ActionData::Value(value))) => {
1920            // Insert at the caret, replacing the active selection (if
1921            // any) — NOT the whole document like `SetValue`. This is the
1922            // AT-SPI (Linux) / UIA (Windows) braille-keyboard and
1923            // dictation insertion path; macOS routes insertion through
1924            // `SetValue` instead, so this never fires there. We advertise
1925            // the action in `accessibility()`, so we must service it.
1926            let st = state.borrow();
1927            let _ = st.cursor.insert_text(value.as_ref());
1928            drop(st);
1929            sync_cursor_signals(state);
1930            ctx.request_frame();
1931            EventResponse::Handled
1932        }
1933        (Action::Focus, _) => {
1934            if let Some(id) = state.borrow().field_widget_id {
1935                ctx.request_focus(id);
1936            }
1937            EventResponse::Handled
1938        }
1939        _ => EventResponse::Ignored,
1940    }
1941}
1942
1943/// Compute word-start character indices for AccessKit.
1944fn compute_word_starts(text: &str) -> Vec<u8> {
1945    let mut starts = Vec::new();
1946    let mut in_word = false;
1947    for (char_index, ch) in text.chars().enumerate() {
1948        let is_word_char = ch.is_alphanumeric() || ch == '_';
1949        if is_word_char
1950            && !in_word
1951            && let Ok(idx) = u8::try_from(char_index)
1952        {
1953            starts.push(idx);
1954        }
1955        in_word = is_word_char;
1956    }
1957    starts
1958}
1959
1960/// Build a fresh right-click context menu widget. Called from the
1961/// `.context_menu(...)` factory on every right-click, so each open
1962/// reads live `has_selection` / `is_empty` state when computing each
1963/// item's enabled flag.
1964fn build_context_menu_widget(state: &SharedState) -> Box<dyn Widget> {
1965    let st = state.borrow();
1966    let has_selection = st.cursor.has_selection();
1967    let doc_non_empty = !st.document.to_plain_text().unwrap_or_default().is_empty();
1968    // Secure fields suppress Cut / Copy while masked (still allowed when
1969    // revealed or when the developer opted in via `allow_copy`).
1970    let copy_allowed = st.copy_allowed();
1971    drop(st);
1972
1973    let state_cut = state.clone();
1974    let state_copy = state.clone();
1975    let state_paste = state.clone();
1976    let state_select_all = state.clone();
1977
1978    Box::new(
1979        MenuList::new()
1980            .item(
1981                MenuItem::new(tr_widget!(menu_cut()))
1982                    .shortcut_label(format_keystroke(KeyStroke::command(Key::X)))
1983                    .enabled(has_selection && copy_allowed)
1984                    .on_activate_fn(move |ctx| {
1985                        {
1986                            let mut st = state_cut.borrow_mut();
1987                            keyboard::clipboard_cut(&mut st, ctx);
1988                        }
1989                        sync_cursor_signals(&state_cut);
1990                        ctx.request_frame();
1991                    }),
1992            )
1993            .item(
1994                MenuItem::new(tr_widget!(menu_copy()))
1995                    .shortcut_label(format_keystroke(KeyStroke::command(Key::C)))
1996                    .enabled(has_selection && copy_allowed)
1997                    .on_activate_fn(move |ctx| {
1998                        let mut st = state_copy.borrow_mut();
1999                        keyboard::clipboard_copy(&mut st, ctx);
2000                    }),
2001            )
2002            .item(
2003                MenuItem::new(tr_widget!(menu_paste()))
2004                    .shortcut_label(format_keystroke(KeyStroke::command(Key::V)))
2005                    .on_activate_fn(move |ctx| {
2006                        {
2007                            let mut st = state_paste.borrow_mut();
2008                            keyboard::clipboard_paste(&mut st, ctx);
2009                        }
2010                        sync_cursor_signals(&state_paste);
2011                        ctx.request_frame();
2012                    }),
2013            )
2014            .item(MenuSeparator)
2015            .item(
2016                MenuItem::new(tr_widget!(menu_select_all()))
2017                    .shortcut_label(format_keystroke(KeyStroke::command(Key::A)))
2018                    .enabled(doc_non_empty)
2019                    .on_activate_fn(move |ctx| {
2020                        {
2021                            let st = state_select_all.borrow();
2022                            st.cursor.select(SelectionType::Document);
2023                        }
2024                        sync_cursor_signals(&state_select_all);
2025                        ctx.request_frame();
2026                    }),
2027            ),
2028    )
2029}
2030
2031/// Run the validator on the bound text and update the feedback signal.
2032///
2033/// On `Corrected`, also writes the corrected text back to the bound
2034/// signal — the field's external→internal sync effect picks this up
2035/// and rewrites the document in the next frame. On `Invalid`, the
2036/// text is left as-typed; composites that want a "revert on invalid"
2037/// behaviour observe the feedback signal and rewrite the text from
2038/// their own source of truth (e.g., `DateEdit` reformats from its
2039/// `Signal<Option<Date>>`).
2040fn run_validator_and_apply(
2041    validator: &ValidatorFn,
2042    bound_text: &Signal<String>,
2043    feedback: &Signal<ValidationFeedback>,
2044) {
2045    let raw = bound_text.get();
2046    match validator(&raw) {
2047        ValidationOutcome::Valid => {
2048            feedback.set(ValidationFeedback::Valid);
2049        }
2050        ValidationOutcome::Corrected { corrected, message } => {
2051            // Write the corrected text first so observers of the
2052            // bound signal see the new value before the feedback
2053            // signal flips. Composites that bind to BOTH signals
2054            // (rare) will see a consistent pair: text + correction
2055            // notice describing the change.
2056            if bound_text.get() != corrected {
2057                bound_text.set(corrected);
2058            }
2059            feedback.set(ValidationFeedback::Corrected {
2060                message,
2061                since: std::time::Instant::now(),
2062            });
2063        }
2064        ValidationOutcome::Invalid { message } => {
2065            feedback.set(ValidationFeedback::Invalid { message });
2066        }
2067    }
2068}
2069
2070/// Build the worst-case-glyph version of an [`InputMask`] for
2071/// natural-width measurement: every editable slot holds the widest
2072/// plausible character its class can accept, and every fixed slot
2073/// holds its literal. Used by `build()` to size the field's
2074/// intrinsic envelope so a fully-typed value never overflows the
2075/// reported natural width.
2076///
2077/// Per-class worst-case glyph (Inter and most UI sans-serifs):
2078/// - `Digit` → `0` (tabular figures are constant-width, but `0` is
2079///   representative for fonts that aren't)
2080/// - `Letter` / `Alphanumeric` / `Any` → `M` (widest cap glyph)
2081/// - `HexDigit` → `0`
2082fn worst_case_template(mask: &InputMask) -> String {
2083    let mut s = String::with_capacity(mask.len());
2084    for pos in mask.positions() {
2085        match pos {
2086            MaskPosition::Editable { class, .. } => {
2087                s.push(match class {
2088                    MaskClass::Digit | MaskClass::HexDigit => '0',
2089                    MaskClass::Letter | MaskClass::Alphanumeric | MaskClass::Any => 'M',
2090                });
2091            }
2092            MaskPosition::Fixed(c) => s.push(*c),
2093        }
2094    }
2095    s
2096}
2097
2098/// Measure the advance width of `text` in logical pixels using the
2099/// app-wide `SharedTypesetter` (the same backend the field paints
2100/// with). Falls back to a per-character-class heuristic when no
2101/// typesetter is installed (headless tests) so the caller still gets
2102/// a non-zero width and any natural-width / cap logic behaves
2103/// reasonably even there. The fallback weights match Inter's body
2104/// proportions closely enough that the difference between an
2105/// underscore and a wide cap glyph (`M`) shows up in headless tests
2106/// — important for verifying the worst-case-glyph mask measurement
2107/// without booting a typesetter.
2108fn measure_width_px(ctx: &mut BuildContext, text: &str, style: &TextStyle) -> f32 {
2109    if text.is_empty() {
2110        return 0.0;
2111    }
2112    if let Some(ts) = ctx.app_state::<SharedTypesetter>() {
2113        let backend = ts.as_text_backend();
2114        let layout = backend.borrow_mut().layout_single_line(text, style, None);
2115        return layout.width;
2116    }
2117    let em = style.size;
2118    text.chars()
2119        .map(|c| match c {
2120            ' ' => 0.30,
2121            '_' => 0.45,
2122            ':' | '.' | ',' | ';' | '/' | '|' | '!' | 'i' | 'l' | 'I' => 0.30,
2123            '0'..='9' => 0.55,
2124            'M' | 'W' | 'm' | 'w' => 0.85,
2125            'A'..='Z' => 0.65,
2126            'a'..='z' => 0.50,
2127            _ => 0.55,
2128        })
2129        .map(|w: f32| w * em)
2130        .sum()
2131}
2132
2133#[cfg(test)]
2134mod window_active_tests {
2135    use super::*;
2136    use teksilo_canvas::{Point, SizeProposal};
2137    use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2138    use teksilo_core::signal::Signal;
2139    use teksilo_core::widget_tree::WidgetTree;
2140
2141    #[test]
2142    fn field_selection_color_swaps_on_window_active() {
2143        let colors = teksilo_core::presets::intui::light().colors;
2144        assert_eq!(
2145            field_selection_color(&colors, true, true),
2146            colors.selection_bg_active.to_array(),
2147            "active window uses the vivid selection colour"
2148        );
2149        assert_eq!(
2150            field_selection_color(&colors, false, true),
2151            colors.selection_bg_inactive.to_array(),
2152            "inactive window uses the muted selection colour"
2153        );
2154        assert_ne!(
2155            field_selection_color(&colors, true, true),
2156            field_selection_color(&colors, false, true)
2157        );
2158    }
2159
2160    /// **A field that is not focused dims its selection, even in an active
2161    /// window.**
2162    ///
2163    /// Only the window axis was ever consulted, so clicking from one field to
2164    /// another left both showing a fully-lit selection: two controls claiming
2165    /// the keystrokes at once. The selection *state* is kept on blur on
2166    /// purpose — the `on_focus(false)` arm explains why, and native fields do
2167    /// the same — and this is the other half of that sentence, which had never
2168    /// been written.
2169    #[test]
2170    fn field_selection_color_dims_when_the_field_is_not_focused() {
2171        let colors = teksilo_core::presets::intui::light().colors;
2172        assert_eq!(
2173            field_selection_color(&colors, true, false),
2174            colors.selection_bg_inactive.to_array(),
2175            "an unfocused field must dim its selection even in an active window"
2176        );
2177        assert_eq!(
2178            field_selection_color(&colors, false, false),
2179            colors.selection_bg_inactive.to_array()
2180        );
2181    }
2182
2183    /// ...and the live field re-tints as focus comes and goes, rather than
2184    /// keeping whatever colour it was built with.
2185    #[test]
2186    fn a_field_re_tints_its_selection_when_focus_leaves_it() {
2187        let colors = teksilo_core::presets::intui::light().colors;
2188        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2189        let a = tree.add(TextInputField::new(Signal::new("hello".to_string())));
2190        let b = tree.add(TextInputField::new(Signal::new("world".to_string())));
2191        tree.layout(SizeProposal::exact(200.0, 40.0));
2192
2193        let tint = |tree: &WidgetTree, id| {
2194            tree.widget_as_any(id)
2195                .and_then(|w| w.downcast_ref::<TextInputField>())
2196                .and_then(|f| f.state.as_ref())
2197                .map(|st| st.borrow().selection_tint)
2198                .expect("a built field")
2199        };
2200
2201        tree.focus(a);
2202        assert_eq!(
2203            tint(&tree, a),
2204            colors.selection_bg_active.to_array(),
2205            "the focused field paints its selection live"
2206        );
2207
2208        tree.focus(b);
2209        assert_eq!(
2210            tint(&tree, a),
2211            colors.selection_bg_inactive.to_array(),
2212            "focus moved to another field and the first kept a lit selection"
2213        );
2214        assert_eq!(tint(&tree, b), colors.selection_bg_active.to_array());
2215    }
2216
2217    #[test]
2218    fn caret_hidden_when_window_inactive() {
2219        let text = Signal::new("hello".to_string());
2220        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2221        let id = tree.add(TextInputField::new(text));
2222        tree.layout(SizeProposal::exact(200.0, 40.0));
2223        let _ = tree.render();
2224
2225        // Reach the built field's shared state (created lazily in build()) to
2226        // observe the caret-gate inputs directly — the caret paints as an
2227        // engine-internal fill, not a top-level decoration.
2228        let state = tree
2229            .widget_as_any(id)
2230            .and_then(|a| a.downcast_ref::<TextInputField>())
2231            .map(|f| f.state().clone())
2232            .expect("built TextInputField is reachable via as_any");
2233
2234        // Focus the field by clicking its centre.
2235        let b = tree.bounds(id);
2236        tree.dispatch_event(WidgetEvent::PointerDown {
2237            position: Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
2238            button: PointerButton::Primary,
2239            modifiers: Modifiers::NONE,
2240        });
2241        // One frame so the blink turns the caret on (on_focus sets it on; the
2242        // 500 ms interval hasn't elapsed after a single 16 ms tick).
2243        tree.request_frame();
2244        tree.tick_animations(std::time::Duration::from_millis(16));
2245        tree.layout(SizeProposal::exact(200.0, 40.0));
2246
2247        assert!(state.borrow().has_focus, "field took focus");
2248        assert!(state.borrow().window_active);
2249        assert!(
2250            state.borrow().caret_visible.get(),
2251            "caret visible when focused in an active window"
2252        );
2253
2254        // Window blur: caret hidden (effect clears it synchronously).
2255        tree.set_window_active(false);
2256        assert!(!state.borrow().window_active);
2257        assert!(
2258            !state.borrow().caret_visible.get(),
2259            "caret hidden while the window is inactive"
2260        );
2261
2262        // Reactivate: caret returns immediately (field still holds focus).
2263        tree.set_window_active(true);
2264        assert!(
2265            state.borrow().caret_visible.get(),
2266            "caret restored on window reactivate"
2267        );
2268    }
2269}
2270
2271/// **A key the platform decorates with control text must still bubble.**
2272///
2273/// These dispatch `KeyDown` with the `text` a real keyboard carries. Every
2274/// synthetic helper in the workspace sends `text: None`, which skips the branch
2275/// under test entirely — so a test written with `press_key` passes on the bug.
2276#[cfg(test)]
2277mod key_text_bubbling_tests {
2278    use super::*;
2279    use std::cell::Cell;
2280    use teksilo_canvas::{Point, SizeProposal};
2281    use teksilo_core::WidgetBuilder;
2282    use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2283    use teksilo_core::signal::Signal;
2284    use teksilo_core::widget_tree::WidgetTree;
2285
2286    /// Dispatch one `KeyDown` to a focused field that sits *inside* a widget
2287    /// carrying an `on_key`, and report whether that outer handler saw it.
2288    ///
2289    /// The nesting is the point. Hanging the handler on the field itself puts
2290    /// it on the same node the click focuses, above the field's own handler
2291    /// rather than behind it, and the bubble under test never happens — which
2292    /// is exactly how an earlier version of this test passed on the bug.
2293    fn outer_handler_sees(key: Key, text: Option<&str>, field: TextInputField) -> bool {
2294        let seen = Rc::new(Cell::new(false));
2295        let seen_for_handler = seen.clone();
2296        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2297        let outer = tree.add(crate::primitives::VStack::new().child(field).on_key(
2298            move |_ev, _ctx| {
2299                seen_for_handler.set(true);
2300                EventResponse::Handled
2301            },
2302        ));
2303        tree.layout(SizeProposal::exact(200.0, 40.0));
2304
2305        let b = tree.bounds(outer);
2306        let centre = Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
2307        tree.dispatch_event(WidgetEvent::PointerDown {
2308            position: centre,
2309            button: PointerButton::Primary,
2310            modifiers: Modifiers::NONE,
2311        });
2312        tree.dispatch_event(WidgetEvent::PointerUp {
2313            position: centre,
2314            button: PointerButton::Primary,
2315            modifiers: Modifiers::NONE,
2316        });
2317        let focused = tree.focused().expect("the click focused something");
2318        assert_ne!(
2319            focused, outer,
2320            "focus must land on the field, or nothing below the outer handler is being tested"
2321        );
2322
2323        tree.dispatch_event(WidgetEvent::KeyDown {
2324            key,
2325            modifiers: Modifiers::NONE,
2326            text: text.map(str::to_string),
2327        });
2328        seen.get()
2329    }
2330
2331    /// The bug: winit gives Escape `text: Some("\u{1b}")`, the field had no
2332    /// `Escape` arm so it fell into the printable-character branch, the control
2333    /// character was filtered out, and the empty result was read as "input
2334    /// rejected" — which swallows the key. Escape therefore never left a
2335    /// focused field, and anything above it that closes on Escape stayed open.
2336    #[test]
2337    fn escape_bubbles_out_of_a_field_even_carrying_its_control_text() {
2338        assert!(
2339            outer_handler_sees(
2340                Key::Escape,
2341                Some("\u{1b}"),
2342                TextInputField::new(Signal::new("hello".to_string()))
2343            ),
2344            "Escape must reach the widget above the field"
2345        );
2346    }
2347
2348    /// ...and it made no difference with `text: None`, which is why the whole
2349    /// suite went green on the bug. Kept so the two cases stay visibly paired.
2350    #[test]
2351    fn escape_bubbles_out_of_a_field_without_text() {
2352        assert!(outer_handler_sees(
2353            Key::Escape,
2354            None,
2355            TextInputField::new(Signal::new("hello".to_string()))
2356        ));
2357    }
2358
2359    /// The other half of the guard, and the reason it is written against the
2360    /// *text* rather than the `Key` variant: a character the field's filter
2361    /// rejects is still swallowed, so a digits-only field does not let a
2362    /// rejected letter fall through and match a shortcut.
2363    ///
2364    /// A typed letter arrives as `Key::A`, not `Key::Character('a')`, so a
2365    /// variant test here would have silently stopped swallowing letters.
2366    #[test]
2367    fn a_filter_rejected_character_is_still_swallowed() {
2368        let digits_only = TextInputField::new(Signal::new(String::new()))
2369            .char_filter(|c: char| c.is_ascii_digit());
2370        assert!(
2371            !outer_handler_sees(Key::A, Some("a"), digits_only),
2372            "a rejected letter must not bubble into a shortcut match"
2373        );
2374    }
2375}
2376
2377/// A live handle on a [`TextInputField`] — its text-editing commands, for a
2378/// caller outside the widget.
2379///
2380/// Every method is a no-op before the field is built (and after it is
2381/// destroyed), which is the honest answer rather than a panic: a menu row bound
2382/// to a field that is no longer on screen should do nothing, not crash.
2383#[derive(Clone)]
2384pub struct TextFieldHandle {
2385    slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>>,
2386    focus_signal: Signal<bool>,
2387}
2388
2389impl std::fmt::Debug for TextFieldHandle {
2390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2391        f.debug_struct("TextFieldHandle")
2392            .field("live", &self.slot.borrow().is_some())
2393            .field("focused", &self.focus_signal.get())
2394            .finish()
2395    }
2396}
2397
2398impl TextFieldHandle {
2399    /// A handle not yet attached to any field — for a composing widget that
2400    /// hands one out before building the field it will delegate to. Every
2401    /// method answers "nothing" until [`TextInputField::share_handle`] binds it.
2402    pub fn detached() -> Self {
2403        Self {
2404            slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
2405            focus_signal: Signal::new(false),
2406        }
2407    }
2408
2409    /// `true` while this field holds the keyboard focus. Observable, so a
2410    /// router can follow the caret without polling.
2411    pub fn focused_signal(&self) -> Signal<bool> {
2412        self.focus_signal.clone()
2413    }
2414
2415    /// Is the widget built and still alive?
2416    pub fn is_live(&self) -> bool {
2417        self.slot.borrow().is_some()
2418    }
2419
2420    fn with<R>(&self, f: impl FnOnce(&mut TextInputState) -> R) -> Option<R> {
2421        let slot = self.slot.borrow();
2422        let state = slot.as_ref()?;
2423        let mut st = state.borrow_mut();
2424        Some(f(&mut st))
2425    }
2426
2427    /// The field's current text.
2428    pub fn text(&self) -> String {
2429        self.with(|st| st.document.to_plain_text().unwrap_or_default())
2430            .unwrap_or_default()
2431    }
2432
2433    /// Is any text selected right now?
2434    pub fn has_selection(&self) -> bool {
2435        self.with(|st| st.cursor.has_selection()).unwrap_or(false)
2436    }
2437
2438    /// May this field's content be copied at all? A password field says no —
2439    /// see [`TextInputField::allow_copy`].
2440    pub fn allows_copy(&self) -> bool {
2441        self.with(|st| st.allow_copy).unwrap_or(false)
2442    }
2443
2444    /// Is the field refusing edits? Cut and Paste are meaningless when it is.
2445    pub fn is_read_only(&self) -> bool {
2446        self.with(|st| st.read_only).unwrap_or(true)
2447    }
2448
2449    /// Select the whole field.
2450    pub fn select_all(&self) {
2451        self.with(|st| st.cursor.select(SelectionType::Document));
2452    }
2453
2454    /// Copy the selection to the clipboard.
2455    pub fn copy(&self, ctx: &EventContext) {
2456        self.with(|st| keyboard::clipboard_copy(st, ctx));
2457    }
2458
2459    /// Cut the selection to the clipboard.
2460    pub fn cut(&self, ctx: &EventContext) {
2461        self.with(|st| keyboard::clipboard_cut(st, ctx));
2462    }
2463
2464    /// Paste over the selection.
2465    pub fn paste(&self, ctx: &EventContext) {
2466        self.with(|st| keyboard::clipboard_paste(st, ctx));
2467    }
2468
2469    /// Undo this field's own last edit.
2470    pub fn undo(&self) {
2471        self.with(|st| {
2472            let _ = st.document.undo();
2473        });
2474    }
2475
2476    /// Redo this field's own last undone edit.
2477    pub fn redo(&self) {
2478        self.with(|st| {
2479            let _ = st.document.redo();
2480        });
2481    }
2482
2483    /// Is there anything to undo? Debounced like the editor's twin.
2484    pub fn can_undo(&self) -> Signal<bool> {
2485        self.with(|st| st.can_undo.clone())
2486            .unwrap_or_else(|| Signal::new(false))
2487    }
2488
2489    /// Is there anything to redo?
2490    pub fn can_redo(&self) -> Signal<bool> {
2491        self.with(|st| st.can_redo.clone())
2492            .unwrap_or_else(|| Signal::new(false))
2493    }
2494}
2495
2496// ── The framework's uniform view of a text-editing widget ────────────────────
2497
2498impl teksilo_core::text_surface::TextSurface for TextFieldHandle {
2499    fn can_undo(&self) -> bool {
2500        TextFieldHandle::can_undo(self).get()
2501    }
2502
2503    fn can_redo(&self) -> bool {
2504        TextFieldHandle::can_redo(self).get()
2505    }
2506
2507    fn undo(&self) {
2508        TextFieldHandle::undo(self);
2509    }
2510
2511    fn redo(&self) {
2512        TextFieldHandle::redo(self);
2513    }
2514
2515    fn has_selection(&self) -> bool {
2516        TextFieldHandle::has_selection(self)
2517    }
2518
2519    fn is_read_only(&self) -> bool {
2520        TextFieldHandle::is_read_only(self)
2521    }
2522
2523    fn allows_copy(&self) -> bool {
2524        TextFieldHandle::allows_copy(self)
2525    }
2526
2527    fn cut(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
2528        TextFieldHandle::cut(self, ctx);
2529    }
2530
2531    fn copy(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
2532        TextFieldHandle::copy(self, ctx);
2533    }
2534
2535    fn paste(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
2536        TextFieldHandle::paste(self, ctx);
2537    }
2538
2539    /// A one-line field carries no formatting to strip, so the plain paste
2540    /// *is* the paste. Answering "nothing" here would make Edit ▸ Paste without
2541    /// formatting silently dead over a rename box.
2542    fn paste_plain(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
2543        TextFieldHandle::paste(self, ctx);
2544    }
2545
2546    fn select_all(&self) {
2547        TextFieldHandle::select_all(self);
2548    }
2549}