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