Skip to main content

teksilo_widgets/primitives/text_input_field/
widget_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementation for [`TextInputField`]: build (the
5//! text engine, pointer and keyboard handlers, focus, IME and the context
6//! menu), layout, placement, paint and accessibility.
7
8use super::*;
9impl Widget for TextInputField {
10    fn as_any(&self) -> Option<&dyn std::any::Any> {
11        Some(self)
12    }
13
14    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
15        // Tell the framework this widget edits text.
16        //
17        // What it buys: an application running Undo or Copy from a menu or a
18        // toolbar — an intent whose walk never passes through this widget —
19        // can tell that the caret is *here*, and drive this surface rather
20        // than its own history. Without it, such an application silently
21        // breaks every text widget it does not personally know about. See
22        // `teksilo_core::text_surface`.
23        ctx.register_text_surface(std::rc::Rc::new(self.handle()));
24        // Resolve the interaction signal (external override wins).
25        if let Some(signal) = self.external_interaction.take() {
26            self.interaction = signal;
27        }
28
29        // Resolve the mask placeholder character. Caller override wins;
30        // otherwise pull from the recipe constant. The theme snapshot
31        // is still captured for downstream typography reads below.
32        let theme_snapshot = ctx.theme_signal().get();
33        let mask_placeholder_char = self
34            .mask_placeholder_override
35            .unwrap_or(crate::styles::recipe_text_input_style::TEXT_FIELD_MASK_PLACEHOLDER_CHAR);
36
37        // Auto-derive placeholder from mask when none was explicitly
38        // set: an empty masked field paints `__/__/____` rather than
39        // a blank surface, giving the user a self-documenting template.
40        if self.placeholder.is_empty()
41            && let Some(ref m) = self.mask
42        {
43            self.placeholder = m.empty_template(mask_placeholder_char);
44        }
45
46        // Cache mask-aware natural width. When a mask is set, the
47        // visual content envelope is the FILLED template — every
48        // editable position holding its widest plausible glyph
49        // (`0` for digits, `M` for letters, etc.) and every fixed
50        // position holding its literal. Measuring the empty
51        // (`__/__/____`) template instead would shortchange the
52        // field by the difference between an underscore and a real
53        // glyph: ~2 dp per digit slot for `0`, ~5 dp per letter
54        // slot for `M`, which adds up to a multi-character shortfall
55        // for date / 12h time fields. We want the natural width to
56        // hold the fully-typed value without overflow.
57        //
58        // Without a mask the 200 dp fallback (set in `new()`) stays.
59        if let Some(ref m) = self.mask {
60            // Measure the worst-case glyph row PLUS one extra `M` of
61            // safety: one for caret breathing room past the last
62            // position, plus a defensive cushion for any per-glyph
63            // measurement variance between our heuristic fallback
64            // and the real glyph shaper. Without this safety char,
65            // dates were observed to clip the trailing 2 characters
66            // and 12h time fields clipped the AM/PM letters.
67            let mut widest = worst_case_template(m);
68            widest.push('M');
69            let style = match &self.text_style {
70                Some(prop) => prop.resolve(&theme_snapshot.typography),
71                None => theme_snapshot.typography.body.clone(),
72            };
73            let style = &style;
74            let measured = measure_width_px(ctx, &widest, style);
75            let slack = style.size;
76            self.natural_width = measured + slack;
77        }
78
79        // Compose the user's char_filter with the mask's class filter.
80        // The mask doesn't know the cursor position here (this is a
81        // pre-position filter), so it accepts any char that fits *any*
82        // editable position class — a permissive gate that catches
83        // gross mismatches (typing "a" into a digits-only mask) without
84        // requiring per-keystroke position tracking. Per-position
85        // gating happens at commit time via the validator.
86        if let Some(ref mask) = self.mask {
87            let mask_for_filter = mask.clone();
88            let user_filter = self.char_filter.take();
89            let combined: CharFilter = Rc::new(move |c: char| {
90                // Always allow fixed-separator characters (they're
91                // legitimate input even if user types them — the
92                // formatter consumes them).
93                let in_mask_class = mask_for_filter.positions().any(|p| match p {
94                    MaskPosition::Editable { class, .. } => class.accepts(c),
95                    MaskPosition::Fixed(sep) => *sep == c,
96                });
97                if !in_mask_class {
98                    return false;
99                }
100                match user_filter.as_ref() {
101                    Some(f) => f(c),
102                    None => true,
103                }
104            });
105            self.char_filter = Some(combined);
106        }
107
108        // Build the shared state from the configured builder values.
109        let mut on_submit = self.on_submit.take().map(Rc::new);
110        let mut on_blur = self.on_blur.take().map(Rc::new);
111
112        // Wrap commit callbacks with the validator pipeline. The
113        // wrapping closure: snapshots the bound text, runs the
114        // validator, applies the outcome (writes feedback, mutates
115        // text on `Corrected`), then chains the user's callback so
116        // composites can react to the now-updated state.
117        if let Some(validator) = self.validator.clone() {
118            let bound_text = self.text.clone();
119            let feedback = self.feedback.clone();
120            let prev_on_blur = on_blur.take();
121            on_blur = Some(Rc::new(Box::new({
122                let validator = validator.clone();
123                let feedback = feedback.clone();
124                let bound_text = bound_text.clone();
125                move |evt_ctx: &mut EventContext| {
126                    run_validator_and_apply(&validator, &bound_text, &feedback);
127                    if let Some(cb) = prev_on_blur.as_ref() {
128                        cb(evt_ctx);
129                    }
130                }
131            }) as CommandFactory));
132            let prev_on_submit = on_submit.take();
133            on_submit = Some(Rc::new(Box::new({
134                let validator = validator.clone();
135                let feedback = feedback.clone();
136                let bound_text = bound_text.clone();
137                move |evt_ctx: &mut EventContext| {
138                    run_validator_and_apply(&validator, &bound_text, &feedback);
139                    if let Some(cb) = prev_on_submit.as_ref() {
140                        cb(evt_ctx);
141                    }
142                }
143            }) as CommandFactory));
144        }
145
146        let initial_text = self.text.get();
147        // `read_only_effective` snapshots the build-time state so the
148        // shared TextInputState's read-only mode is set once. Disabled
149        // is now arena-driven and propagates per-paint via
150        // `effective_enabled`; the framework gates pointer events on
151        // `arena.is_enabled` before they reach this field's handlers. The
152        // shared state's read_only stays a separate, document-level
153        // concept (allows selection / no edits).
154        let read_only_effective = self.read_only || !self.enabled.get();
155
156        let initial_suffix = self.suffix.get();
157        let shared_state = TextInputState::new(TextInputConfig {
158            initial_text,
159            max_length: self.max_length,
160            read_only: read_only_effective,
161            on_submit,
162            on_access_set_value: self.on_access_set_value.clone(),
163            on_blur,
164            char_filter: self.char_filter.take(),
165            placeholder: self.placeholder.clone(),
166            suffix: initial_suffix,
167            secure: self.secure,
168            echo_mode: self.echo_mode,
169            echo_char: self.echo_char,
170            revealed: self.revealed.clone(),
171            at_reveal_policy: self.at_reveal_policy,
172            allow_copy: self.allow_copy,
173            focus_signal: self.focus_signal.clone(),
174        });
175        self.state = Some(shared_state.clone());
176        {
177            let mut st = shared_state.borrow_mut();
178            st.text_alignment = self.text_alignment;
179            st.text_style = self
180                .text_style
181                .as_ref()
182                .map(|prop| prop.resolve(&theme_snapshot.typography));
183        }
184        // A role follows the theme: a theme that ships another size or family
185        // for it re-shapes the text. The theme change relayouts every node, so
186        // the field's height follows too.
187        if let Some(prop @ TextStyleProp::Role(_)) = self.text_style.clone() {
188            let state = shared_state.clone();
189            let theme_signal = ctx.theme_signal();
190            ctx.effect(&theme_signal, move |theme| {
191                let resolved = prop.resolve(&theme.typography);
192                let mut st = state.borrow_mut();
193                if st.text_style.as_ref() != Some(&resolved) {
194                    st.text_style = Some(resolved);
195                    st.needs_full_layout = true;
196                    // Re-derived from the new size at the next paint.
197                    st.applied_font_scale = f32::NAN;
198                    if !st.suffix.is_empty() {
199                        st.relayout_suffix_engine();
200                    }
201                }
202            });
203        }
204        // Late-populate the slot so `caret_setter()` closures captured
205        // before build can now reach the inner state. Idempotent on
206        // rebuild — overwrites the slot with the freshly created
207        // SharedState.
208        *self.state_slot.borrow_mut() = Some(shared_state.clone());
209
210        // Reset feedback to Pristine whenever the user types — prior
211        // Invalid / Corrected announcements should clear as soon as
212        // the user starts editing again so they don't shout stale
213        // errors at someone trying to fix them.
214        {
215            let feedback = self.feedback.clone();
216            ctx.effect(&self.text, move |_| {
217                if !matches!(feedback.get(), ValidationFeedback::Pristine) {
218                    feedback.set(ValidationFeedback::Pristine);
219                }
220            });
221        }
222
223        // Mirror the inner state's `cursor_position` onto the field's
224        // public `caret_position` so callers of `caret_position()` see
225        // live caret updates. The state's signal is keyed by the
226        // shared state's identity (created in `TextInputState::new`),
227        // not by the field's; this effect bridges the two.
228        {
229            let inner = shared_state.borrow().cursor_position.clone();
230            let outer = self.caret_position.clone();
231            outer.set(inner.get());
232            ctx.effect(&inner, move |pos| {
233                if outer.get() != *pos {
234                    outer.set(*pos);
235                }
236            });
237        }
238
239        // Bind feedback at AccessibilityOnly so the field's AT node
240        // refreshes its `set_invalid` state when feedback changes.
241        {
242            let self_id = ctx.self_id();
243            self.feedback.bind_to(
244                self_id,
245                ctx.binding_registry(),
246                teksilo_core::binding::BindingLevel::AccessibilityOnly,
247            );
248        }
249
250        // Combobox wiring: a moved highlight in the list this field drives must
251        // re-walk the AT tree so the new `active_descendant` is announced.
252        // AccessibilityOnly — nothing about this field's own pixels changed.
253        for sig in [self.active_descendant.as_ref(), self.controls.as_ref()]
254            .into_iter()
255            .flatten()
256        {
257            sig.bind_to(
258                ctx.self_id(),
259                ctx.binding_registry(),
260                teksilo_core::binding::BindingLevel::AccessibilityOnly,
261            );
262        }
263
264        // Secure fields: flipping the reveal toggle must repaint AND
265        // refresh AT. `RepaintOnly` dirties this node for the render
266        // walker so `paint()` runs and re-lays-out the masked/unmasked
267        // glyphs via the `needs_full_layout` flag the effect below sets
268        // — without it the flag is set but nothing calls `paint()`, so
269        // the visual only updates on the next unrelated repaint
270        // (hover / focus). This mirrors how `text_signal` is bound for
271        // edits. The parallel `AccessibilityOnly` bind swaps the AT
272        // role/value (PasswordInput ↔ TextInput under SwapRole); it lives
273        // in its own bucket and does not imply repaint, so both are
274        // required.
275        if self.secure
276            && let Some(revealed) = self.revealed.clone()
277        {
278            let id = ctx.self_id();
279            let reg = ctx.binding_registry();
280            revealed.bind_to(id, reg, teksilo_core::binding::BindingLevel::RepaintOnly);
281            revealed.bind_to(
282                id,
283                reg,
284                teksilo_core::binding::BindingLevel::AccessibilityOnly,
285            );
286            // The mask a hiding walk withheld (see `AtPublished`) is
287            // published by the walk this bump asks for.
288            shared_state.borrow().at_republish.bind_to(
289                id,
290                reg,
291                teksilo_core::binding::BindingLevel::AccessibilityOnly,
292            );
293        }
294
295        let text_signal = shared_state.borrow().text_signal.clone();
296
297        // Sync external text signal → internal state. A programmatic
298        // update on the bound signal rewrites the document; the
299        // caret ends up at the end of the inserted text (cursor
300        // behavior is documented in
301        // `text_document::TextCursor::insert_text`).
302        //
303        // `insert_text` only enqueues a `ContentsChanged` document
304        // event — `tick()` drains it on the next frame and propagates
305        // the new text to `text_signal`. Frames are demand-driven, so
306        // we ping `frame_request` here to guarantee a tick runs even
307        // when the external writer (e.g. an HSV-canvas drag feeding a
308        // spinner / hex bridge) is the only thing changing on screen.
309        // Without it, the document stays in sync with the bound signal
310        // but the visible glyphs lag until something else (focus, a
311        // keystroke, an animation frame) wakes the loop.
312        {
313            let ext = self.text.clone();
314            let state_for_sync = shared_state.clone();
315            let touch_for_sync = self.touch.clone();
316            ctx.effect(&ext, move |new_text| {
317                let st = state_for_sync.borrow();
318                let current = st.document.to_plain_text().unwrap_or_default();
319                if current != *new_text {
320                    st.cursor.select(SelectionType::Document);
321                    let _ = st.cursor.insert_text(new_text);
322                    if let Some(handle) = &st.frame_request {
323                        handle.set(true);
324                    }
325                    drop(st);
326                    // The content the affordances marked is gone. This effect has
327                    // no `EventContext`, which is why retirement is the
328                    // controller publishing empty geometry rather than an overlay
329                    // dismissal — see `FieldTouch::dismiss`.
330                    touch_for_sync.dismiss();
331                }
332            });
333        }
334
335        // Sync internal text signal → external. Every edit that
336        // reaches `text_signal` also updates the caller-owned
337        // signal, so observers bound to it see every keystroke
338        // (after the debounce in `tick`).
339        {
340            let ext = self.text.clone();
341            ctx.effect(&text_signal, move |new_text| {
342                if ext.get() != *new_text {
343                    ext.set(new_text.clone());
344                }
345            });
346        }
347
348        // Secure reveal toggle: flipping the bound `revealed` signal
349        // swaps the laid-out glyphs wholesale (bullets ↔ plaintext), so
350        // mark the layout dirty and ping the frame loop to re-lay-out.
351        if self.secure
352            && let Some(revealed) = self.revealed.clone()
353        {
354            let state_for_reveal = shared_state.clone();
355            ctx.effect(&revealed, move |_| {
356                let mut st = state_for_reveal.borrow_mut();
357                st.needs_full_layout = true;
358                if let Some(handle) = &st.frame_request {
359                    handle.set(true);
360                }
361            });
362        }
363
364        // Swap the private engine for one sharing the app's
365        // `SharedTypesetter` so glyphs land in the atlas
366        // teksilo-render uploads to the GPU. When no typesetter is
367        // installed (headless tests), the pre-built private
368        // engine stays in place.
369        if let Some(shared) = ctx.app_state::<SharedTypesetter>() {
370            let mut st = self.state().borrow_mut();
371            let mut engine = RichTextEngine::from_shared(shared.clone());
372            engine.set_wrap_mode(teksilo_text::WrapMode::None);
373            st.engine = engine;
374            st.needs_full_layout = true;
375            // A text style's size is honoured as a scale from the size the
376            // shared service shapes at by default.
377            st.engine_base_size = shared
378                .bridge()
379                .borrow()
380                .service()
381                .font_registry()
382                .default_size_px();
383        }
384
385        // Apply theme colors to the (possibly freshly swapped-in) engine.
386        // Setting them before the swap would be lost. The rich-text
387        // engine stores colors in GPU-ready form, so we register an
388        // effect on the theme signal that re-applies the palette on
389        // every theme switch instead of capturing a single snapshot.
390        //
391        // The text / caret / suffix *foreground* colours are deliberately
392        // NOT set here — `paint` owns them, because they depend on the
393        // effective enabled state as well as the theme (see the resolve
394        // block there). Selection is theme + window-active only, so it
395        // stays on this effect path.
396        let theme_signal = ctx.theme_signal();
397        // The selection colour is also window-active-aware. Rather than one
398        // effect on a derived `theme.zip(window_active)`, the theme effect
399        // reads the live window-active value via `.get()`, and the separate
400        // window-active effect (below, near the frame handles) re-applies the
401        // selection colour reading the live theme. Between them, a change to
402        // either axis re-applies correctly.
403        {
404            let theme = theme_signal.get();
405            let colors = &theme.colors;
406            let mut st = self.state().borrow_mut();
407            let tint = field_selection_color(colors, ctx.window_active(), st.has_focus);
408            st.selection_tint = tint;
409            st.engine.set_selection_color(tint);
410        }
411        {
412            let state = self.state().clone();
413            let wa_signal = ctx.window_active_signal();
414            ctx.effect(&theme_signal, move |theme| {
415                let colors = &theme.colors;
416                let mut st = state.borrow_mut();
417                let tint = field_selection_color(colors, wa_signal.get(), st.has_focus);
418                st.selection_tint = tint;
419                st.engine.set_selection_color(tint);
420            });
421        }
422
423        // Suffix engine: second independent `RichTextEngine` used
424        // to paint the non-editable trailing string (Qt's
425        // `QSpinBox` `suffix`). Shares the app's typesetter when
426        // available so glyphs land in the same atlas as the main
427        // document; falls back to a private engine under headless
428        // tests.
429        //
430        // `suffix_width` is cached on `TextInputState` and drives
431        // both the effective text viewport (so the scroll logic
432        // keeps the caret visible without sliding text behind the
433        // suffix) and the suffix paint origin at the right edge
434        // of the field. When the suffix is bound to a signal, a
435        // reactive effect below re-lays the engine out each time
436        // the signal fires.
437        let text_area_height = self.base_text_height().max(1.0);
438        let needs_suffix_engine = matches!(self.suffix, Prop::Bound(_)) || {
439            let st = self.state().borrow();
440            !st.suffix.is_empty()
441        };
442        if needs_suffix_engine {
443            let mut suffix_engine = if let Some(shared) = ctx.app_state::<SharedTypesetter>() {
444                RichTextEngine::from_shared(shared.clone())
445            } else {
446                state::private_engine()
447            };
448            suffix_engine.set_wrap_mode(teksilo_text::WrapMode::None);
449            {
450                let theme = theme_signal.get();
451                let secondary = theme.colors.text_secondary.to_array();
452                suffix_engine.set_text_color(secondary);
453                suffix_engine.set_cursor_color(secondary);
454                suffix_engine.set_selection_color([0.0, 0.0, 0.0, 0.0]);
455            }
456            suffix_engine.set_viewport(10_000.0, text_area_height);
457
458            {
459                let mut st = self.state().borrow_mut();
460                st.suffix_engine = Some(suffix_engine);
461            }
462            // Initial layout from the current suffix value.
463            let initial = self.state().borrow().suffix.clone();
464            relayout_suffix(self.state(), &initial);
465        }
466
467        // Reactive suffix: observe the signal and re-lay out on
468        // every change. `Relayout` dirty-tracking ensures the
469        // surrounding layout sees the new `suffix_width` and the
470        // text viewport narrows/widens accordingly.
471        if let Prop::Bound(signal) = &self.suffix {
472            let self_id = ctx.self_id();
473            signal.bind_to(
474                self_id,
475                ctx.binding_registry(),
476                teksilo_core::binding::BindingLevel::Relayout,
477            );
478            let state_for_effect = self.state().clone();
479            ctx.effect(signal, move |new_text| {
480                relayout_suffix(&state_for_effect, new_text);
481            });
482        }
483
484        // A colour bound to a signal — a focus-driven role, say — repaints the
485        // field when it changes. A role follows the theme without a binding:
486        // a theme change already repaints every node.
487        if let Some(color) = &self.text_color {
488            color.register_if_bound(
489                ctx.self_id(),
490                ctx.binding_registry(),
491                teksilo_core::binding::BindingLevel::RepaintOnly,
492            );
493        }
494
495        // Bind caret_visible for repaint.
496        {
497            let st = self.state().borrow();
498            let caret_visible = st.caret_visible.clone();
499            drop(st);
500            let self_id = ctx.self_id();
501            caret_visible.bind_to(
502                self_id,
503                ctx.binding_registry(),
504                teksilo_core::binding::BindingLevel::RepaintOnly,
505            );
506        }
507
508        // Bind text_signal at RepaintOnly AND AccessibilityOnly.
509        //
510        // RepaintOnly: when the text changes by any route — local
511        // typing, IME, clipboard paste, the ext→internal sync
512        // effect firing because a composite parent (SpinBox etc.)
513        // drove the bound signal — the field must redraw. During
514        // typing the caret-blink signal already keeps the widget
515        // repainting, which used to mask a missing repaint trigger
516        // on programmatic text changes to an unfocused field. With
517        // the explicit bind, no path depends on blink.
518        //
519        // AccessibilityOnly: screen readers see edits as soon as
520        // the text signal updates, independent of whether a paint
521        // happens this frame.
522        {
523            let st = self.state().borrow();
524            let text_signal = st.text_signal.clone();
525            drop(st);
526            let self_id = ctx.self_id();
527            let registry = ctx.binding_registry();
528            text_signal.bind_to(
529                self_id,
530                registry,
531                teksilo_core::binding::BindingLevel::RepaintOnly,
532            );
533            text_signal.bind_to(
534                self_id,
535                registry,
536                teksilo_core::binding::BindingLevel::AccessibilityOnly,
537            );
538        }
539
540        // Bind the caret and the selection anchor at AccessibilityOnly.
541        //
542        // A caret-only move (an arrow, Home, End, Shift+arrow, Ctrl+A, a click)
543        // edits nothing, so `text_signal` stays put; without these bindings
544        // the node was never re-walked and every reader was told the caret
545        // and selection of the last edit. `has_selection` is derived from the
546        // two and needs no binding of its own. Same pairing as
547        // `RichTextEditorBody`.
548        {
549            let st = self.state().borrow();
550            let signals = [st.cursor_position.clone(), st.cursor_anchor.clone()];
551            drop(st);
552            for signal in &signals {
553                signal.bind_to(
554                    ctx.self_id(),
555                    ctx.binding_registry(),
556                    teksilo_core::binding::BindingLevel::AccessibilityOnly,
557                );
558            }
559        }
560
561        // Stash frame infrastructure handles and self_id.
562        {
563            let mut st = self.state().borrow_mut();
564            st.frame_request = Some(ctx.frame_request_handle());
565            st.frame_wake_at = Some(ctx.wake_at_handle());
566            st.field_widget_id = Some(ctx.self_id());
567        }
568
569        self.mount_touch_selection(ctx);
570
571        // Same dormancy discipline as `RichTextEditor`: a field parked in a
572        // non-selected `Switcher` / `visible_when(false)` branch must not
573        // keep the event loop awake (caret `wake_at`, frame-tick work,
574        // window-active re-arm). See that widget's build for the full story.
575        let activation = ctx.activation_signal(ctx.self_id());
576        if activation.get() {
577            ctx.request_frame();
578        }
579
580        {
581            let state = self.state().clone();
582            let interaction = self.interaction.clone();
583            ctx.effect(&activation, move |&active| {
584                if active {
585                    // **Re-activated** — re-arm the frame loop. The dormant branch
586                    // below does not re-arm `frame_request` (a parked surface has
587                    // nothing to paint) and the frame-tick effect is skipped
588                    // entirely while dormant, so nothing restarts the tick on the
589                    // way back. Same defect and same fix as `RichTextEditor` /
590                    // `CodeEditor`: the in-tree modal path builds content, parks it
591                    // dormant, mounts it, activates it and *then* moves focus in
592                    // (`present_in_tree_modal_request`), so without this a field in
593                    // a dialog draws no caret at all.
594                    let st = state.borrow();
595                    if let Some(handle) = &st.frame_request {
596                        handle.set(true);
597                    }
598                    return;
599                }
600                let mut st = state.borrow_mut();
601                if st.has_focus {
602                    st.has_focus = false;
603                    st.focus_signal.set(false);
604                    // Mirror the on_focus(false) interaction write so a
605                    // Focused chrome style doesn't stick on a parked field.
606                    interaction.set(InteractionState::Idle);
607                }
608                if st.caret_visible.get() {
609                    st.caret_visible.set(false);
610                }
611                st.blink.reset();
612            });
613        }
614
615        // Frame-tick effect: flushes pending chars, drains document
616        // events, drives the caret blink, and debounces undo/redo
617        // state changes.
618        //
619        // IMPORTANT: the mutable borrow must be dropped BEFORE
620        // setting `text_signal`. Setting it fires observers
621        // synchronously, which chain into the ext→internal sync
622        // effect that borrows the same state. Holding `borrow_mut`
623        // across `signal.set()` would panic.
624        {
625            let state = self.state().clone();
626            let active = activation.clone();
627            let tick_signal = ctx.frame_tick();
628            ctx.effect(&tick_signal, move |delta| {
629                if !active.get() {
630                    return;
631                }
632                let (more, pending_text) = {
633                    let mut st = state.borrow_mut();
634                    let more = tick(&mut st, *delta);
635                    let pending = st.deferred_text_update.take();
636                    (more, pending)
637                };
638                if let Some(text) = pending_text {
639                    let st = state.borrow();
640                    if st.text_signal.get() != text {
641                        st.text_signal.set(text);
642                    }
643                }
644                // An edit applied by `tick` (typed characters, a paste, a
645                // programmatic rewrite, undo) moves the caret without passing
646                // through a key handler's `sync_cursor_signals`. Publish it
647                // here, or the signals keep the pre-edit caret, and the next
648                // arrow key that lands on that stale value changes no signal
649                // and reaches no reader.
650                {
651                    let st = state.borrow();
652                    publish_cursor_signals(&st);
653                    // The walk that revealed or hid a secure field published
654                    // no runs; ask for the one that publishes its new text.
655                    if st.at_published.get() == AtPublished::Withheld {
656                        st.at_republish.set(st.at_republish.get().wrapping_add(1));
657                    }
658                }
659                if more {
660                    let st = state.borrow();
661                    if let Some(handle) = &st.frame_request {
662                        handle.set(true);
663                    }
664                }
665            });
666        }
667
668        // Window-active effect — mirror the tree's window-active state onto the
669        // field state so the frame loop (no context) can gate the caret, and
670        // re-apply the window-aware selection colour (reading the live theme,
671        // since `ctx.effect` can't observe a derived theme×active signal). The
672        // loop may not tick while the window is inactive (animation scheduler
673        // parked), so on deactivation hide the caret synchronously here and
674        // request a frame so it reaches a paint pass — only while this field
675        // is itself active (a dormant field must not re-arm the loop).
676        {
677            let state = self.state().clone();
678            let active = activation.clone();
679            let wa_signal = ctx.window_active_signal();
680            let theme_for_sel = theme_signal.clone();
681            let touch_for_window = self.touch.clone();
682            ctx.effect(&wa_signal, move |&window_active| {
683                let mut st = state.borrow_mut();
684                st.window_active = window_active;
685                let theme = theme_for_sel.get();
686                let tint = field_selection_color(&theme.colors, window_active, st.has_focus);
687                st.selection_tint = tint;
688                st.engine.set_selection_color(tint);
689                if window_active {
690                    // Reactivated: show the caret immediately if still focused
691                    // (restart the blink phase), rather than waiting one interval.
692                    if st.has_focus && !st.caret_visible.get() {
693                        st.caret_visible.set(true);
694                    }
695                    st.blink.reset();
696                } else {
697                    // Deactivated: hide the caret synchronously (the frame loop
698                    // may not tick while the window is inactive).
699                    if st.caret_visible.get() {
700                        st.caret_visible.set(false);
701                    }
702                    st.blink.reset();
703                    // …and retire the touch affordances with it. Handles over an
704                    // inactive window's text are as wrong as a caret in it.
705                    touch_for_window.dismiss();
706                }
707                if active.get()
708                    && let Some(handle) = &st.frame_request
709                {
710                    handle.set(true);
711                }
712            });
713        }
714
715        // Forward the enabled state into the arena. Disabled state no
716        // longer seeded into the interaction signal — the framework's
717        // arena enabled-state is the single source of truth (events
718        // gated, leaves resolve Disabled role).
719        let self_id = ctx.self_id();
720        ctx.enabled_when(self_id, self.enabled.clone());
721
722        let state_for_focus = self.state().clone();
723        let touch_for_focus = self.touch.clone();
724        let interaction_for_focus = self.interaction.clone();
725        // The selection band's tint depends on focus, so the focus handler has
726        // to re-apply it — and needs the live theme to do so.
727        let theme_for_focus = theme_signal.clone();
728        let state_for_pointer = self.state().clone();
729        let state_for_hold = self.state().clone();
730        let touch_for_pointer = self.touch.clone();
731        let touch_for_hold = self.touch.clone();
732        let state_for_key = self.state().clone();
733        let touch_for_key = self.touch.clone();
734        let state_for_double = self.state().clone();
735        let state_for_triple = self.state().clone();
736        let touch_for_double = self.touch.clone();
737        let touch_for_triple = self.touch.clone();
738        let state_for_access = self.state().clone();
739        let touch_for_access = self.touch.clone();
740        let state_for_menu = self.state().clone();
741
742        let handlers = HandlerSet::new()
743            .focusable(true)
744            .cursor(CursorIcon::Text)
745            // Secure fields declare `ImePurpose::Password` so the platform
746            // suppresses the IME's learning dictionary / candidate history —
747            // the OS input method stays enabled (a password must still be
748            // composable in a non-Latin script), and masking the preedit is
749            // this widget's own job. Read by the platform IME layer at
750            // focus-change time; an unset descriptor (the node default) is
751            // what means "no OS IME".
752            .ime_input(if self.secure {
753                teksilo_core::ime::ImeContext::password()
754            } else {
755                teksilo_core::ime::ImeContext::text()
756            })
757            .on_focus(move |gained, ctx| {
758                interaction_for_focus.set(if gained {
759                    InteractionState::Focused
760                } else {
761                    InteractionState::Idle
762                });
763
764                let mut st = state_for_focus.borrow_mut();
765                st.has_focus = gained;
766                st.focus_signal.set(gained);
767                // Re-tint the selection band: `has_focus` is half of what
768                // decides it, so losing focus inside an active window has to
769                // re-apply just as losing the window does.
770                let sel_theme = theme_for_focus.get();
771                let tint = field_selection_color(&sel_theme.colors, st.window_active, gained);
772                st.selection_tint = tint;
773                st.engine.set_selection_color(tint);
774                // RevealWhileTyping shows plaintext while focused and
775                // re-masks on blur — both transitions need a relayout.
776                if st.secure && st.echo_mode == EchoMode::RevealWhileTyping {
777                    st.needs_full_layout = true;
778                }
779                let mut blur_callback: Option<Rc<CommandFactory>> = None;
780                if gained {
781                    st.blink.restart();
782                    st.caret_visible.set(true);
783                    drop(st);
784                    // What a focus gain does to the text depends on how it
785                    // arrived, and only one arrival selects it all: Tab, which
786                    // is how a reader moves *to a field*, to replace what is
787                    // in it or to read it whole (Qt's `QLineEdit` does the
788                    // same on `TabFocusReason`). A click places the caret
789                    // itself, where it lands. A programmatic focus (a
790                    // dialog opening, a menu or popover closing and handing
791                    // focus back, an application's `request_focus`) and an
792                    // assistive technology's leave the text as it is: a
793                    // field that selected all there made the next key wipe
794                    // what the reader came back to. A caller that wants the
795                    // text selected on its own focus says so with
796                    // `TextFieldHandle::select_all`.
797                    match ctx.focus_origin() {
798                        Some(teksilo_core::FocusOrigin::Keyboard) => {
799                            let st = state_for_focus.borrow();
800                            st.cursor.select(SelectionType::Document);
801                            drop(st);
802                            sync_cursor_signals(&state_for_focus);
803                        }
804                        Some(teksilo_core::FocusOrigin::Restored) => {
805                            // A rebuild replaced the field that had focus, and
806                            // this one sits where it was. Its predecessor's
807                            // caret died with it; the end is where typing
808                            // carries on.
809                            let st = state_for_focus.borrow();
810                            st.cursor.move_position(
811                                teksilo_text::text_document::MoveOperation::End,
812                                teksilo_text::text_document::MoveMode::MoveAnchor,
813                                1,
814                            );
815                            drop(st);
816                            sync_cursor_signals(&state_for_focus);
817                        }
818                        _ => {}
819                    }
820                    // Seed the OS IME candidate area at the caret so the
821                    // first composition appears in the right place.
822                    keyboard::report_ime_cursor_area(&state_for_focus, ctx);
823                } else {
824                    // Preserve `cursor`'s selection across focus loss
825                    // — clearing it here breaks the right-click
826                    // context menu path (the framework focuses the
827                    // newly-mounted menu, which dispatches `FocusLost`
828                    // here, and `Cut` / `Copy` invoked from the menu
829                    // afterwards find an empty selection). A Win32
830                    // edit control does the same: the default (no
831                    // `ES_NOHIDESEL`) hides the highlight on blur but
832                    // `EM_GETSEL` still returns the range, so the
833                    // menu invoked afterwards still has something to
834                    // act on. The *painting* is the part that stops —
835                    // see `field_selection_color`, which returns a
836                    // transparent band for an unfocused field.
837                    st.scroll_x = 0.0;
838                    st.caret_visible.set(false);
839                    st.drag_state = state::DragState::Idle;
840                    // Drop the IME-area dedup cache. The OS candidate area is a
841                    // single per-window resource a sibling field may re-point
842                    // while we are unfocused; clearing this forces the next
843                    // focus-gain report to re-seed it instead of being deduped.
844                    st.last_ime_area = None;
845                    blur_callback = st.on_blur.clone();
846                    drop(st);
847                    // The band is exempt from outside-press dismissal, so the
848                    // host owns this.
849                    //
850                    // Measured, and worth saying: deleting this line reddens
851                    // nothing, because the framework retires an overlay when
852                    // focus leaves the widget it is anchored on
853                    // (`dismiss_overlays_left_by_focus`), and the affordance
854                    // overlays are anchored on this field — so their `on_dismiss`
855                    // callbacks reach the same conclusion. That rule walks *one*
856                    // overlay's parent chain, though, and a field inside a dialog
857                    // anchors two sibling overlays rather than a cascade, which
858                    // is not a shape the host can verify from here. The call is
859                    // what makes the answer the host's own.
860                    touch_for_focus.dismiss();
861                    // Abandon any in-progress composition on blur — remove
862                    // the tentative preedit text from the document.
863                    keyboard::clear_ime_preedit(&state_for_focus);
864                    sync_cursor_signals(&state_for_focus);
865                }
866                if let Some(cb) = blur_callback {
867                    cb(ctx);
868                }
869                ctx.request_frame();
870            })
871            .on_pointer_event(move |event, ctx| {
872                mouse::handle_pointer_event(&state_for_pointer, &touch_for_pointer, event, ctx)
873            })
874            // A hold selects the word under the finger. Attaching this
875            // **withdraws** the tree-owned long-press route (`touch_route`
876            // rule 1: a widget's own `on_long_press` wins), which is what used
877            // to open this field's context menu for a coarse pointer — the
878            // selection toolbar `FieldTouch::raise` puts up is its replacement,
879            // and offers the same commands.
880            .on_long_press(move |event, ctx| {
881                mouse::handle_long_press(&state_for_hold, &touch_for_hold, event, ctx);
882            })
883            .on_key(move |event, ctx| {
884                let response = keyboard::handle_key(&state_for_key, event, ctx);
885                // A keystroke moves the caret and edits the text, neither of
886                // which the controller made — so the handles it published are
887                // pointing at where the text used to be. `refresh` is a no-op
888                // until something has been raised.
889                touch_for_key.refresh(ctx, touch::ToolbarIntent::Hide);
890                response
891            })
892            .on_double_tap(move |event, ctx| {
893                mouse::handle_double_tap(&state_for_double, event.position, ctx);
894                // A finger can double-tap too, and the selection it just made is
895                // one the controller did not make.
896                if event.pointer.kind.is_direct() {
897                    touch_for_double.raise(ctx, touch::ToolbarIntent::Show);
898                }
899            })
900            .on_triple_tap(move |event, ctx| {
901                mouse::handle_triple_tap(&state_for_triple, event.position, ctx);
902                if event.pointer.kind.is_direct() {
903                    touch_for_triple.raise(ctx, touch::ToolbarIntent::Show);
904                }
905            })
906            .on_access_action_request(move |action, _target_node, data, ctx| {
907                let response = handle_access_action(&state_for_access, action, data, ctx);
908                touch_for_access.refresh(ctx, touch::ToolbarIntent::Keep);
909                response
910            })
911            // Right-click context menu — built fresh per click so the
912            // enabled state of each item reflects the live selection /
913            // clipboard state at the moment the menu opens. The framework
914            // handles overlay placement, focus restoration, and dismissal.
915            .context_menu(move |position, ctx| {
916                // Framework gates pointer events on `arena.is_enabled`
917                // before reaching this closure — a disabled field
918                // never receives the right-click that would open the
919                // context menu.
920                // Reposition the caret to the click position when the
921                // click lands outside the existing selection — the
922                // platform convention for "right-click then Cut /
923                // Copy / Paste at the new caret". A pointer's point only:
924                // the keyboard's menu is anchored in the middle of the
925                // field, and moving the caret there sent Paste elsewhere.
926                if ctx
927                    .context_menu_trigger()
928                    .is_none_or(teksilo_core::widget_builder::ContextMenuTrigger::is_pointer)
929                {
930                    mouse::reposition_caret_for_context_menu(&state_for_menu, position);
931                }
932                Some(build_context_menu_widget(&state_for_menu))
933            });
934
935        ctx.apply_self_handlers(handlers);
936
937        // Select all, the clipboard and undo / redo come through the
938        // rebindable `edit.*` shortcuts, not as keys.
939        {
940            let state = self.state().clone();
941            let touch = self.touch.clone();
942            let surfaces = ctx.text_surfaces();
943            crate::common::standard_edit::register_standard_edit_actions(
944                ctx,
945                move |command, ctx| {
946                    let ran = keyboard::run_standard_edit(
947                        &state,
948                        command,
949                        surfaces.empty_history_passes_on(),
950                        ctx,
951                    );
952                    if ran {
953                        // As after a key: the handles the controller published
954                        // point at where the text used to be.
955                        touch.refresh(ctx, touch::ToolbarIntent::Hide);
956                    }
957                    ran
958                },
959            );
960        }
961        Vec::new()
962    }
963
964    fn layout_response(
965        &self,
966        proposal: SizeProposal,
967        ctx: &LayoutContext,
968    ) -> teksilo_core::widget::LayoutResponse {
969        // Default unwrap is the cached natural width (mask-aware when
970        // a mask is set; 200 dp fallback otherwise). Composing widgets
971        // that wrap us in a constraint pass `Some(width)` and we use
972        // that; the natural width is what surfaces in unconstrained
973        // intrinsic queries (ZStack measurement with `unspecified()`,
974        // etc.) so the chain reports a sensible content size.
975        //
976        // The cached `natural_width` / `text_height` are 1.0-scale baselines;
977        // multiply by `ctx.text_scale` so the field box grows with the global
978        // accessibility text scale (the engine grows the glyphs to match — see
979        // `paint`). A caller-supplied width constraint is honored as-is.
980        let scale = ctx.text_scale;
981        let w = proposal
982            .width
983            .unwrap_or(self.natural_width * scale)
984            .max(0.0);
985        let h = (self.base_text_height() * scale).max(0.0);
986        Size::new(w, h).into()
987    }
988
989    fn place_children(
990        &self,
991        bounds: Rect,
992        _proposal: SizeProposal,
993        _children: &mut [WidgetPlacement],
994        ctx: &LayoutContext,
995    ) {
996        // Layout runs before paint, so this is the authoritative point to adopt
997        // the field's viewport. `sync_viewport` welds the width write to the
998        // `needs_full_layout` flag it also serves as the detector for (see its
999        // docs); paint calls it again as an idempotent echo.
1000        if let Some(state) = self.state.as_ref() {
1001            state.borrow_mut().sync_viewport(bounds);
1002        }
1003
1004        if let Some(backend) = ctx.text_backend {
1005            self.retain_text_geometry(
1006                bounds,
1007                &self.measured_style(&ctx.theme.typography, ctx.text_scale),
1008                backend,
1009                base_text_direction(ctx.layout_direction),
1010            );
1011        }
1012    }
1013
1014    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1015        let Some(state) = self.state.as_ref() else {
1016            return;
1017        };
1018        let mut st = state.borrow_mut();
1019
1020        // Grow the shaped text with the global accessibility scale. Must run
1021        // before the relayout block below so the larger glyphs are shaped this
1022        // frame; no-op when the scale is unchanged.
1023        st.apply_font_scale(ctx.text_scale);
1024        // Idempotent echo — `place_children` already adopted these exact bounds
1025        // during layout, so this is normally a no-op.
1026        st.sync_viewport(bounds);
1027
1028        // Resolve the glyph / caret / suffix colours against the *effective*
1029        // enabled state, exactly as `TextWidget` and `RectWidget` resolve a
1030        // `ColorProp` at paint time. `paint` is the single writer of these:
1031        // the field shapes through a `RichTextEngine`, which takes raw GPU
1032        // colours and so never passes through `ColorProp::resolve` — the
1033        // disabled substitution that greys every role-driven leaf for free
1034        // cannot reach it. Doing it here (rather than as a build-time effect
1035        // on `effective_enabled_signal`) is also the only correct option:
1036        // that signal is *derived* whenever an ancestor binds `enabled`, and
1037        // `Signal::observe` panics on derived signals. Cheap — the engine
1038        // stores the colour and the render-frame builder reads it, so there
1039        // is no relayout and no reshaping.
1040        //
1041        // An app colour (`text_color`) is resolved the same way, every paint,
1042        // so a role follows the theme and a signal is read live. A disabled
1043        // field still greys out — a crisp custom colour on an inert field reads
1044        // as editable — unless the app wrapped the colour in
1045        // `ColorProp::undimmed`, the framework's statement that it carries
1046        // meaning a disabled control keeps.
1047        let text_color = match &self.text_color {
1048            Some(color) if ctx.effective_enabled || matches!(color, ColorProp::Undimmed(_)) => {
1049                color.resolve(ctx.theme, ctx.effective_enabled)
1050            }
1051            _ if ctx.effective_enabled => ctx.theme.colors.text_primary,
1052            _ => ctx.theme.colors.text_disabled,
1053        };
1054        st.engine.set_text_color(text_color.to_array());
1055        st.engine.set_cursor_color(text_color.to_array());
1056
1057        let suffix_width = st.suffix_width;
1058        let text_viewport_width = (bounds.width - suffix_width).max(0.0);
1059
1060        st.engine.set_viewport(10_000.0, bounds.height);
1061
1062        if st.needs_full_layout || !st.engine.has_full_layout() {
1063            st.layout_full_masked();
1064            st.needs_full_layout = false;
1065            st.content_dirty = true;
1066        }
1067
1068        // A chosen style sizes the field to `size × line_height`, which is not
1069        // the height the font's own metrics give the shaped line; centre the
1070        // line in the slack so the text sits where the placeholder does. Zero
1071        // without a style: the default field's height was tuned to its font.
1072        st.text_offset_y = if st.text_style.is_some() && st.engine.has_full_layout() {
1073            ((bounds.height - st.engine.content_height()) / 2.0).max(0.0)
1074        } else {
1075            0.0
1076        };
1077        let offset_y = st.text_offset_y;
1078
1079        // Suppress the caret in an inactive window for every paint — the
1080        // authoritative gate, covering the frame between a window-active flip
1081        // and the build-time effect running.
1082        let caret_on = st.caret_visible.get() && st.has_focus && st.window_active;
1083        // `NoEcho` while masked lays out an *empty* source, so the real
1084        // document cursor (which may sit past 0) must not be handed to
1085        // the engine — pin the displayed caret/selection to the start.
1086        // The real `cursor` still tracks the true position for editing.
1087        let hide_all = st.echo_mode == EchoMode::NoEcho && st.should_mask();
1088        let (disp_pos, disp_anchor) = if hide_all {
1089            (0, 0)
1090        } else {
1091            (st.cursor.position(), st.cursor.anchor())
1092        };
1093        // Single-line input has no wrap → affinity is moot; the
1094        // default Downstream matches pre-affinity behavior.
1095        let cursor_display = CursorDisplay {
1096            position: disp_pos,
1097            anchor: disp_anchor,
1098            affinity: CursorAffinity::Downstream,
1099            visible: caret_on,
1100            selected_cells: Vec::new(),
1101        };
1102        st.engine.set_cursor(&cursor_display);
1103
1104        ensure_caret_visible_h(
1105            &mut st,
1106            text_viewport_width,
1107            &ctx.theme.input,
1108            ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft,
1109        );
1110
1111        let scroll_x = st.scroll_x;
1112
1113        let text_clip = Rect::new(bounds.x, bounds.y, text_viewport_width, bounds.height);
1114        canvas.set_clip(text_clip);
1115
1116        {
1117            let state_ref: &mut TextInputState = &mut st;
1118            let TextInputState {
1119                ref mut engine,
1120                ref document,
1121                ref mut image_cache,
1122                ..
1123            } = *state_ref;
1124
1125            engine.with_render_frame(|frame| {
1126                paint_frame(
1127                    canvas,
1128                    PaintParams {
1129                        frame,
1130                        origin: Point::new(bounds.x - scroll_x, bounds.y + offset_y),
1131                        document,
1132                        image_cache,
1133                        // No inline images on this surface, so none can be missing.
1134                        image_resolver: None,
1135                        selection: None,
1136                        selection_color: [0.0; 4],
1137                        selected_image_out: None,
1138                        resize_preview: None,
1139                        draw_caret: caret_on,
1140                    },
1141                );
1142            });
1143        }
1144
1145        // IME preedit underline: a thin line under the composing range so
1146        // the user sees the text is tentative. Single line → one segment;
1147        // on a secure field it sits under the masked bullets. Drawn inside
1148        // the text clip so it never spills past the viewport.
1149        if let Some(range) = st.ime_preedit_range.clone()
1150            && st.engine.has_full_layout()
1151            && range.start < range.end
1152        {
1153            let start_c = st
1154                .engine
1155                .caret_rect(range.start, CursorAffinity::Downstream);
1156            let end_c = st.engine.caret_rect(range.end, CursorAffinity::Downstream);
1157            let x0 = bounds.x - scroll_x + start_c[0];
1158            let x1 = bounds.x - scroll_x + end_c[0];
1159            let y = bounds.y + offset_y + start_c[1] + start_c[3] - 1.0;
1160            canvas.draw_line(
1161                Point::new(x0, y),
1162                Point::new(x1, y),
1163                text_color,
1164                teksilo_canvas::StrokeStyle::solid(1.0),
1165            );
1166        }
1167
1168        canvas.clear_clip();
1169
1170        if suffix_width > 0.0
1171            && let Some(suffix_engine) = st.suffix_engine.as_mut()
1172        {
1173            // The suffix dims with the value it annotates — a crisp " %"
1174            // beside greyed-out digits reads as a rendering bug.
1175            let suffix_color = if ctx.effective_enabled {
1176                ctx.theme.colors.text_secondary
1177            } else {
1178                ctx.theme.colors.text_disabled
1179            };
1180            suffix_engine.set_text_color(suffix_color.to_array());
1181            let suffix_clip = Rect::new(
1182                bounds.x + text_viewport_width,
1183                bounds.y,
1184                suffix_width,
1185                bounds.height,
1186            );
1187            canvas.set_clip(suffix_clip);
1188            let suffix_origin = Point::new(bounds.x + text_viewport_width, bounds.y + offset_y);
1189            suffix_engine.with_render_frame(|frame| {
1190                paint_suffix_glyphs(canvas, frame, suffix_origin);
1191            });
1192            canvas.clear_clip();
1193        }
1194
1195        // Re-measure for the accessibility pass. `place_children` is the
1196        // authority on the width, but an edit dirties the field at
1197        // `RepaintOnly` / `AccessibilityOnly` and never relayouts — so
1198        // without this echo every keystroke would leave the runs describing
1199        // the text as it was before it. Same shape as the `sync_viewport`
1200        // echo above; the backend caches by (text, style), so a frame that
1201        // only blinks the caret pays a lookup.
1202        drop(st);
1203        if let Some(backend) = canvas.text_backend() {
1204            self.retain_text_geometry(
1205                bounds,
1206                &self.measured_style(&ctx.theme.typography, ctx.text_scale),
1207                backend,
1208                base_text_direction(ctx.layout_direction),
1209            );
1210        }
1211    }
1212
1213    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1214        use teksilo_core::accesskit::{Action, Role};
1215
1216        let Some(state) = self.state.as_ref() else {
1217            return;
1218        };
1219        let st = state.borrow();
1220
1221        let text = st.document.to_plain_text().unwrap_or_default();
1222
1223        // AT-protection tracks the *explicit* reveal toggle only — not
1224        // the visual `RevealWhileTyping` focus-reveal (a sighted-only
1225        // convenience that a screen reader shouldn't surface as
1226        // plaintext, and that has no AT-dirty trigger on focus). The
1227        // reveal signal is bound at AccessibilityOnly in `build`, so the
1228        // role/value swap reaches AT when it flips. `Role::PasswordInput`
1229        // is the sole mechanism telling AT not to speak the value —
1230        // accesskit has no separate `protected` flag.
1231        let explicitly_revealed = st.revealed.as_ref().is_some_and(|s| s.get());
1232        let protected = st.secure
1233            && match st.at_reveal_policy {
1234                AtRevealPolicy::AlwaysProtected => true,
1235                AtRevealPolicy::SwapRole => !explicitly_revealed,
1236            };
1237
1238        // What a reader may read. A protected field shows it what a sighted
1239        // user sees: the mask, one echo character per character of the secret
1240        // (nothing at all under `NoEcho`), and never the plaintext.
1241        let hide_all = protected && st.echo_mode == EchoMode::NoEcho;
1242        // Revealing or hiding a secure field: publish no runs this walk, so
1243        // the adapter has no plaintext to report as inserted or deleted (see
1244        // `AtPublished`), and have the frame tick ask for the walk that
1245        // publishes the new text.
1246        let withhold = st.secure
1247            && matches!(
1248                (st.at_published.get(), protected),
1249                (AtPublished::Plaintext, true) | (AtPublished::Mask, false)
1250            );
1251        if !builder.emits_no_children() {
1252            st.at_published.set(if withhold {
1253                AtPublished::Withheld
1254            } else if protected {
1255                AtPublished::Mask
1256            } else {
1257                AtPublished::Plaintext
1258            });
1259            if withhold && let Some(frame) = &st.frame_request {
1260                frame.set(true);
1261            }
1262        }
1263        let at_text = if !protected {
1264            text
1265        } else if hide_all {
1266            String::new()
1267        } else {
1268            st.echo_char.to_string().repeat(text.chars().count())
1269        };
1270
1271        if protected {
1272            builder.set_role(Role::PasswordInput);
1273        } else {
1274            // Plain field, or a revealed field under `SwapRole`: report
1275            // as a text input exposing the real value, mirroring the web
1276            // `type=password ↔ type=text` swap. The specialised role from
1277            // `input_purpose` (WCAG 1.3.5) applies here; `Role::TextInput` is
1278            // the `Normal` default.
1279            builder.set_role(self.input_purpose.to_role());
1280        }
1281        // Keep the value on the input node so the focus announcement is
1282        // unchanged: accesskit resolves `value()` from `data().value()`
1283        // first, falling back to the TextRun text only when unset.
1284        if !at_text.is_empty() {
1285            builder.set_value(&at_text);
1286        }
1287
1288        // Expose the content as child `Role::TextRun`s, NOT as
1289        // `character_lengths` on the input node itself. accesskit_consumer's
1290        // `supports_text_ranges()` is false for a childless input that only
1291        // hosts character data on its own node, so the macOS adapter never
1292        // fires `AXSelectedTextChanged` (VoiceOver reads the value once on
1293        // focus but never echoes characters/words while typing) and AT-SPI
1294        // publishes no Text interface. Runs are emitted even for an empty
1295        // field so `supports_text_ranges()` is already true before the first
1296        // keystroke (the change-diff's *old* node must support ranges too for
1297        // the notification to fire). A masked field needs them as much: Orca
1298        // 46.1 speaks no key in password text (`default.py`,
1299        // `presentKeyboardEvent`) and echoes a keystroke there only from the
1300        // text-inserted event (`script_utilities.py`,
1301        // `isEchoableTextInsertionEvent`), which the mask's runs raise.
1302        let retained = self.retained.borrow();
1303        let source = match retained.as_ref() {
1304            // A retained measurement describes the text it was taken of.
1305            // The document can move on between two layouts, so compare
1306            // rather than trust — a run whose ranges index a text that no
1307            // longer exists is worse than one with no extents.
1308            Some(placed) if placed.text == at_text => match placed.geometry.as_deref() {
1309                // The measurement covers the whole line; the field shows a
1310                // window onto it, so slide the rects back by the scroll
1311                // offset to land in the field's own space. `build`
1312                // translates them into window space from there.
1313                Some(geometry) => TextRunSource::from_geometry(
1314                    &placed.text,
1315                    geometry,
1316                    Point::new(-st.scroll_x, st.text_offset_y),
1317                    0,
1318                )
1319                .with_base_direction(placed.base_direction),
1320                None => TextRunSource::flat(&placed.text, 0)
1321                    .with_fallback_rect(Rect::new(
1322                        0.0,
1323                        0.0,
1324                        placed.bounds.width,
1325                        placed.bounds.height,
1326                    ))
1327                    .with_base_direction(placed.base_direction),
1328            },
1329            // Never placed, painted without a measuring backend, or one
1330            // edit ahead of the last measurement.
1331            _ => TextRunSource::flat(&at_text, 0),
1332        };
1333        let emission = if withhold {
1334            TextRunEmission::default()
1335        } else {
1336            push_text_runs(builder, None, &source)
1337        };
1338
1339        // While composing (IME preedit active), expose the composition
1340        // as a selection so screen readers / braille track the tentative
1341        // text — the composing characters are already in `value`. Falls
1342        // back to the live cursor/selection when not composing. A protected
1343        // field reports its caret only: the mask has one character per
1344        // character, so the offsets are the same, and under `NoEcho` the
1345        // caret stays at the start of a text that is always empty, as the
1346        // painted caret does. `position()` / `anchor()` are character
1347        // indices (text-document is char-space), which the emission maps
1348        // onto the run that holds them — a caret past 255 characters is
1349        // in the second run, at its own offset.
1350        let (anchor, pos) = match st.ime_preedit_range.clone() {
1351            _ if hide_all => (0, 0),
1352            Some(range) if !protected => (range.start, range.end),
1353            _ => (st.cursor.anchor(), st.cursor.position()),
1354        };
1355        if let (Some(anchor), Some(focus)) =
1356            (emission.position_of(anchor), emission.position_of(pos))
1357        {
1358            builder.set_text_selection_to(anchor, focus);
1359        }
1360
1361        if !st.placeholder.is_empty() {
1362            builder.set_placeholder(st.placeholder.clone());
1363        }
1364
1365        if st.read_only {
1366            builder.set_read_only();
1367        }
1368
1369        builder.add_action(Action::Focus);
1370        if !st.read_only {
1371            builder.add_action(Action::SetValue);
1372            builder.add_action(Action::ReplaceSelectedText);
1373        }
1374        // Only meaningful when the caret model is exposed to AT.
1375        if !hide_all {
1376            builder.add_action(Action::SetTextSelection);
1377        }
1378
1379        // Validation feedback → accesskit `aria-invalid`. Surface
1380        // `Invalid` as `Invalid::True`; `Corrected` doesn't carry an
1381        // invalid marker (the data is now valid) but the composite's
1382        // Live region announces the correction. The framework's
1383        // AccessNodeBuilder doesn't yet wrap `set_invalid`, so reach
1384        // through `inner_mut()` which is the documented escape hatch.
1385        if self.feedback.get().is_invalid() {
1386            builder
1387                .inner_mut()
1388                .set_invalid(teksilo_core::accesskit::Invalid::True);
1389        }
1390
1391        // ARIA combobox wiring. This node is the one that actually holds
1392        // keyboard focus, which is why the relation is published here and not
1393        // on whichever composite owns the list — AT follows the *focused*
1394        // node's active descendant.
1395        if let Some(listbox) = self.controls.as_ref().and_then(|s| s.get()) {
1396            builder.push_controlled(teksilo_core::accessibility::widget_id_to_node_id(listbox));
1397        }
1398        if let Some(active) = self.active_descendant.as_ref().and_then(|s| s.get()) {
1399            builder
1400                .inner_mut()
1401                .set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(active));
1402        }
1403    }
1404}