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). A Win32
1328 // edit control does the same: the default (no
1329 // `ES_NOHIDESEL`) hides the highlight on blur but
1330 // `EM_GETSEL` still returns the range, so the
1331 // menu invoked afterwards still has something to
1332 // act on. The *painting* is the part that stops —
1333 // see `field_selection_color`, which returns a
1334 // transparent band for an unfocused field.
1335 st.scroll_x = 0.0;
1336 st.caret_visible.set(false);
1337 st.drag_state = state::DragState::Idle;
1338 // Drop the IME-area dedup cache. The OS candidate area is a
1339 // single per-window resource a sibling field may re-point
1340 // while we are unfocused; clearing this forces the next
1341 // focus-gain report to re-seed it instead of being deduped.
1342 st.last_ime_area = None;
1343 blur_callback = st.on_blur.clone();
1344 drop(st);
1345 // Abandon any in-progress composition on blur — remove
1346 // the tentative preedit text from the document.
1347 keyboard::clear_ime_preedit(&state_for_focus);
1348 sync_cursor_signals(&state_for_focus);
1349 }
1350 if let Some(cb) = blur_callback {
1351 cb(ctx);
1352 }
1353 ctx.request_frame();
1354 })
1355 .on_pointer_event(move |event, ctx| {
1356 mouse::handle_pointer_event(&state_for_pointer, event, ctx)
1357 })
1358 .on_key(move |event, ctx| keyboard::handle_key(&state_for_key, event, ctx))
1359 .on_double_tap(move |event, ctx| {
1360 mouse::handle_double_tap(&state_for_double, event.position, ctx)
1361 })
1362 .on_triple_tap(move |event, ctx| {
1363 mouse::handle_triple_tap(&state_for_triple, event.position, ctx)
1364 })
1365 .on_access_action_request(move |action, _target_node, data, ctx| {
1366 handle_access_action(&state_for_access, action, data, ctx)
1367 })
1368 // Right-click context menu — built fresh per click so the
1369 // enabled state of each item reflects the live selection /
1370 // clipboard state at the moment the menu opens. The framework
1371 // handles overlay placement, focus restoration, and dismissal.
1372 .context_menu(move |position, ctx| {
1373 let _ = ctx;
1374 // Framework gates pointer events on `arena.is_enabled`
1375 // before reaching this closure — a disabled field
1376 // never receives the right-click that would open the
1377 // context menu.
1378 // Reposition the caret to the click position when the
1379 // click lands outside the existing selection — the
1380 // platform convention for "right-click then Cut /
1381 // Copy / Paste at the new caret".
1382 mouse::reposition_caret_for_context_menu(&state_for_menu, position);
1383 Some(build_context_menu_widget(&state_for_menu))
1384 });
1385
1386 ctx.apply_self_handlers(handlers);
1387 Vec::new()
1388 }
1389
1390 fn layout_response(
1391 &self,
1392 proposal: SizeProposal,
1393 ctx: &LayoutContext,
1394 ) -> teksilo_core::widget::LayoutResponse {
1395 // Default unwrap is the cached natural width (mask-aware when
1396 // a mask is set; 200 dp fallback otherwise). Composing widgets
1397 // that wrap us in a constraint pass `Some(width)` and we use
1398 // that; the natural width is what surfaces in unconstrained
1399 // intrinsic queries (ZStack measurement with `unspecified()`,
1400 // etc.) so the chain reports a sensible content size.
1401 //
1402 // The cached `natural_width` / `text_height` are 1.0-scale baselines;
1403 // multiply by `ctx.text_scale` so the field box grows with the global
1404 // accessibility text scale (the engine grows the glyphs to match — see
1405 // `paint`). A caller-supplied width constraint is honored as-is.
1406 let scale = ctx.text_scale;
1407 let w = proposal
1408 .width
1409 .unwrap_or(self.natural_width * scale)
1410 .max(0.0);
1411 let h = (self.text_height.unwrap_or(DEFAULT_TEXT_HEIGHT) * scale).max(0.0);
1412 Size::new(w, h).into()
1413 }
1414
1415 fn place_children(
1416 &self,
1417 bounds: Rect,
1418 _proposal: SizeProposal,
1419 _children: &mut [WidgetPlacement],
1420 _ctx: &LayoutContext,
1421 ) {
1422 // Layout runs before paint, so this is the authoritative point to adopt
1423 // the field's viewport. `sync_viewport` welds the width write to the
1424 // `needs_full_layout` flag it also serves as the detector for (see its
1425 // docs); paint calls it again as an idempotent echo.
1426 if let Some(state) = self.state.as_ref() {
1427 state.borrow_mut().sync_viewport(bounds);
1428 }
1429 }
1430
1431 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1432 let Some(state) = self.state.as_ref() else {
1433 return;
1434 };
1435 let mut st = state.borrow_mut();
1436
1437 // Grow the shaped text with the global accessibility scale. Must run
1438 // before the relayout block below so the larger glyphs are shaped this
1439 // frame; no-op when the scale is unchanged.
1440 st.apply_font_scale(ctx.text_scale);
1441 // Idempotent echo — `place_children` already adopted these exact bounds
1442 // during layout, so this is normally a no-op.
1443 st.sync_viewport(bounds);
1444
1445 // Resolve the glyph / caret / suffix colours against the *effective*
1446 // enabled state, exactly as `TextWidget` and `RectWidget` resolve a
1447 // `ColorProp` at paint time. `paint` is the single writer of these:
1448 // the field shapes through a `RichTextEngine`, which takes raw GPU
1449 // colours and so never passes through `ColorProp::resolve` — the
1450 // disabled substitution that greys every role-driven leaf for free
1451 // cannot reach it. Doing it here (rather than as a build-time effect
1452 // on `effective_enabled_signal`) is also the only correct option:
1453 // that signal is *derived* whenever an ancestor binds `enabled`, and
1454 // `Signal::observe` panics on derived signals. Cheap — the engine
1455 // stores the colour and the render-frame builder reads it, so there
1456 // is no relayout and no reshaping.
1457 let text_color = if ctx.effective_enabled {
1458 ctx.theme.colors.text_primary
1459 } else {
1460 ctx.theme.colors.text_disabled
1461 };
1462 st.engine.set_text_color(text_color.to_array());
1463 st.engine.set_cursor_color(text_color.to_array());
1464
1465 let suffix_width = st.suffix_width;
1466 let text_viewport_width = (bounds.width - suffix_width).max(0.0);
1467
1468 st.engine.set_viewport(10_000.0, bounds.height);
1469
1470 if st.needs_full_layout || !st.engine.has_full_layout() {
1471 st.layout_full_masked();
1472 st.needs_full_layout = false;
1473 st.content_dirty = true;
1474 }
1475
1476 // Suppress the caret in an inactive window for every paint — the
1477 // authoritative gate, covering the frame between a window-active flip
1478 // and the build-time effect running.
1479 let caret_on = st.caret_visible.get() && st.has_focus && st.window_active;
1480 // `NoEcho` while masked lays out an *empty* source, so the real
1481 // document cursor (which may sit past 0) must not be handed to
1482 // the engine — pin the displayed caret/selection to the start.
1483 // The real `cursor` still tracks the true position for editing.
1484 let hide_all = st.echo_mode == EchoMode::NoEcho && st.should_mask();
1485 let (disp_pos, disp_anchor) = if hide_all {
1486 (0, 0)
1487 } else {
1488 (st.cursor.position(), st.cursor.anchor())
1489 };
1490 // Single-line input has no wrap → affinity is moot; the
1491 // default Downstream matches pre-affinity behavior.
1492 let cursor_display = CursorDisplay {
1493 position: disp_pos,
1494 anchor: disp_anchor,
1495 affinity: CursorAffinity::Downstream,
1496 visible: caret_on,
1497 selected_cells: Vec::new(),
1498 };
1499 st.engine.set_cursor(&cursor_display);
1500
1501 ensure_caret_visible_h(&mut st, text_viewport_width);
1502
1503 let scroll_x = st.scroll_x;
1504
1505 let text_clip = Rect::new(bounds.x, bounds.y, text_viewport_width, bounds.height);
1506 canvas.set_clip(text_clip);
1507
1508 {
1509 let state_ref: &mut TextInputState = &mut st;
1510 let TextInputState {
1511 ref mut engine,
1512 ref document,
1513 ref mut image_cache,
1514 ..
1515 } = *state_ref;
1516
1517 engine.with_render_frame(|frame| {
1518 paint_frame(
1519 canvas,
1520 PaintParams {
1521 frame,
1522 origin: Point::new(bounds.x - scroll_x, bounds.y),
1523 document,
1524 image_cache,
1525 // No inline images on this surface, so none can be missing.
1526 image_resolver: None,
1527 selection: None,
1528 selection_color: [0.0; 4],
1529 selected_image_out: None,
1530 resize_preview: None,
1531 draw_caret: caret_on,
1532 },
1533 );
1534 });
1535 }
1536
1537 // IME preedit underline: a thin line under the composing range so
1538 // the user sees the text is tentative. Single line → one segment;
1539 // on a secure field it sits under the masked bullets. Drawn inside
1540 // the text clip so it never spills past the viewport.
1541 if let Some(range) = st.ime_preedit_range.clone()
1542 && st.engine.has_full_layout()
1543 && range.start < range.end
1544 {
1545 let start_c = st
1546 .engine
1547 .caret_rect(range.start, CursorAffinity::Downstream);
1548 let end_c = st.engine.caret_rect(range.end, CursorAffinity::Downstream);
1549 let x0 = bounds.x - scroll_x + start_c[0];
1550 let x1 = bounds.x - scroll_x + end_c[0];
1551 let y = bounds.y + start_c[1] + start_c[3] - 1.0;
1552 canvas.draw_line(
1553 Point::new(x0, y),
1554 Point::new(x1, y),
1555 ctx.theme.colors.text_primary,
1556 teksilo_canvas::StrokeStyle::solid(1.0),
1557 );
1558 }
1559
1560 canvas.clear_clip();
1561
1562 if suffix_width > 0.0
1563 && let Some(suffix_engine) = st.suffix_engine.as_mut()
1564 {
1565 // The suffix dims with the value it annotates — a crisp " %"
1566 // beside greyed-out digits reads as a rendering bug.
1567 let suffix_color = if ctx.effective_enabled {
1568 ctx.theme.colors.text_secondary
1569 } else {
1570 ctx.theme.colors.text_disabled
1571 };
1572 suffix_engine.set_text_color(suffix_color.to_array());
1573 let suffix_clip = Rect::new(
1574 bounds.x + text_viewport_width,
1575 bounds.y,
1576 suffix_width,
1577 bounds.height,
1578 );
1579 canvas.set_clip(suffix_clip);
1580 let suffix_origin = Point::new(bounds.x + text_viewport_width, bounds.y);
1581 suffix_engine.with_render_frame(|frame| {
1582 paint_suffix_glyphs(canvas, frame, suffix_origin);
1583 });
1584 canvas.clear_clip();
1585 }
1586 }
1587
1588 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1589 use teksilo_core::accesskit::{Action, Role};
1590
1591 let Some(state) = self.state.as_ref() else {
1592 return;
1593 };
1594 let st = state.borrow();
1595
1596 let text = st.document.to_plain_text().unwrap_or_default();
1597
1598 // AT-protection tracks the *explicit* reveal toggle only — not
1599 // the visual `RevealWhileTyping` focus-reveal (a sighted-only
1600 // convenience that a screen reader shouldn't surface as
1601 // plaintext, and that has no AT-dirty trigger on focus). The
1602 // reveal signal is bound at AccessibilityOnly in `build`, so the
1603 // role/value swap reaches AT when it flips. `Role::PasswordInput`
1604 // is the sole mechanism telling AT not to speak the value —
1605 // accesskit has no separate `protected` flag.
1606 let explicitly_revealed = st.revealed.as_ref().is_some_and(|s| s.get());
1607 let protected = st.secure
1608 && match st.at_reveal_policy {
1609 AtRevealPolicy::AlwaysProtected => true,
1610 AtRevealPolicy::SwapRole => !explicitly_revealed,
1611 };
1612
1613 if protected {
1614 builder.set_role(Role::PasswordInput);
1615 // Expose a bullet string of the right length (NoEcho hides
1616 // even that) so AT can announce the character count, never
1617 // the secret. Deliberately omit character lengths, word
1618 // starts, and the text selection: the caret model stays
1619 // opaque so no structure about the secret leaks.
1620 if st.echo_mode != EchoMode::NoEcho {
1621 let count = text.chars().count();
1622 if count > 0 {
1623 builder.set_value(st.echo_char.to_string().repeat(count));
1624 }
1625 }
1626 } else {
1627 // Plain field, or a revealed field under `SwapRole`: report
1628 // as a text input exposing the real value, mirroring the web
1629 // `type=password ↔ type=text` swap. The specialised role from
1630 // `input_purpose` (WCAG 1.3.5) applies here; `Role::TextInput` is
1631 // the `Normal` default.
1632 builder.set_role(self.input_purpose.to_role());
1633 // Keep the value on the input node so the focus announcement is
1634 // unchanged: accesskit resolves `value()` from `data().value()`
1635 // first, falling back to the TextRun text only when unset.
1636 if !text.is_empty() {
1637 builder.set_value(&text);
1638 }
1639
1640 // Expose the editable content as a child `Role::TextRun`, NOT as
1641 // `character_lengths` on the input node itself. accesskit_consumer's
1642 // `supports_text_ranges()` is false for a childless input that only
1643 // hosts character data on its own node, so the macOS adapter never
1644 // fires `AXSelectedTextChanged` — VoiceOver reads the value once on
1645 // focus but never echoes characters/words while typing. Emit the run
1646 // even when empty so `supports_text_ranges()` is already true before
1647 // the first keystroke (the change-diff's *old* node must support
1648 // ranges too for the notification to fire). `position()` / `anchor()`
1649 // are character indices (text-document is char-space), matching the
1650 // TextRun's `character_index` contract — correct for multibyte text.
1651 let char_lengths: Vec<u8> = text.chars().map(|c| c.len_utf8() as u8).collect();
1652 let word_starts = compute_word_starts(&text);
1653 let word_starts = (!word_starts.is_empty()).then_some(word_starts);
1654 let run_id =
1655 builder.push_text_run_child_on_self(0, text.clone(), char_lengths, word_starts);
1656
1657 // While composing (IME preedit active), expose the composition
1658 // as a selection so screen readers / braille track the tentative
1659 // text — the composing characters are already in `value`. Falls
1660 // back to the live cursor/selection when not composing. (The
1661 // secure branch above never reaches here, so a password preedit
1662 // is never exposed.) Selection now references the TextRun child.
1663 let (anchor, pos) = match st.ime_preedit_range.clone() {
1664 Some(range) => (range.start, range.end),
1665 None => (st.cursor.anchor(), st.cursor.position()),
1666 };
1667 builder.set_text_selection_to((run_id, anchor), (run_id, pos));
1668 }
1669
1670 if !st.placeholder.is_empty() {
1671 builder.set_placeholder(st.placeholder.clone());
1672 }
1673
1674 if st.read_only {
1675 builder.set_read_only();
1676 }
1677
1678 builder.add_action(Action::Focus);
1679 if !st.read_only {
1680 builder.add_action(Action::SetValue);
1681 builder.add_action(Action::ReplaceSelectedText);
1682 }
1683 // Only meaningful when the caret model is exposed to AT.
1684 if !protected {
1685 builder.add_action(Action::SetTextSelection);
1686 }
1687
1688 // Validation feedback → accesskit `aria-invalid`. Surface
1689 // `Invalid` as `Invalid::True`; `Corrected` doesn't carry an
1690 // invalid marker (the data is now valid) but the composite's
1691 // Live region announces the correction. The framework's
1692 // AccessNodeBuilder doesn't yet wrap `set_invalid`, so reach
1693 // through `inner_mut()` which is the documented escape hatch.
1694 if self.feedback.get().is_invalid() {
1695 builder
1696 .inner_mut()
1697 .set_invalid(teksilo_core::accesskit::Invalid::True);
1698 }
1699
1700 // ARIA combobox wiring. This node is the one that actually holds
1701 // keyboard focus, which is why the relation is published here and not
1702 // on whichever composite owns the list — AT follows the *focused*
1703 // node's active descendant.
1704 if let Some(listbox) = self.controls.as_ref().and_then(|s| s.get()) {
1705 builder.push_controlled(teksilo_core::accessibility::widget_id_to_node_id(listbox));
1706 }
1707 if let Some(active) = self.active_descendant.as_ref().and_then(|s| s.get()) {
1708 builder
1709 .inner_mut()
1710 .set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(active));
1711 }
1712 }
1713}
1714
1715impl TextInputField {
1716 /// Borrow the shared state. Panics if called before `build()`
1717 /// has run — the state is allocated in `build()` from the
1718 /// builder config.
1719 fn state(&self) -> &SharedState {
1720 self.state
1721 .as_ref()
1722 .expect("TextInputField::state called before build")
1723 }
1724}
1725
1726/// Adjust `scroll_x` so the caret stays within the visible viewport.
1727///
1728/// `text_viewport_width` is the portion of the viewport reserved for
1729/// editable text, i.e. `viewport_width - suffix_width`. Callers pass
1730/// the reduced width explicitly so the scroll never slides text
1731/// behind the non-editable suffix.
1732fn ensure_caret_visible_h(st: &mut TextInputState, text_viewport_width: f32) {
1733 if !st.engine.has_full_layout() || text_viewport_width <= 0.0 {
1734 return;
1735 }
1736 let pos = st.cursor.position();
1737 // Single-line input: no wrap, affinity is a no-op.
1738 let caret = st.engine.caret_rect(pos, CursorAffinity::Downstream);
1739 let caret_x = caret[0];
1740 let caret_w = caret[2].max(1.0);
1741 let vw = text_viewport_width;
1742
1743 if caret_x - st.scroll_x < SCROLL_MARGIN {
1744 st.scroll_x = (caret_x - SCROLL_MARGIN).max(0.0);
1745 } else if caret_x + caret_w - st.scroll_x > vw - SCROLL_MARGIN {
1746 st.scroll_x = caret_x + caret_w - vw + SCROLL_MARGIN;
1747 }
1748}
1749
1750/// Update the cached suffix text and re-run layout on the suffix
1751/// engine. Called from `build()` for the initial value and from
1752/// the reactive effect when the bound suffix signal fires.
1753fn relayout_suffix(state: &SharedState, new_text: &str) {
1754 let mut st = state.borrow_mut();
1755 st.suffix = new_text.to_string();
1756 if new_text.is_empty() {
1757 st.suffix_width = 0.0;
1758 // Leave the engine in place (cheap to reuse) but don't
1759 // lay out — paint skips the suffix when width is zero.
1760 return;
1761 }
1762 let Some(engine) = st.suffix_engine.as_mut() else {
1763 // No engine allocated (pure-static path that started
1764 // empty and never became non-empty). Allocate lazily so
1765 // late signal flips still render.
1766 return;
1767 };
1768 let doc = TextDocument::new();
1769 let _ = doc.set_plain_text(new_text);
1770 let flow = doc.snapshot_flow();
1771 engine.layout_full(&flow);
1772 st.suffix_width = engine.max_content_width();
1773}
1774
1775/// Paint glyphs from a pre-laid-out suffix `RenderFrame` at a fixed
1776/// origin. Decorations, selection rectangles, and caret are ignored —
1777/// the suffix is plain non-editable text, so only the glyph pass is
1778/// needed. Kept inline (rather than reusing `paint_frame`) to avoid
1779/// the `TextDocument` / `ImageCache` parameters `paint_frame`
1780/// requires for inline images the suffix never contains.
1781fn paint_suffix_glyphs(canvas: &mut Canvas, frame: &teksilo_text::RenderFrame, origin: Point) {
1782 use teksilo_canvas::GlyphQuad as CanvasGlyphQuad;
1783 for g in frame.glyphs.iter() {
1784 let quad = CanvasGlyphQuad {
1785 screen: [
1786 g.screen[0] + origin.x,
1787 g.screen[1] + origin.y,
1788 g.screen[2],
1789 g.screen[3],
1790 ],
1791 atlas: g.atlas,
1792 color: g.color,
1793 is_color: g.is_color,
1794 };
1795 canvas.draw_glyph_quad(quad);
1796 }
1797}
1798
1799/// The band a selection is painted in. Two axes decide it, and they do *not*
1800/// decide it the same way:
1801///
1802/// | field focus | window | band |
1803/// | --- | --- | --- |
1804/// | focused | active | vivid `selection_bg_active` |
1805/// | focused | inactive | muted `selection_bg_inactive` |
1806/// | not focused | either | nothing — fully transparent |
1807///
1808/// **An entry that does not hold focus paints no selection**, which is what
1809/// every native single-line field does. A Win32 edit control hides the
1810/// selection on focus-out unless it was created with `ES_NOHIDESEL`, and
1811/// WinForms spells the same default `TextBoxBase.HideSelection = true`.
1812/// `QLineEdit::focusOutEvent` goes further and calls `deselect()` outright for
1813/// every focus reason except `ActiveWindowFocusReason` and `PopupFocusReason`.
1814/// On macOS an `NSTextField` that stops being first responder has its shared
1815/// field editor detached, so there is no selection left to draw. GTK's entry is
1816/// the one toolkit that keeps a defocused selection lit, and that has been
1817/// filed against it as a papercut rather than defended as a design.
1818///
1819/// The *window* axis is the one where dimming, not hiding, is correct — and
1820/// the same three toolkits say so: Qt's carve-out for `ActiveWindowFocusReason`
1821/// exists precisely so a focused field keeps its selection when the window goes
1822/// to the background, and AppKit renders it there in
1823/// `unemphasizedSelectedTextBackgroundColor`. Losing the window is not the same
1824/// event as losing the caret.
1825///
1826/// Multi-line editors (`RichTextEditor`, `CodeEditor`, `LogView`) are
1827/// deliberately **not** on this rule: `QTextEdit` / `NSTextView` / every code
1828/// editor keep a visible selection in a blurred view, because there the
1829/// selection is a region of a document the user is working with rather than a
1830/// transient edit state.
1831///
1832/// The selection *state* survives blur either way — the `on_focus(false)` arm
1833/// spells out why (the right-click Copy path needs it) — this decides only what
1834/// is drawn.
1835fn field_selection_color(
1836 colors: &teksilo_tokens::ColorTokens,
1837 window_active: bool,
1838 has_focus: bool,
1839) -> [f32; 4] {
1840 match (has_focus, window_active) {
1841 (false, _) => [0.0; 4],
1842 (true, true) => colors.selection_bg_active.to_array(),
1843 (true, false) => colors.selection_bg_inactive.to_array(),
1844 }
1845}
1846
1847/// Simplified frame-loop tick for single-line text input.
1848fn tick(state: &mut TextInputState, delta: f32) -> bool {
1849 if !state.pending_chars.is_empty() {
1850 let batch = std::mem::take(&mut state.pending_chars);
1851 let _ = state.cursor.insert_text(&batch);
1852 state.pending_text_changed = true;
1853 }
1854
1855 let had_events = state.drain_events();
1856
1857 // Blink only when focused AND the host window is active — the caret hides
1858 // in an inactive window (the universal desktop convention). The else-branch
1859 // below then turns it off, since `!blinking_active` now also covers the
1860 // window-inactive case.
1861 let caret_active = state.has_focus && state.window_active;
1862 let caret_visible = state.caret_visible.clone();
1863 let wake = state.frame_wake_at.clone();
1864 // A single-line field always blinks (no read-only/static presets), so it
1865 // hands the shared machine a fixed `Blinking` policy.
1866 state.blink.tick(
1867 CaretPolicy::Blinking,
1868 caret_active,
1869 &caret_visible,
1870 wake.as_ref(),
1871 );
1872
1873 if state.needs_full_layout && state.viewport_width > 0.0 {
1874 state.layout_full_masked();
1875 state.needs_full_layout = false;
1876 state.content_dirty = true;
1877 }
1878
1879 if state.pending_text_changed {
1880 let new_text = state.document.to_plain_text().unwrap_or_default();
1881 if state.text_signal.get() != new_text {
1882 state.deferred_text_update = Some(new_text);
1883 }
1884 }
1885
1886 if state.debounce.tick(delta) {
1887 if state.pending_text_changed {
1888 state.pending_text_changed = false;
1889 }
1890 if let Some((cu, cr)) = state.pending_undo_redo.take() {
1891 if state.can_undo.get() != cu {
1892 state.can_undo.set(cu);
1893 }
1894 if state.can_redo.get() != cr {
1895 state.can_redo.set(cr);
1896 }
1897 }
1898 }
1899 let debounce_work = state.pending_text_changed || state.pending_undo_redo.is_some();
1900
1901 had_events || debounce_work
1902}
1903
1904/// Handle AccessKit actions (SetValue, SetTextSelection, Focus).
1905fn handle_access_action(
1906 state: &SharedState,
1907 action: teksilo_core::accesskit::Action,
1908 data: Option<teksilo_core::accesskit::ActionData>,
1909 ctx: &mut EventContext,
1910) -> EventResponse {
1911 use teksilo_core::accesskit::{Action, ActionData};
1912
1913 match (action, data) {
1914 (Action::SetTextSelection, Some(ActionData::SetTextSelection(sel))) => {
1915 let st = state.borrow();
1916 st.cursor.set_position(
1917 sel.anchor.character_index,
1918 teksilo_text::text_document::MoveMode::MoveAnchor,
1919 );
1920 st.cursor.set_position(
1921 sel.focus.character_index,
1922 teksilo_text::text_document::MoveMode::KeepAnchor,
1923 );
1924 drop(st);
1925 sync_cursor_signals(state);
1926 ctx.request_frame();
1927 EventResponse::Handled
1928 }
1929 (Action::SetValue, Some(ActionData::Value(value))) => {
1930 let st = state.borrow();
1931 st.cursor.select(SelectionType::Document);
1932 let _ = st.cursor.insert_text(value.as_ref());
1933 drop(st);
1934 sync_cursor_signals(state);
1935 ctx.request_frame();
1936 EventResponse::Handled
1937 }
1938 (Action::ReplaceSelectedText, Some(ActionData::Value(value))) => {
1939 // Insert at the caret, replacing the active selection (if
1940 // any) — NOT the whole document like `SetValue`. This is the
1941 // AT-SPI (Linux) / UIA (Windows) braille-keyboard and
1942 // dictation insertion path; macOS routes insertion through
1943 // `SetValue` instead, so this never fires there. We advertise
1944 // the action in `accessibility()`, so we must service it.
1945 let st = state.borrow();
1946 let _ = st.cursor.insert_text(value.as_ref());
1947 drop(st);
1948 sync_cursor_signals(state);
1949 ctx.request_frame();
1950 EventResponse::Handled
1951 }
1952 (Action::Focus, _) => {
1953 if let Some(id) = state.borrow().field_widget_id {
1954 ctx.request_focus(id);
1955 }
1956 EventResponse::Handled
1957 }
1958 _ => EventResponse::Ignored,
1959 }
1960}
1961
1962/// Compute word-start character indices for AccessKit.
1963fn compute_word_starts(text: &str) -> Vec<u8> {
1964 let mut starts = Vec::new();
1965 let mut in_word = false;
1966 for (char_index, ch) in text.chars().enumerate() {
1967 let is_word_char = ch.is_alphanumeric() || ch == '_';
1968 if is_word_char
1969 && !in_word
1970 && let Ok(idx) = u8::try_from(char_index)
1971 {
1972 starts.push(idx);
1973 }
1974 in_word = is_word_char;
1975 }
1976 starts
1977}
1978
1979/// Build a fresh right-click context menu widget. Called from the
1980/// `.context_menu(...)` factory on every right-click, so each open
1981/// reads live `has_selection` / `is_empty` state when computing each
1982/// item's enabled flag.
1983fn build_context_menu_widget(state: &SharedState) -> Box<dyn Widget> {
1984 let st = state.borrow();
1985 let has_selection = st.cursor.has_selection();
1986 let doc_non_empty = !st.document.to_plain_text().unwrap_or_default().is_empty();
1987 // Secure fields suppress Cut / Copy while masked (still allowed when
1988 // revealed or when the developer opted in via `allow_copy`).
1989 let copy_allowed = st.copy_allowed();
1990 drop(st);
1991
1992 let state_cut = state.clone();
1993 let state_copy = state.clone();
1994 let state_paste = state.clone();
1995 let state_select_all = state.clone();
1996
1997 Box::new(
1998 MenuList::new()
1999 .item(
2000 MenuItem::new(tr_widget!(menu_cut()))
2001 .shortcut_label(format_keystroke(KeyStroke::command(Key::X)))
2002 .enabled(has_selection && copy_allowed)
2003 .on_activate_fn(move |ctx| {
2004 {
2005 let mut st = state_cut.borrow_mut();
2006 keyboard::clipboard_cut(&mut st, ctx);
2007 }
2008 sync_cursor_signals(&state_cut);
2009 ctx.request_frame();
2010 }),
2011 )
2012 .item(
2013 MenuItem::new(tr_widget!(menu_copy()))
2014 .shortcut_label(format_keystroke(KeyStroke::command(Key::C)))
2015 .enabled(has_selection && copy_allowed)
2016 .on_activate_fn(move |ctx| {
2017 let mut st = state_copy.borrow_mut();
2018 keyboard::clipboard_copy(&mut st, ctx);
2019 }),
2020 )
2021 .item(
2022 MenuItem::new(tr_widget!(menu_paste()))
2023 .shortcut_label(format_keystroke(KeyStroke::command(Key::V)))
2024 .on_activate_fn(move |ctx| {
2025 {
2026 let mut st = state_paste.borrow_mut();
2027 keyboard::clipboard_paste(&mut st, ctx);
2028 }
2029 sync_cursor_signals(&state_paste);
2030 ctx.request_frame();
2031 }),
2032 )
2033 .item(MenuSeparator)
2034 .item(
2035 MenuItem::new(tr_widget!(menu_select_all()))
2036 .shortcut_label(format_keystroke(KeyStroke::command(Key::A)))
2037 .enabled(doc_non_empty)
2038 .on_activate_fn(move |ctx| {
2039 {
2040 let st = state_select_all.borrow();
2041 st.cursor.select(SelectionType::Document);
2042 }
2043 sync_cursor_signals(&state_select_all);
2044 ctx.request_frame();
2045 }),
2046 ),
2047 )
2048}
2049
2050/// Run the validator on the bound text and update the feedback signal.
2051///
2052/// On `Corrected`, also writes the corrected text back to the bound
2053/// signal — the field's external→internal sync effect picks this up
2054/// and rewrites the document in the next frame. On `Invalid`, the
2055/// text is left as-typed; composites that want a "revert on invalid"
2056/// behaviour observe the feedback signal and rewrite the text from
2057/// their own source of truth (e.g., `DateEdit` reformats from its
2058/// `Signal<Option<Date>>`).
2059fn run_validator_and_apply(
2060 validator: &ValidatorFn,
2061 bound_text: &Signal<String>,
2062 feedback: &Signal<ValidationFeedback>,
2063) {
2064 let raw = bound_text.get();
2065 match validator(&raw) {
2066 ValidationOutcome::Valid => {
2067 feedback.set(ValidationFeedback::Valid);
2068 }
2069 ValidationOutcome::Corrected { corrected, message } => {
2070 // Write the corrected text first so observers of the
2071 // bound signal see the new value before the feedback
2072 // signal flips. Composites that bind to BOTH signals
2073 // (rare) will see a consistent pair: text + correction
2074 // notice describing the change.
2075 if bound_text.get() != corrected {
2076 bound_text.set(corrected);
2077 }
2078 feedback.set(ValidationFeedback::Corrected {
2079 message,
2080 since: std::time::Instant::now(),
2081 });
2082 }
2083 ValidationOutcome::Invalid { message } => {
2084 feedback.set(ValidationFeedback::Invalid { message });
2085 }
2086 }
2087}
2088
2089/// Build the worst-case-glyph version of an [`InputMask`] for
2090/// natural-width measurement: every editable slot holds the widest
2091/// plausible character its class can accept, and every fixed slot
2092/// holds its literal. Used by `build()` to size the field's
2093/// intrinsic envelope so a fully-typed value never overflows the
2094/// reported natural width.
2095///
2096/// Per-class worst-case glyph (Inter and most UI sans-serifs):
2097/// - `Digit` → `0` (tabular figures are constant-width, but `0` is
2098/// representative for fonts that aren't)
2099/// - `Letter` / `Alphanumeric` / `Any` → `M` (widest cap glyph)
2100/// - `HexDigit` → `0`
2101fn worst_case_template(mask: &InputMask) -> String {
2102 let mut s = String::with_capacity(mask.len());
2103 for pos in mask.positions() {
2104 match pos {
2105 MaskPosition::Editable { class, .. } => {
2106 s.push(match class {
2107 MaskClass::Digit | MaskClass::HexDigit => '0',
2108 MaskClass::Letter | MaskClass::Alphanumeric | MaskClass::Any => 'M',
2109 });
2110 }
2111 MaskPosition::Fixed(c) => s.push(*c),
2112 }
2113 }
2114 s
2115}
2116
2117/// Measure the advance width of `text` in logical pixels using the
2118/// app-wide `SharedTypesetter` (the same backend the field paints
2119/// with). Falls back to a per-character-class heuristic when no
2120/// typesetter is installed (headless tests) so the caller still gets
2121/// a non-zero width and any natural-width / cap logic behaves
2122/// reasonably even there. The fallback weights match Inter's body
2123/// proportions closely enough that the difference between an
2124/// underscore and a wide cap glyph (`M`) shows up in headless tests
2125/// — important for verifying the worst-case-glyph mask measurement
2126/// without booting a typesetter.
2127fn measure_width_px(ctx: &mut BuildContext, text: &str, style: &TextStyle) -> f32 {
2128 if text.is_empty() {
2129 return 0.0;
2130 }
2131 if let Some(ts) = ctx.app_state::<SharedTypesetter>() {
2132 let backend = ts.as_text_backend();
2133 let layout = backend.borrow_mut().layout_single_line(text, style, None);
2134 return layout.width;
2135 }
2136 let em = style.size;
2137 text.chars()
2138 .map(|c| match c {
2139 ' ' => 0.30,
2140 '_' => 0.45,
2141 ':' | '.' | ',' | ';' | '/' | '|' | '!' | 'i' | 'l' | 'I' => 0.30,
2142 '0'..='9' => 0.55,
2143 'M' | 'W' | 'm' | 'w' => 0.85,
2144 'A'..='Z' => 0.65,
2145 'a'..='z' => 0.50,
2146 _ => 0.55,
2147 })
2148 .map(|w: f32| w * em)
2149 .sum()
2150}
2151
2152#[cfg(test)]
2153mod window_active_tests {
2154 use super::*;
2155 use teksilo_canvas::{Point, SizeProposal};
2156 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2157 use teksilo_core::signal::Signal;
2158 use teksilo_core::widget_tree::WidgetTree;
2159
2160 #[test]
2161 fn field_selection_color_swaps_on_window_active() {
2162 let colors = teksilo_core::presets::intui::light().colors;
2163 assert_eq!(
2164 field_selection_color(&colors, true, true),
2165 colors.selection_bg_active.to_array(),
2166 "active window uses the vivid selection colour"
2167 );
2168 assert_eq!(
2169 field_selection_color(&colors, false, true),
2170 colors.selection_bg_inactive.to_array(),
2171 "inactive window uses the muted selection colour"
2172 );
2173 assert_ne!(
2174 field_selection_color(&colors, true, true),
2175 field_selection_color(&colors, false, true)
2176 );
2177 }
2178
2179 /// **A field that is not focused paints no selection at all**, in an
2180 /// active window or a background one.
2181 ///
2182 /// Dimming it was not enough: tab across a form of `SpinBox`es — each of
2183 /// which selects all on keyboard focus — and every field left behind kept
2184 /// a grey band, so the form read as a column of half-lit selections with
2185 /// no way to tell which one the keystrokes went to. Native single-line
2186 /// fields hide it outright (Win32 without `ES_NOHIDESEL`,
2187 /// `TextBoxBase.HideSelection = true`, `QLineEdit`'s `deselect()` on
2188 /// focus-out, AppKit detaching the field editor).
2189 #[test]
2190 fn field_selection_color_vanishes_when_the_field_is_not_focused() {
2191 let colors = teksilo_core::presets::intui::light().colors;
2192 assert_eq!(
2193 field_selection_color(&colors, true, false),
2194 [0.0; 4],
2195 "an unfocused field must paint no selection, even in an active window"
2196 );
2197 assert_eq!(field_selection_color(&colors, false, false), [0.0; 4]);
2198 }
2199
2200 /// ...and the live field re-tints as focus comes and goes, rather than
2201 /// keeping whatever colour it was built with.
2202 #[test]
2203 fn a_field_re_tints_its_selection_when_focus_leaves_it() {
2204 let colors = teksilo_core::presets::intui::light().colors;
2205 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2206 let a = tree.add(TextInputField::new(Signal::new("hello".to_string())));
2207 let b = tree.add(TextInputField::new(Signal::new("world".to_string())));
2208 tree.layout(SizeProposal::exact(200.0, 40.0));
2209
2210 let tint = |tree: &WidgetTree, id| {
2211 tree.widget_as_any(id)
2212 .and_then(|w| w.downcast_ref::<TextInputField>())
2213 .and_then(|f| f.state.as_ref())
2214 .map(|st| st.borrow().selection_tint)
2215 .expect("a built field")
2216 };
2217
2218 tree.focus(a);
2219 assert_eq!(
2220 tint(&tree, a),
2221 colors.selection_bg_active.to_array(),
2222 "the focused field paints its selection live"
2223 );
2224
2225 tree.focus(b);
2226 assert_eq!(
2227 tint(&tree, a),
2228 [0.0; 4],
2229 "focus moved to another field and the first kept a visible selection"
2230 );
2231 assert_eq!(tint(&tree, b), colors.selection_bg_active.to_array());
2232 }
2233
2234 #[test]
2235 fn caret_hidden_when_window_inactive() {
2236 let text = Signal::new("hello".to_string());
2237 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2238 let id = tree.add(TextInputField::new(text));
2239 tree.layout(SizeProposal::exact(200.0, 40.0));
2240 let _ = tree.render();
2241
2242 // Reach the built field's shared state (created lazily in build()) to
2243 // observe the caret-gate inputs directly — the caret paints as an
2244 // engine-internal fill, not a top-level decoration.
2245 let state = tree
2246 .widget_as_any(id)
2247 .and_then(|a| a.downcast_ref::<TextInputField>())
2248 .map(|f| f.state().clone())
2249 .expect("built TextInputField is reachable via as_any");
2250
2251 // Focus the field by clicking its centre.
2252 let b = tree.bounds(id);
2253 tree.dispatch_event(WidgetEvent::PointerDown {
2254 position: Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
2255 button: PointerButton::Primary,
2256 modifiers: Modifiers::NONE,
2257 });
2258 // One frame so the blink turns the caret on (on_focus sets it on; the
2259 // 500 ms interval hasn't elapsed after a single 16 ms tick).
2260 tree.request_frame();
2261 tree.tick_animations(std::time::Duration::from_millis(16));
2262 tree.layout(SizeProposal::exact(200.0, 40.0));
2263
2264 assert!(state.borrow().has_focus, "field took focus");
2265 assert!(state.borrow().window_active);
2266 assert!(
2267 state.borrow().caret_visible.get(),
2268 "caret visible when focused in an active window"
2269 );
2270
2271 // Window blur: caret hidden (effect clears it synchronously).
2272 tree.set_window_active(false);
2273 assert!(!state.borrow().window_active);
2274 assert!(
2275 !state.borrow().caret_visible.get(),
2276 "caret hidden while the window is inactive"
2277 );
2278
2279 // Reactivate: caret returns immediately (field still holds focus).
2280 tree.set_window_active(true);
2281 assert!(
2282 state.borrow().caret_visible.get(),
2283 "caret restored on window reactivate"
2284 );
2285 }
2286}
2287
2288/// **A key the platform decorates with control text must still bubble.**
2289///
2290/// These dispatch `KeyDown` with the `text` a real keyboard carries. Every
2291/// synthetic helper in the workspace sends `text: None`, which skips the branch
2292/// under test entirely — so a test written with `press_key` passes on the bug.
2293#[cfg(test)]
2294mod key_text_bubbling_tests {
2295 use super::*;
2296 use std::cell::Cell;
2297 use teksilo_canvas::{Point, SizeProposal};
2298 use teksilo_core::WidgetBuilder;
2299 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2300 use teksilo_core::signal::Signal;
2301 use teksilo_core::widget_tree::WidgetTree;
2302
2303 /// Dispatch one `KeyDown` to a focused field that sits *inside* a widget
2304 /// carrying an `on_key`, and report whether that outer handler saw it.
2305 ///
2306 /// The nesting is the point. Hanging the handler on the field itself puts
2307 /// it on the same node the click focuses, above the field's own handler
2308 /// rather than behind it, and the bubble under test never happens — which
2309 /// is exactly how an earlier version of this test passed on the bug.
2310 fn outer_handler_sees(key: Key, text: Option<&str>, field: TextInputField) -> bool {
2311 let seen = Rc::new(Cell::new(false));
2312 let seen_for_handler = seen.clone();
2313 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2314 let outer = tree.add(crate::primitives::VStack::new().child(field).on_key(
2315 move |_ev, _ctx| {
2316 seen_for_handler.set(true);
2317 EventResponse::Handled
2318 },
2319 ));
2320 tree.layout(SizeProposal::exact(200.0, 40.0));
2321
2322 let b = tree.bounds(outer);
2323 let centre = Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
2324 tree.dispatch_event(WidgetEvent::PointerDown {
2325 position: centre,
2326 button: PointerButton::Primary,
2327 modifiers: Modifiers::NONE,
2328 });
2329 tree.dispatch_event(WidgetEvent::PointerUp {
2330 position: centre,
2331 button: PointerButton::Primary,
2332 modifiers: Modifiers::NONE,
2333 });
2334 let focused = tree.focused().expect("the click focused something");
2335 assert_ne!(
2336 focused, outer,
2337 "focus must land on the field, or nothing below the outer handler is being tested"
2338 );
2339
2340 tree.dispatch_event(WidgetEvent::KeyDown {
2341 key,
2342 modifiers: Modifiers::NONE,
2343 text: text.map(str::to_string),
2344 });
2345 seen.get()
2346 }
2347
2348 /// The bug: winit gives Escape `text: Some("\u{1b}")`, the field had no
2349 /// `Escape` arm so it fell into the printable-character branch, the control
2350 /// character was filtered out, and the empty result was read as "input
2351 /// rejected" — which swallows the key. Escape therefore never left a
2352 /// focused field, and anything above it that closes on Escape stayed open.
2353 #[test]
2354 fn escape_bubbles_out_of_a_field_even_carrying_its_control_text() {
2355 assert!(
2356 outer_handler_sees(
2357 Key::Escape,
2358 Some("\u{1b}"),
2359 TextInputField::new(Signal::new("hello".to_string()))
2360 ),
2361 "Escape must reach the widget above the field"
2362 );
2363 }
2364
2365 /// ...and it made no difference with `text: None`, which is why the whole
2366 /// suite went green on the bug. Kept so the two cases stay visibly paired.
2367 #[test]
2368 fn escape_bubbles_out_of_a_field_without_text() {
2369 assert!(outer_handler_sees(
2370 Key::Escape,
2371 None,
2372 TextInputField::new(Signal::new("hello".to_string()))
2373 ));
2374 }
2375
2376 /// The other half of the guard, and the reason it is written against the
2377 /// *text* rather than the `Key` variant: a character the field's filter
2378 /// rejects is still swallowed, so a digits-only field does not let a
2379 /// rejected letter fall through and match a shortcut.
2380 ///
2381 /// A typed letter arrives as `Key::A`, not `Key::Character('a')`, so a
2382 /// variant test here would have silently stopped swallowing letters.
2383 #[test]
2384 fn a_filter_rejected_character_is_still_swallowed() {
2385 let digits_only = TextInputField::new(Signal::new(String::new()))
2386 .char_filter(|c: char| c.is_ascii_digit());
2387 assert!(
2388 !outer_handler_sees(Key::A, Some("a"), digits_only),
2389 "a rejected letter must not bubble into a shortcut match"
2390 );
2391 }
2392}
2393
2394/// A live handle on a [`TextInputField`] — its text-editing commands, for a
2395/// caller outside the widget.
2396///
2397/// Every method is a no-op before the field is built (and after it is
2398/// destroyed), which is the honest answer rather than a panic: a menu row bound
2399/// to a field that is no longer on screen should do nothing, not crash.
2400#[derive(Clone)]
2401pub struct TextFieldHandle {
2402 slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>>,
2403 focus_signal: Signal<bool>,
2404}
2405
2406impl std::fmt::Debug for TextFieldHandle {
2407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2408 f.debug_struct("TextFieldHandle")
2409 .field("live", &self.slot.borrow().is_some())
2410 .field("focused", &self.focus_signal.get())
2411 .finish()
2412 }
2413}
2414
2415impl TextFieldHandle {
2416 /// A handle not yet attached to any field — for a composing widget that
2417 /// hands one out before building the field it will delegate to. Every
2418 /// method answers "nothing" until [`TextInputField::share_handle`] binds it.
2419 pub fn detached() -> Self {
2420 Self {
2421 slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
2422 focus_signal: Signal::new(false),
2423 }
2424 }
2425
2426 /// `true` while this field holds the keyboard focus. Observable, so a
2427 /// router can follow the caret without polling.
2428 pub fn focused_signal(&self) -> Signal<bool> {
2429 self.focus_signal.clone()
2430 }
2431
2432 /// Is the widget built and still alive?
2433 pub fn is_live(&self) -> bool {
2434 self.slot.borrow().is_some()
2435 }
2436
2437 fn with<R>(&self, f: impl FnOnce(&mut TextInputState) -> R) -> Option<R> {
2438 let slot = self.slot.borrow();
2439 let state = slot.as_ref()?;
2440 let mut st = state.borrow_mut();
2441 Some(f(&mut st))
2442 }
2443
2444 /// The field's current text.
2445 pub fn text(&self) -> String {
2446 self.with(|st| st.document.to_plain_text().unwrap_or_default())
2447 .unwrap_or_default()
2448 }
2449
2450 /// Is any text selected right now?
2451 pub fn has_selection(&self) -> bool {
2452 self.with(|st| st.cursor.has_selection()).unwrap_or(false)
2453 }
2454
2455 /// May this field's content be copied at all? A password field says no —
2456 /// see [`TextInputField::allow_copy`].
2457 pub fn allows_copy(&self) -> bool {
2458 self.with(|st| st.allow_copy).unwrap_or(false)
2459 }
2460
2461 /// Is the field refusing edits? Cut and Paste are meaningless when it is.
2462 pub fn is_read_only(&self) -> bool {
2463 self.with(|st| st.read_only).unwrap_or(true)
2464 }
2465
2466 /// Select the whole field.
2467 pub fn select_all(&self) {
2468 self.with(|st| st.cursor.select(SelectionType::Document));
2469 }
2470
2471 /// Copy the selection to the clipboard.
2472 pub fn copy(&self, ctx: &EventContext) {
2473 self.with(|st| keyboard::clipboard_copy(st, ctx));
2474 }
2475
2476 /// Cut the selection to the clipboard.
2477 pub fn cut(&self, ctx: &EventContext) {
2478 self.with(|st| keyboard::clipboard_cut(st, ctx));
2479 }
2480
2481 /// Paste over the selection.
2482 pub fn paste(&self, ctx: &EventContext) {
2483 self.with(|st| keyboard::clipboard_paste(st, ctx));
2484 }
2485
2486 /// Undo this field's own last edit.
2487 pub fn undo(&self) {
2488 self.with(|st| {
2489 let _ = st.document.undo();
2490 });
2491 }
2492
2493 /// Redo this field's own last undone edit.
2494 pub fn redo(&self) {
2495 self.with(|st| {
2496 let _ = st.document.redo();
2497 });
2498 }
2499
2500 /// Is there anything to undo? Debounced like the editor's twin.
2501 pub fn can_undo(&self) -> Signal<bool> {
2502 self.with(|st| st.can_undo.clone())
2503 .unwrap_or_else(|| Signal::new(false))
2504 }
2505
2506 /// Is there anything to redo?
2507 pub fn can_redo(&self) -> Signal<bool> {
2508 self.with(|st| st.can_redo.clone())
2509 .unwrap_or_else(|| Signal::new(false))
2510 }
2511}
2512
2513// ── The framework's uniform view of a text-editing widget ────────────────────
2514
2515impl teksilo_core::text_surface::TextSurface for TextFieldHandle {
2516 fn can_undo(&self) -> bool {
2517 TextFieldHandle::can_undo(self).get()
2518 }
2519
2520 fn can_redo(&self) -> bool {
2521 TextFieldHandle::can_redo(self).get()
2522 }
2523
2524 fn undo(&self) {
2525 TextFieldHandle::undo(self);
2526 }
2527
2528 fn redo(&self) {
2529 TextFieldHandle::redo(self);
2530 }
2531
2532 fn has_selection(&self) -> bool {
2533 TextFieldHandle::has_selection(self)
2534 }
2535
2536 fn is_read_only(&self) -> bool {
2537 TextFieldHandle::is_read_only(self)
2538 }
2539
2540 fn allows_copy(&self) -> bool {
2541 TextFieldHandle::allows_copy(self)
2542 }
2543
2544 fn cut(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
2545 TextFieldHandle::cut(self, ctx);
2546 }
2547
2548 fn copy(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
2549 TextFieldHandle::copy(self, ctx);
2550 }
2551
2552 fn paste(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
2553 TextFieldHandle::paste(self, ctx);
2554 }
2555
2556 /// A one-line field carries no formatting to strip, so the plain paste
2557 /// *is* the paste. Answering "nothing" here would make Edit ▸ Paste without
2558 /// formatting silently dead over a rename box.
2559 fn paste_plain(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
2560 TextFieldHandle::paste(self, ctx);
2561 }
2562
2563 fn select_all(&self) {
2564 TextFieldHandle::select_all(self);
2565 }
2566}