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}