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(crate) mod touch;
60pub mod validator;
61mod widget_impl;
62
63use std::rc::Rc;
64use teksilo_i18n::tr_widget;
65
66use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
67use teksilo_core::accessibility::AccessNodeBuilder;
68use teksilo_core::accessibility::text_runs::{RetainedText, TextRunSource, push_text_runs};
69use teksilo_core::build_context::BuildContext;
70use teksilo_core::event::{EventResponse, Key};
71use teksilo_core::shortcut::KeyStroke;
72use teksilo_core::signal::{Prop, Signal};
73use teksilo_core::widget::{
74 CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
75};
76use teksilo_core::widget_builder::HandlerSet;
77use teksilo_core::widget_id::WidgetId;
78use teksilo_text::text_document::{SelectionType, TextDocument};
79use teksilo_text::{CursorAffinity, CursorDisplay, RichTextEngine, SharedTypesetter};
80use teksilo_tokens::{InputTokens, TextStyle};
81
82use crate::button::InteractionState;
83use crate::keystroke_format::format_keystroke;
84use crate::menu_item::MenuItem;
85use crate::menu_list::{MenuList, MenuSeparator};
86use crate::rich_text::paint::{PaintParams, paint_frame};
87
88pub(crate) use self::state::{CharFilter, CommandFactory};
89use self::state::{SharedState, TextInputConfig, TextInputState, sync_cursor_signals};
90
91pub use self::mask::{InputMask, MaskClass, MaskError, MaskPosition};
92pub use self::validator::{ValidationFeedback, ValidationOutcome, ValidatorFn};
93
94// The caret blink period and the debounce window are shared with every other
95// text surface — see `common::editor_runtime`. They used to be re-declared
96// here as private constants ("same as RichTextEditor", said the comment),
97// which is exactly the kind of duplication that drifts silently: two carets
98// blinking at different rates is invisible to tests and obvious to users.
99use crate::common::editor_runtime::CaretPolicy;
100use teksilo_core::styles::density::spacing;
101
102/// Horizontal scroll margin in pixels. The caret stays at least this
103/// far from the left/right edge of the viewport.
104const SCROLL_MARGIN: f32 = 4.0;
105
106/// [`SCROLL_MARGIN`] scaled by the density's `spacing_factor`
107/// (1.00 / 1.15 / 1.30).
108fn scroll_margin(tokens: &InputTokens) -> f32 {
109 spacing(SCROLL_MARGIN, tokens)
110}
111
112/// Default text-area height when the caller does not override it
113/// via [`TextInputField::text_height`]. Sits close to the Int UI
114/// `TEXT_FIELD_HEIGHT` recipe constant (in
115/// `crate::styles::recipe_text_input_style`) minus its border and
116/// vertical padding — the 18 dp the `TextInput` composite passes —
117/// so a bare `TextInputField` added to a tree without its composite
118/// still looks right.
119const DEFAULT_TEXT_HEIGHT: f32 = 20.0;
120
121/// The semantic purpose of a text field, surfaced to assistive technology as
122/// a specialised AccessKit role (WCAG 1.3.5 Identify Input Purpose / EN 301 549).
123///
124/// This is the in-framework-achievable part of SC 1.3.5: a screen reader
125/// announces "email, edit text" instead of a generic "edit text". The FULL
126/// HTML `autocomplete`-token vocabulary (`given-name`, `postal-code`,
127/// `cc-number`, …) that drives OS/browser autofill has **no representation in
128/// AccessKit 0.24** and therefore cannot be exposed from Teksilo — see
129/// `docs/a11y/a11y_issues.md`. Password entry is configured via
130/// [`TextInputField::secure`], not here.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum InputPurpose {
133 /// Ordinary free text (`Role::TextInput`).
134 #[default]
135 Normal,
136 /// Email address (`Role::EmailInput`).
137 Email,
138 /// Telephone number (`Role::PhoneNumberInput`).
139 Phone,
140 /// URL (`Role::UrlInput`).
141 Url,
142 /// Numeric entry — e.g. a quantity or code (`Role::NumberInput`).
143 Number,
144 /// Search query (`Role::SearchInput`).
145 Search,
146}
147
148impl InputPurpose {
149 /// The AccessKit role for a non-secure field with this purpose.
150 pub(crate) fn to_role(self) -> teksilo_core::accesskit::Role {
151 use teksilo_core::accesskit::Role;
152 match self {
153 InputPurpose::Normal => Role::TextInput,
154 InputPurpose::Email => Role::EmailInput,
155 InputPurpose::Phone => Role::PhoneNumberInput,
156 InputPurpose::Url => Role::UrlInput,
157 InputPurpose::Number => Role::NumberInput,
158 InputPurpose::Search => Role::SearchInput,
159 }
160 }
161}
162
163/// How a secure ([`TextInputField::secure`]) field echoes typed
164/// characters. Mirrors Qt's `QLineEdit::EchoMode`.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub enum EchoMode {
167 /// Replace every character with the echo glyph (default `'•'`).
168 /// The plaintext stays in the bound `Signal<String>` but never
169 /// reaches the text engine while masked.
170 #[default]
171 Masked,
172 /// Show nothing at all — not even the length. The caret stays at
173 /// the start. Qt's `NoEcho`.
174 NoEcho,
175 /// Show plaintext while the field is focused (being edited) and
176 /// re-mask on blur. Qt's `PasswordEchoOnEdit`.
177 RevealWhileTyping,
178}
179
180/// How a *revealed* secure field reports to assistive technology.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182pub enum AtRevealPolicy {
183 /// When revealed, expose the field as a normal `Role::TextInput`
184 /// carrying the plaintext value — matching what is visibly on
185 /// screen and the web `type=password ↔ type=text` swap. When
186 /// masked, it reverts to `Role::PasswordInput`. (Default.)
187 #[default]
188 SwapRole,
189 /// Always report `Role::PasswordInput` and never expose plaintext
190 /// to assistive tech, even while visually revealed. Higher
191 /// confidentiality at the cost of consistency with the screen.
192 AlwaysProtected,
193}
194
195/// Editable single-line text surface primitive.
196///
197/// See the [module docs](self) for the full feature list and a
198/// compositional example.
199pub struct TextInputField {
200 // ── Configuration (builder methods, consumed in build) ───────────
201 text: Signal<String>,
202 /// Enabled state, static or reactive; forwarded to the arena at build
203 /// time.
204 enabled: Prop<bool>,
205 read_only: bool,
206 max_length: Option<usize>,
207 placeholder: String,
208 on_submit: Option<CommandFactory>,
209 on_access_set_value: Option<super::text_input_field::state::AccessSetValue>,
210 on_blur: Option<CommandFactory>,
211 char_filter: Option<CharFilter>,
212 /// Fixed trailing label rendered inside the field's border.
213 /// Accepts both plain strings and `Signal<String>` — when bound,
214 /// the field re-measures the suffix and relayouts each time the
215 /// signal fires, so composites like `SpinBox` can derive the
216 /// suffix from the widget state (e.g. hide it while
217 /// `special_value_text` is active).
218 suffix: Prop<String>,
219 text_height: Option<f32>,
220 external_interaction: Option<Signal<InteractionState>>,
221
222 /// Optional input mask. When set, the field auto-derives a
223 /// placeholder template (`__/__/____` for `99/99/9999`) and
224 /// rejects non-fitting characters via a position-aware filter
225 /// composed with the user's `char_filter`. See [`InputMask`] for
226 /// the grammar.
227 mask: Option<InputMask>,
228 /// Visible char used for unfilled editable positions in the mask
229 /// template. Defaults to the `TEXT_FIELD_MASK_PLACEHOLDER_CHAR`
230 /// recipe constant (in `crate::styles::recipe_text_input_style`,
231 /// `_`).
232 mask_placeholder_override: Option<char>,
233 /// Validator closure called on every commit (Enter, Tab-out,
234 /// blur). Returns a [`ValidationOutcome`] that drives
235 /// [`feedback`](Self::validation_feedback_signal).
236 validator: Option<ValidatorFn>,
237 /// Published feedback signal. Composites bind to this to render
238 /// the inline validation strip below the field.
239 feedback: Signal<ValidationFeedback>,
240
241 // ── Secure / password masking (set via `secure`) ────────────────
242 secure: bool,
243 echo_mode: EchoMode,
244 echo_char: char,
245 revealed: Option<Signal<bool>>,
246 at_reveal_policy: AtRevealPolicy,
247 allow_copy: bool,
248
249 /// Semantic purpose → specialised AT role (WCAG 1.3.5). Ignored while the
250 /// field is `secure` (password role wins).
251 input_purpose: InputPurpose,
252
253 /// ARIA combobox wiring — see [`active_descendant`](Self::active_descendant).
254 active_descendant: Option<Signal<Option<WidgetId>>>,
255 /// The listbox this field drives, if any — see
256 /// [`controls`](Self::controls).
257 controls: Option<Signal<Option<WidgetId>>>,
258
259 // ── Internal (set during build) ─────────────────────────────────
260 state: Option<SharedState>,
261 /// Interaction signal actually used at runtime. Either the one
262 /// supplied by a wrapping composite via
263 /// [`TextInputField::interaction_signal`] or a fresh one owned
264 /// by the field. Read by the focus handler to repaint a
265 /// parent's focus ring / border on gain/loss.
266 interaction: Signal<InteractionState>,
267 /// Mirror of the inner state's `cursor_position` for external
268 /// readers. Wired in `build()` via a `ctx.effect`. Composing
269 /// widgets that need the caret (e.g. `DateEdit` for segment
270 /// stepping) read this via [`TextInputField::caret_position`].
271 caret_position: Signal<usize>,
272 /// Late-bound handle to the inner `SharedState`, populated in
273 /// `build()`. Lets composing widgets capture a `caret_setter`
274 /// closure BEFORE the field is moved into the tree, then call
275 /// it later to programmatically reposition the caret. Required
276 /// because the inner state doesn't exist before `build()` runs,
277 /// but the composing widget loses ownership of `self` once it
278 /// hands the field to `ctx.add(...)`.
279 state_slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>>,
280 /// Minted with the widget, not with its state, so a [`TextFieldHandle`]
281 /// taken before `build` observes the signal the built widget writes.
282 focus_signal: Signal<bool>,
283 /// Touch selection: the controller, and the ids of the two overlays it
284 /// raises. Minted with the widget rather than in `build` so the handle
285 /// survives a rebuild even though the controller inside it is replaced.
286 /// See [`touch`].
287 pub(crate) touch: Rc<touch::FieldTouch>,
288 /// Natural intrinsic width in logical pixels, cached during
289 /// `build()`. When an [`InputMask`] is set, this measures the
290 /// mask's worst-case filled template (e.g. `00/00/0000`, plus a
291 /// safety `M`) in the theme body font and adds a small caret
292 /// slack — so a date / time / phone field reports a width that
293 /// matches its content envelope instead of the generic 200 dp
294 /// fallback. Composing widgets like `DateEdit` rely on this so
295 /// their unconstrained natural width tracks the format pattern.
296 natural_width: f32,
297 /// What the field's text measured, kept for the accessibility pass.
298 ///
299 /// A reader reviewing the field by character, word or line needs
300 /// per-character extents, and the editing engine's own layout is not
301 /// reachable from `accessibility()`. Written by `place_children` — the
302 /// first pass that knows the final width — and again by `paint`, because
303 /// a keystroke dirties the field at `RepaintOnly` / `AccessibilityOnly`
304 /// and never relayouts: geometry taken from `place_children` alone would
305 /// be one edit stale for as long as anyone is typing.
306 retained: Rc<std::cell::RefCell<Option<RetainedText>>>,
307}
308
309impl std::fmt::Debug for TextInputField {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 f.debug_struct("TextInputField")
312 .field("placeholder", &self.placeholder)
313 .field("enabled", &self.enabled.get())
314 .field("read_only", &self.read_only)
315 .finish_non_exhaustive()
316 }
317}
318
319impl TextInputField {
320 /// Construct a new field bound to `text`.
321 pub fn new(text: Signal<String>) -> Self {
322 let state_slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>> =
323 std::rc::Rc::new(std::cell::RefCell::new(None));
324 Self {
325 text,
326 enabled: Prop::Static(true),
327 read_only: false,
328 max_length: None,
329 placeholder: String::new(),
330 on_submit: None,
331 on_access_set_value: None,
332 on_blur: None,
333 char_filter: None,
334 suffix: Prop::Static(String::new()),
335 text_height: None,
336 external_interaction: None,
337 mask: None,
338 mask_placeholder_override: None,
339 validator: None,
340 feedback: Signal::new(ValidationFeedback::Pristine),
341 secure: false,
342 echo_mode: EchoMode::Masked,
343 echo_char: '\u{2022}',
344 revealed: None,
345 at_reveal_policy: AtRevealPolicy::SwapRole,
346 allow_copy: true,
347 input_purpose: InputPurpose::Normal,
348 active_descendant: None,
349 controls: None,
350 state: None,
351 interaction: Signal::new(InteractionState::Idle),
352 caret_position: Signal::new(0),
353 state_slot: state_slot.clone(),
354 focus_signal: Signal::new(false),
355 touch: touch::FieldTouch::new(state_slot),
356 natural_width: 200.0,
357 retained: Rc::new(std::cell::RefCell::new(None)),
358 }
359 }
360
361 /// Declarative placeholder string. The field itself paints
362 /// nothing for placeholder — that visual is the composite
363 /// parent's responsibility (`TextInput` overlays a
364 /// `TextWidget`). The string is still stored here and published
365 /// via AccessKit's `placeholder` property so screen readers
366 /// announce it.
367 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
368 self.placeholder = text.into();
369 self
370 }
371
372 /// Set the enabled state, statically or reactively. Disabled blocks
373 /// input and AccessKit interaction. Forwarded to the arena at build
374 /// time.
375 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
376 self.enabled = enabled.into();
377 self
378 }
379
380 /// Mark the field read-only. Caret and selection still work;
381 /// inserts, deletes, paste, undo/redo, and cut are all no-ops.
382 pub fn read_only(mut self, read_only: bool) -> Self {
383 self.read_only = read_only;
384 self
385 }
386
387 /// Hard cap on document length in `char`s (grapheme count is
388 /// approximated — each `char` counts as one unit, matching
389 /// `String::chars().count()`).
390 pub fn max_length(mut self, max_length: usize) -> Self {
391 self.max_length = Some(max_length);
392 self
393 }
394
395 /// Closure fired on `Enter`. Unlike `on_blur_fn`, this does
396 /// not move focus — the field stays focused and the caret
397 /// stays where it was.
398 pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
399 self.on_submit = Some(Box::new(f));
400 self
401 }
402
403 /// Handle an assistive technology's whole-value write, given the string it
404 /// set.
405 ///
406 /// Leave it unset for a field whose bound `Signal<String>` **is** the
407 /// value: the write has already landed and there is nothing to derive.
408 ///
409 /// Install one for a field whose text is a *projection* of a typed value,
410 /// as `SpinBox` and the date and time editors are. There the string is
411 /// only a display of the real value, so without this an
412 /// `Action::SetValue` resolved against the inner text node changes what is
413 /// shown, leaves the typed value stale until the next blur, and never
414 /// fires the host's change callback — an assistive technology or an
415 /// automation client sees a success and the wrong value. The composite's
416 /// own node handles `SetValue` properly; this closes the same door on the
417 /// text node beneath it.
418 ///
419 /// The string is handed over rather than read back from the bound signal
420 /// because the document→signal sync is deferred to the next frame tick, so
421 /// a host reading the signal here would parse the text from *before* this
422 /// edit and revert.
423 pub fn on_access_set_value(
424 mut self,
425 f: impl Fn(&str, &mut EventContext) -> bool + 'static,
426 ) -> Self {
427 self.on_access_set_value = Some(std::rc::Rc::new(f));
428 self
429 }
430
431 /// Closure fired once per focus-loss, after selection/scroll
432 /// have been reset. SpinBox-style callers parse and reformat
433 /// here; validators revalidate here.
434 pub fn on_blur_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
435 self.on_blur = Some(Box::new(f));
436 self
437 }
438
439 /// Per-character input-filter predicate. Applied uniformly to
440 /// keyboard input, IME commits, and clipboard paste so a filtered
441 /// field cannot receive disallowed characters through any path.
442 /// Composes with `max_length` and the built-in control/newline
443 /// strip (filter runs after the strip). Whole-string validity
444 /// (e.g. "at most one decimal point") is a commit-time concern
445 /// for `on_blur` / `on_submit`.
446 pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
447 self.char_filter = Some(Rc::new(f));
448 self
449 }
450
451 /// Static non-editable trailing string rendered flush-right
452 /// inside the field's bounds (Qt's `QSpinBox::suffix`). The
453 /// caret cannot enter the suffix; clicks past the text end
454 /// position the caret at the last editable character.
455 ///
456 /// Accepts a static `String`/`&str` or a reactive `Signal<String>` /
457 /// `Prop<String>`; when bound, the field re-measures the suffix glyphs
458 /// and relayouts the editable text viewport each time the signal fires.
459 /// Typical use: a `SpinBox` with `special_value_text` binds an empty
460 /// string to the suffix whenever the value equals `min`, and the
461 /// configured unit string otherwise.
462 pub fn suffix(mut self, text: impl Into<Prop<String>>) -> Self {
463 self.suffix = text.into();
464 self
465 }
466
467 /// Override the intrinsic text-area height. The field is a
468 /// pure leaf with no theme lookup of its own; by default it
469 /// reports `DEFAULT_TEXT_HEIGHT`. A wrapping composite like
470 /// `TextInput` passes the `TEXT_FIELD_HEIGHT` recipe constant
471 /// (in `crate::styles::recipe_text_input_style`) minus
472 /// border + padding here so the visuals line up with the
473 /// rest of the form.
474 pub fn text_height(mut self, height: f32) -> Self {
475 self.text_height = Some(height);
476 self
477 }
478
479 /// Bind an externally-owned `InteractionState` signal. The
480 /// field writes `Focused` on focus gain and `Idle` on loss;
481 /// other states (`Hovered`, `Pressed`, `Disabled`) are the
482 /// composite's responsibility. When unset, the field owns a
483 /// private signal that observers can still read via
484 /// [`interaction`](TextInputField::interaction), but composites
485 /// that drive a focus ring or border color usually want to
486 /// push their own.
487 pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
488 self.external_interaction = Some(signal);
489 self
490 }
491
492 /// Set an input mask (Qt grammar). Constrains accepted characters
493 /// per position, auto-derives the empty-state template
494 /// (`__/__/____` for `99/99/9999`), and routes typed chars
495 /// through the mask's class filter.
496 ///
497 /// Composes with [`char_filter`](Self::char_filter): a char must
498 /// pass *both* the mask's per-position class AND the user's
499 /// `char_filter` to be accepted.
500 ///
501 /// On parse error (only the trailing-backslash case in practice),
502 /// the mask is silently dropped — the field falls back to its
503 /// no-mask behaviour rather than panicking.
504 pub fn input_mask(mut self, mask: impl AsRef<str>) -> Self {
505 match InputMask::parse(mask.as_ref()) {
506 Ok(m) => self.mask = Some(m),
507 Err(_) => self.mask = None,
508 }
509 self
510 }
511
512 /// Override the visible character used for unfilled editable mask
513 /// positions. Default: the `TEXT_FIELD_MASK_PLACEHOLDER_CHAR`
514 /// recipe constant (in `crate::styles::recipe_text_input_style`,
515 /// `_`).
516 pub fn mask_placeholder(mut self, c: char) -> Self {
517 self.mask_placeholder_override = Some(c);
518 self
519 }
520
521 /// Install a validator. The closure runs on every commit (Enter,
522 /// Tab-out, focus loss) and returns a [`ValidationOutcome`] that
523 /// drives [`validation_feedback_signal`](Self::validation_feedback_signal).
524 ///
525 /// **Does not run per-keystroke** — that's [`char_filter`](Self::char_filter)'s
526 /// job. Mixing per-keystroke text rewriting with validation
527 /// produces caret-jump bugs and is explicitly out of scope.
528 pub fn validator(mut self, f: impl Fn(&str) -> ValidationOutcome + 'static) -> Self {
529 self.validator = Some(Rc::new(f));
530 self
531 }
532
533 /// Turn this into a secure (password) field with the given
534 /// [`EchoMode`]. Masking happens at the text-engine layer (one echo
535 /// glyph per source `char`), so the plaintext never reaches the
536 /// shaper or glyph atlas while masked, and caret / selection /
537 /// hit-test stay correct. Also defaults `allow_copy` to `false` and
538 /// declares the focused node an `ImePurpose::Password` surface — the
539 /// OS IME stays enabled so non-Latin passwords can still be composed,
540 /// with the preedit masked on screen and hidden from AT. Pair with
541 /// [`revealed`](Self::revealed) for a reveal toggle.
542 pub fn secure(mut self, echo_mode: EchoMode) -> Self {
543 self.secure = true;
544 self.echo_mode = echo_mode;
545 self.allow_copy = false;
546 self
547 }
548
549 /// Declare the field's semantic [`InputPurpose`] (WCAG 1.3.5), which
550 /// selects a specialised AccessKit role (`EmailInput`, `PhoneNumberInput`,
551 /// …) so screen readers announce the field's kind. Ignored while `secure`
552 /// (the password role wins). Does not change IME behaviour — winit's
553 /// `ImePurpose` has no email/number/url variants — nor drive OS autofill,
554 /// which AccessKit cannot express (see `docs/a11y/a11y_issues.md`).
555 pub fn input_purpose(mut self, purpose: InputPurpose) -> Self {
556 self.input_purpose = purpose;
557 self
558 }
559
560 /// Publish `active_descendant` pointing at the row a *separate* list is
561 /// currently highlighting — the ARIA combobox pattern.
562 ///
563 /// Keyboard focus stays in this field while arrow keys move a highlight
564 /// through a listbox elsewhere in the tree (a command palette, a
565 /// type-ahead picker, a suggestion popup). Assistive technology follows
566 /// the focused node's active descendant, so the announcement has to be
567 /// published **here**, on the node that actually holds focus — not on the
568 /// composite ancestor that owns the list. Without it the arrow keys move a
569 /// highlight that is announced to nobody.
570 ///
571 /// Bound at `AccessibilityOnly`, so moving the highlight re-walks the AT
572 /// tree without a rebuild or a repaint. Pair with [`controls`](Self::controls).
573 pub fn active_descendant(mut self, active: Signal<Option<WidgetId>>) -> Self {
574 self.active_descendant = Some(active);
575 self
576 }
577
578 /// Publish a `controls` relation to the listbox this field drives, so an
579 /// AT client can navigate from the input to the list it is filtering.
580 /// The companion of [`active_descendant`](Self::active_descendant).
581 pub fn controls(mut self, listbox: Signal<Option<WidgetId>>) -> Self {
582 self.controls = Some(listbox);
583 self
584 }
585
586 /// Override the masking glyph (default `'•'`, U+2022). Any
587 /// uniform-width character works; the engine emits exactly one per
588 /// source `char`.
589 pub fn echo_char(mut self, c: char) -> Self {
590 self.echo_char = c;
591 self
592 }
593
594 /// Bind the reveal toggle. When the signal is `true` the field
595 /// shows plaintext regardless of [`EchoMode`]; when `false` it
596 /// masks. Shared with the eye [`IconButton::visibility_toggle`].
597 ///
598 /// [`IconButton::visibility_toggle`]: crate::IconButton::visibility_toggle
599 pub fn revealed(mut self, revealed: Signal<bool>) -> Self {
600 self.revealed = Some(revealed);
601 self
602 }
603
604 /// How a *revealed* secure field reports to assistive tech. Default
605 /// [`AtRevealPolicy::SwapRole`].
606 pub fn at_reveal_policy(mut self, policy: AtRevealPolicy) -> Self {
607 self.at_reveal_policy = policy;
608 self
609 }
610
611 /// Permit (or forbid) copy / cut. Plain fields default `true`;
612 /// [`secure`](Self::secure) flips the default to `false`. Even when
613 /// `false`, copy is allowed while the field is revealed.
614 pub fn allow_copy(mut self, allow: bool) -> Self {
615 self.allow_copy = allow;
616 self
617 }
618
619 /// Reactive handle on the published [`ValidationFeedback`] state.
620 /// Composites bind to this to render the inline feedback strip
621 /// below the field. Always present; reads `Pristine` until the
622 /// first commit (or forever if no validator is installed).
623 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
624 self.feedback.clone()
625 }
626
627 /// The `Signal<String>` this field is bound to.
628 pub fn text(&self) -> Signal<String> {
629 self.text.clone()
630 }
631
632 /// Adopt an existing handle instead of minting one.
633 ///
634 /// For a composing widget — `TextInput` wraps this field — that must hand
635 /// out a handle of its own **before** it builds the field it will delegate
636 /// to. Sharing the slot and the focus signal makes the wrapper's handle and
637 /// the field's the same handle, rather than two that agree by accident.
638 pub fn share_handle(mut self, handle: &TextFieldHandle) -> Self {
639 self.state_slot = handle.slot.clone();
640 self.focus_signal = handle.focus_signal.clone();
641 self
642 }
643
644 /// A live handle on this field, valid before and after `build`.
645 ///
646 /// The counterpart of `RichTextEditor::handle`, and the reason it exists:
647 /// an application that routes Undo, Cut, Copy, Paste and Select All to
648 /// "whichever text surface holds the caret" has to be able to *drive* every
649 /// such surface, not only the rich editors. Without this, a menu built for
650 /// those commands can only grey them out over a rename field or a search
651 /// box while the field's own key handling still works — a menu that lies
652 /// about what the keyboard can do.
653 ///
654 /// Like `caret_setter`, the handle reaches its state through the slot the
655 /// widget late-populates, so it may be taken while the tree is being
656 /// described and used once it is live.
657 pub fn handle(&self) -> TextFieldHandle {
658 TextFieldHandle {
659 slot: self.state_slot.clone(),
660 focus_signal: self.focus_signal.clone(),
661 }
662 }
663
664 /// The interaction signal this field writes on focus changes.
665 /// Call before inserting the field into the tree.
666 pub fn interaction(&self) -> Signal<InteractionState> {
667 self.interaction.clone()
668 }
669
670 /// Reactive caret position in the field's text (in `usize` char
671 /// offsets). Updates after every keyboard or pointer action that
672 /// moves the cursor. Used by composing widgets that need to know
673 /// where the caret is — e.g. `DateEdit` reads this to figure out
674 /// which date segment Up/Down should step.
675 pub fn caret_position(&self) -> Signal<usize> {
676 self.caret_position.clone()
677 }
678
679 /// Returns a callable that programmatically sets the caret
680 /// position (in char offsets) on the field. Capture this on the
681 /// builder BEFORE `ctx.add(...)` consumes the field; call it
682 /// after a programmatic text rewrite to restore the caret to the
683 /// right column instead of leaving it at the document end (the
684 /// default behaviour of `cursor.insert_text`).
685 ///
686 /// The returned closure becomes a no-op until `build()` runs;
687 /// after build it walks the field's inner state and moves the
688 /// document cursor to `position`, clamped to the document
689 /// length. Used by `DateEdit` / `TimeEdit` segment-stepping to
690 /// keep the caret within its current segment after Up/Down.
691 pub fn caret_setter(&self) -> std::rc::Rc<dyn Fn(usize)> {
692 let slot = self.state_slot.clone();
693 std::rc::Rc::new(move |position: usize| {
694 if let Some(state) = slot.borrow().as_ref() {
695 let st = state.borrow();
696 st.cursor
697 .set_position(position, teksilo_text::text_document::MoveMode::MoveAnchor);
698 let actual = st.cursor.position();
699 if st.cursor_position.get() != actual {
700 st.cursor_position.set(actual);
701 }
702 }
703 })
704 }
705}
706
707impl TextInputField {
708 /// Shape the field's text once more and keep the geometry.
709 ///
710 /// The whole line is measured with no width cap: the field scrolls
711 /// rather than ellipsizes, so every character has an extent even while
712 /// it sits outside the viewport, and `accessibility` slides the result
713 /// by the scroll offset.
714 fn retain_text_geometry(
715 &self,
716 bounds: Rect,
717 style: &TextStyle,
718 backend: &Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>,
719 base_direction: teksilo_core::accesskit::TextDirection,
720 ) {
721 let Some(state) = self.state.as_ref() else {
722 return;
723 };
724 let (text, masked) = {
725 let st = state.borrow();
726 (
727 st.document.to_plain_text().unwrap_or_default(),
728 st.should_mask(),
729 )
730 };
731 if masked {
732 // Masking happens inside the editing engine precisely so the
733 // secret never reaches the shaper or the glyph atlas; measuring
734 // it here would put it there. A masked field is also
735 // `Role::PasswordInput`, whose branch emits no runs to carry
736 // geometry anyway.
737 *self.retained.borrow_mut() = None;
738 return;
739 }
740 let layout = backend.borrow_mut().layout_single_line(&text, style, None);
741 *self.retained.borrow_mut() = Some(RetainedText {
742 text,
743 geometry: layout.geometry.clone(),
744 bounds,
745 base_direction,
746 });
747 }
748
749 /// Borrow the shared state. Panics if called before `build()`
750 /// has run — the state is allocated in `build()` from the
751 /// builder config.
752 fn state(&self) -> &SharedState {
753 self.state
754 .as_ref()
755 .expect("TextInputField::state called before build")
756 }
757}
758
759/// The reading direction the field's runs are announced with.
760///
761/// The single-line editing engine reports no per-segment direction, so the
762/// ambient layout direction is the only answer available; a right-to-left
763/// field therefore announces right-to-left even for Latin content, which is
764/// what the surrounding UI does too.
765fn base_text_direction(
766 direction: teksilo_core::environment::LayoutDirection,
767) -> teksilo_core::accesskit::TextDirection {
768 match direction {
769 teksilo_core::environment::LayoutDirection::RightToLeft => {
770 teksilo_core::accesskit::TextDirection::RightToLeft
771 }
772 _ => teksilo_core::accesskit::TextDirection::LeftToRight,
773 }
774}
775
776/// Adjust `scroll_x` so the caret stays within the visible viewport.
777///
778/// `text_viewport_width` is the portion of the viewport reserved for
779/// editable text, i.e. `viewport_width - suffix_width`. Callers pass
780/// the reduced width explicitly so the scroll never slides text
781/// behind the non-editable suffix.
782fn ensure_caret_visible_h(st: &mut TextInputState, text_viewport_width: f32, tokens: &InputTokens) {
783 if !st.engine.has_full_layout() || text_viewport_width <= 0.0 {
784 return;
785 }
786 let pos = st.cursor.position();
787 // Single-line input: no wrap, affinity is a no-op.
788 let caret = st.engine.caret_rect(pos, CursorAffinity::Downstream);
789 let caret_x = caret[0];
790 let caret_w = caret[2].max(1.0);
791 let vw = text_viewport_width;
792
793 let margin = scroll_margin(tokens);
794 if caret_x - st.scroll_x < margin {
795 st.scroll_x = (caret_x - margin).max(0.0);
796 } else if caret_x + caret_w - st.scroll_x > vw - margin {
797 st.scroll_x = caret_x + caret_w - vw + margin;
798 }
799}
800
801/// Update the cached suffix text and re-run layout on the suffix
802/// engine. Called from `build()` for the initial value and from
803/// the reactive effect when the bound suffix signal fires.
804fn relayout_suffix(state: &SharedState, new_text: &str) {
805 let mut st = state.borrow_mut();
806 st.suffix = new_text.to_string();
807 if new_text.is_empty() {
808 st.suffix_width = 0.0;
809 // Leave the engine in place (cheap to reuse) but don't
810 // lay out — paint skips the suffix when width is zero.
811 return;
812 }
813 let Some(engine) = st.suffix_engine.as_mut() else {
814 // No engine allocated (pure-static path that started
815 // empty and never became non-empty). Nothing to lay out —
816 // `build()` allocates the engine eagerly for every bound
817 // suffix, so a late signal flip never reaches this branch.
818 return;
819 };
820 let doc = TextDocument::new();
821 let _ = doc.set_plain_text(new_text);
822 let flow = doc.snapshot_flow();
823 engine.layout_full(&flow);
824 st.suffix_width = engine.max_content_width();
825}
826
827/// Paint glyphs from a pre-laid-out suffix `RenderFrame` at a fixed
828/// origin. Decorations, selection rectangles, and caret are ignored —
829/// the suffix is plain non-editable text, so only the glyph pass is
830/// needed. Kept inline (rather than reusing `paint_frame`) to avoid
831/// the `TextDocument` / `ImageCache` parameters `paint_frame`
832/// requires for inline images the suffix never contains.
833fn paint_suffix_glyphs(canvas: &mut Canvas, frame: &teksilo_text::RenderFrame, origin: Point) {
834 use teksilo_canvas::GlyphQuad as CanvasGlyphQuad;
835 for g in frame.glyphs.iter() {
836 let quad = CanvasGlyphQuad {
837 screen: [
838 g.screen[0] + origin.x,
839 g.screen[1] + origin.y,
840 g.screen[2],
841 g.screen[3],
842 ],
843 atlas: g.atlas,
844 color: g.color,
845 is_color: g.is_color,
846 };
847 canvas.draw_glyph_quad(quad);
848 }
849}
850
851/// The band a selection is painted in. Two axes decide it, and they do *not*
852/// decide it the same way:
853///
854/// | field focus | window | band |
855/// | --- | --- | --- |
856/// | focused | active | vivid `selection_bg_active` |
857/// | focused | inactive | muted `selection_bg_inactive` |
858/// | not focused | either | nothing — fully transparent |
859///
860/// **An entry that does not hold focus paints no selection**, which is what
861/// every native single-line field does. A Win32 edit control hides the
862/// selection on focus-out unless it was created with `ES_NOHIDESEL`, and
863/// WinForms spells the same default `TextBoxBase.HideSelection = true`.
864/// `QLineEdit::focusOutEvent` goes further and calls `deselect()` outright for
865/// every focus reason except `ActiveWindowFocusReason` and `PopupFocusReason`.
866/// On macOS an `NSTextField` that stops being first responder has its shared
867/// field editor detached, so there is no selection left to draw. GTK's entry is
868/// the one toolkit that keeps a defocused selection lit, and that has been
869/// filed against it as a papercut rather than defended as a design.
870///
871/// The *window* axis is the one where dimming, not hiding, is correct — and
872/// the same three toolkits say so: Qt's carve-out for `ActiveWindowFocusReason`
873/// exists precisely so a focused field keeps its selection when the window goes
874/// to the background, and AppKit renders it there in
875/// `unemphasizedSelectedTextBackgroundColor`. Losing the window is not the same
876/// event as losing the caret.
877///
878/// Multi-line editors (`RichTextEditor`, `CodeEditor`, `LogView`) are
879/// deliberately **not** on this rule: `QTextEdit` / `NSTextView` / every code
880/// editor keep a visible selection in a blurred view, because there the
881/// selection is a region of a document the user is working with rather than a
882/// transient edit state.
883///
884/// The selection *state* survives blur either way — the `on_focus(false)` arm
885/// spells out why (the right-click Copy path needs it) — this decides only what
886/// is drawn.
887fn field_selection_color(
888 colors: &teksilo_tokens::ColorTokens,
889 window_active: bool,
890 has_focus: bool,
891) -> [f32; 4] {
892 match (has_focus, window_active) {
893 (false, _) => [0.0; 4],
894 (true, true) => colors.selection_bg_active.to_array(),
895 (true, false) => colors.selection_bg_inactive.to_array(),
896 }
897}
898
899/// Simplified frame-loop tick for single-line text input.
900fn tick(state: &mut TextInputState, delta: f32) -> bool {
901 if !state.pending_chars.is_empty() {
902 let batch = std::mem::take(&mut state.pending_chars);
903 let _ = state.cursor.insert_text(&batch);
904 state.pending_text_changed = true;
905 }
906
907 let had_events = state.drain_events();
908
909 // Blink only when focused AND the host window is active — the caret hides
910 // in an inactive window (the universal desktop convention). The shared
911 // blink machine's inactive arm then turns it off, since `!caret_active`
912 // now also covers the window-inactive case.
913 let caret_active = state.has_focus && state.window_active;
914 let caret_visible = state.caret_visible.clone();
915 let wake = state.frame_wake_at.clone();
916 // A single-line field always blinks (no read-only/static presets), so it
917 // hands the shared machine a fixed `Blinking` policy.
918 state.blink.tick(
919 CaretPolicy::Blinking,
920 caret_active,
921 &caret_visible,
922 wake.as_ref(),
923 );
924
925 if state.needs_full_layout && state.viewport_width > 0.0 {
926 state.layout_full_masked();
927 state.needs_full_layout = false;
928 state.content_dirty = true;
929 }
930
931 if state.pending_text_changed {
932 let new_text = state.document.to_plain_text().unwrap_or_default();
933 if state.text_signal.get() != new_text {
934 state.deferred_text_update = Some(new_text);
935 }
936 }
937
938 if state.debounce.tick(delta) {
939 if state.pending_text_changed {
940 state.pending_text_changed = false;
941 }
942 if let Some((cu, cr)) = state.pending_undo_redo.take() {
943 if state.can_undo.get() != cu {
944 state.can_undo.set(cu);
945 }
946 if state.can_redo.get() != cr {
947 state.can_redo.set(cr);
948 }
949 }
950 }
951 let debounce_work = state.pending_text_changed || state.pending_undo_redo.is_some();
952
953 had_events || debounce_work
954}
955
956/// Handle AccessKit actions (SetValue, SetTextSelection, Focus).
957fn handle_access_action(
958 state: &SharedState,
959 action: teksilo_core::accesskit::Action,
960 data: Option<teksilo_core::accesskit::ActionData>,
961 ctx: &mut EventContext,
962) -> EventResponse {
963 use teksilo_core::accesskit::{Action, ActionData};
964
965 match (action, data) {
966 (Action::SetTextSelection, Some(ActionData::SetTextSelection(sel))) => {
967 let st = state.borrow();
968 st.cursor.set_position(
969 sel.anchor.character_index,
970 teksilo_text::text_document::MoveMode::MoveAnchor,
971 );
972 st.cursor.set_position(
973 sel.focus.character_index,
974 teksilo_text::text_document::MoveMode::KeepAnchor,
975 );
976 drop(st);
977 sync_cursor_signals(state);
978 ctx.request_frame();
979 EventResponse::Handled
980 }
981 // Both write arms are gated on the field being editable. The node
982 // advertises neither action while `read_only` is set, but an adapter
983 // dispatches what the technology asks for rather than what the node
984 // offered — AT-SPI publishes `EditableText` off the interface set, not
985 // off the action list — so a read-only `SpinBox`, `DateEdit`,
986 // `TimeEdit`, `DateTimeEdit` or `DateRangeEdit` had its value rewritten
987 // by anything that tried. Refusing here is the only place that covers
988 // every host at once, and it reports the refusal instead of a silent
989 // no-op.
990 (Action::SetValue | Action::ReplaceSelectedText, _) if state.borrow().read_only => {
991 EventResponse::Ignored
992 }
993 (Action::SetValue, Some(ActionData::Value(value))) => {
994 // Snapshot the document before overwriting it. A host that refuses
995 // the string has to be able to put the field back, and it cannot do
996 // it from its own side: its revert writes the bound
997 // `Signal<String>`, which still holds the *pre-edit* display at
998 // this point (the document→signal sync is deferred to the next
999 // frame tick), so the write is a no-op and the rejected string is
1000 // what the deferred sync then publishes.
1001 let before = {
1002 let st = state.borrow();
1003 st.document.to_plain_text().unwrap_or_default()
1004 };
1005 let st = state.borrow();
1006 st.cursor.select(SelectionType::Document);
1007 let _ = st.cursor.insert_text(value.as_ref());
1008 // A composite whose text only *projects* a typed value handles the
1009 // write itself, because an assistive technology's `SetValue` is a
1010 // finished edit, not a keystroke: without it a `SpinBox` would
1011 // show the new number, keep the old value until the next blur, and
1012 // never fire `on_value_changed`. It is handed the string, not left
1013 // to read the bound signal, which this edit has not synced yet.
1014 let host = st.on_access_set_value.clone();
1015 drop(st);
1016 sync_cursor_signals(state);
1017 ctx.request_frame();
1018 // The host owns the verdict: it parses, clamps and — on a string it
1019 // cannot read — reverts the display to the value the composite
1020 // still holds. Reporting `Handled` regardless told the technology a
1021 // write had landed when the field had just thrown it away, so
1022 // Orca's value entry and macOS's `setAccessibilityValue:` both read
1023 // back success on `"twelve"`.
1024 match host {
1025 Some(host) if !host(value.as_ref(), ctx) => {
1026 // Refused, so the field must not keep the refused string:
1027 // reporting `Ignored` over a document still showing it is
1028 // the same lie the other way round.
1029 let st = state.borrow();
1030 st.cursor.select(SelectionType::Document);
1031 let _ = st.cursor.insert_text(&before);
1032 drop(st);
1033 sync_cursor_signals(state);
1034 EventResponse::Ignored
1035 }
1036 _ => EventResponse::Handled,
1037 }
1038 }
1039 (Action::ReplaceSelectedText, Some(ActionData::Value(value))) => {
1040 // Insert at the caret, replacing the active selection (if
1041 // any) — NOT the whole document like `SetValue`. This is the
1042 // AT-SPI (Linux) / UIA (Windows) braille-keyboard and
1043 // dictation insertion path; macOS routes insertion through
1044 // `SetValue` instead, so this never fires there. We advertise
1045 // the action in `accessibility()`, so we must service it.
1046 let st = state.borrow();
1047 let _ = st.cursor.insert_text(value.as_ref());
1048 drop(st);
1049 sync_cursor_signals(state);
1050 ctx.request_frame();
1051 EventResponse::Handled
1052 }
1053 (Action::Focus, _) => {
1054 if let Some(id) = state.borrow().field_widget_id {
1055 ctx.request_focus(id);
1056 }
1057 EventResponse::Handled
1058 }
1059 _ => EventResponse::Ignored,
1060 }
1061}
1062
1063/// Build a fresh right-click context menu widget. Called from the
1064/// `.context_menu(...)` factory on every right-click, so each open
1065/// reads live `has_selection` / `is_empty` state when computing each
1066/// item's enabled flag.
1067fn build_context_menu_widget(state: &SharedState) -> Box<dyn Widget> {
1068 let st = state.borrow();
1069 let has_selection = st.cursor.has_selection();
1070 let doc_non_empty = !st.document.to_plain_text().unwrap_or_default().is_empty();
1071 // Secure fields suppress Cut / Copy while masked (still allowed when
1072 // revealed or when the developer opted in via `allow_copy`).
1073 let copy_allowed = st.copy_allowed();
1074 drop(st);
1075
1076 Box::new(
1077 MenuList::new()
1078 .item(menu_row_cut(state).enabled(has_selection && copy_allowed))
1079 .item(menu_row_copy(state).enabled(has_selection && copy_allowed))
1080 .item(menu_row_paste(state))
1081 .item(MenuSeparator)
1082 .item(menu_row_select_all(state).enabled(doc_non_empty)),
1083 )
1084}
1085
1086// The four command rows, shared by the right-click menu above and the touch
1087// selection toolbar in `touch.rs`. One implementation of each command, reached
1088// two ways — the menu decides enablement from a snapshot taken as it opens, the
1089// toolbar decides *visibility* from the controller's derived
1090// `ClipboardActions`, and neither owns the command itself.
1091
1092pub(crate) fn menu_row_cut(state: &SharedState) -> MenuItem {
1093 let state = state.clone();
1094 MenuItem::new(tr_widget!(menu_cut()))
1095 .shortcut_label(format_keystroke(KeyStroke::command(Key::X)))
1096 .on_activate_fn(move |ctx| {
1097 {
1098 let mut st = state.borrow_mut();
1099 keyboard::clipboard_cut(&mut st, ctx);
1100 }
1101 sync_cursor_signals(&state);
1102 ctx.request_frame();
1103 })
1104}
1105
1106pub(crate) fn menu_row_copy(state: &SharedState) -> MenuItem {
1107 let state = state.clone();
1108 MenuItem::new(tr_widget!(menu_copy()))
1109 .shortcut_label(format_keystroke(KeyStroke::command(Key::C)))
1110 .on_activate_fn(move |ctx| {
1111 let mut st = state.borrow_mut();
1112 keyboard::clipboard_copy(&mut st, ctx);
1113 })
1114}
1115
1116pub(crate) fn menu_row_paste(state: &SharedState) -> MenuItem {
1117 let state = state.clone();
1118 MenuItem::new(tr_widget!(menu_paste()))
1119 .shortcut_label(format_keystroke(KeyStroke::command(Key::V)))
1120 .on_activate_fn(move |ctx| {
1121 {
1122 let mut st = state.borrow_mut();
1123 keyboard::clipboard_paste(&mut st, ctx);
1124 }
1125 sync_cursor_signals(&state);
1126 ctx.request_frame();
1127 })
1128}
1129
1130pub(crate) fn menu_row_select_all(state: &SharedState) -> MenuItem {
1131 let state = state.clone();
1132 MenuItem::new(tr_widget!(menu_select_all()))
1133 .shortcut_label(format_keystroke(KeyStroke::command(Key::A)))
1134 .on_activate_fn(move |ctx| {
1135 {
1136 let st = state.borrow();
1137 st.cursor.select(SelectionType::Document);
1138 }
1139 sync_cursor_signals(&state);
1140 ctx.request_frame();
1141 })
1142}
1143
1144/// Run the validator on the bound text and update the feedback signal.
1145///
1146/// On `Corrected`, also writes the corrected text back to the bound
1147/// signal — the field's external→internal sync effect picks this up
1148/// and rewrites the document in the next frame. On `Invalid`, the
1149/// text is left as-typed; composites that want a "revert on invalid"
1150/// behaviour observe the feedback signal and rewrite the text from
1151/// their own source of truth (e.g., `DateEdit` reformats from its
1152/// `Signal<Option<Date>>`).
1153fn run_validator_and_apply(
1154 validator: &ValidatorFn,
1155 bound_text: &Signal<String>,
1156 feedback: &Signal<ValidationFeedback>,
1157) {
1158 let raw = bound_text.get();
1159 match validator(&raw) {
1160 ValidationOutcome::Valid => {
1161 feedback.set(ValidationFeedback::Valid);
1162 }
1163 ValidationOutcome::Corrected { corrected, message } => {
1164 // Write the corrected text first so observers of the
1165 // bound signal see the new value before the feedback
1166 // signal flips. Composites that bind to BOTH signals
1167 // (rare) will see a consistent pair: text + correction
1168 // notice describing the change.
1169 if bound_text.get() != corrected {
1170 bound_text.set(corrected);
1171 }
1172 feedback.set(ValidationFeedback::Corrected {
1173 message,
1174 since: std::time::Instant::now(),
1175 });
1176 }
1177 ValidationOutcome::Invalid { message } => {
1178 feedback.set(ValidationFeedback::Invalid { message });
1179 }
1180 }
1181}
1182
1183/// Build the worst-case-glyph version of an [`InputMask`] for
1184/// natural-width measurement: every editable slot holds the widest
1185/// plausible character its class can accept, and every fixed slot
1186/// holds its literal. Used by `build()` to size the field's
1187/// intrinsic envelope so a fully-typed value never overflows the
1188/// reported natural width.
1189///
1190/// Per-class worst-case glyph (Inter and most UI sans-serifs):
1191/// - `Digit` → `0` (tabular figures are constant-width, but `0` is
1192/// representative for fonts that aren't)
1193/// - `Letter` / `Alphanumeric` / `Any` → `M` (widest cap glyph)
1194/// - `HexDigit` → `0`
1195fn worst_case_template(mask: &InputMask) -> String {
1196 let mut s = String::with_capacity(mask.len());
1197 for pos in mask.positions() {
1198 match pos {
1199 MaskPosition::Editable { class, .. } => {
1200 s.push(match class {
1201 MaskClass::Digit | MaskClass::HexDigit => '0',
1202 MaskClass::Letter | MaskClass::Alphanumeric | MaskClass::Any => 'M',
1203 });
1204 }
1205 MaskPosition::Fixed(c) => s.push(*c),
1206 }
1207 }
1208 s
1209}
1210
1211/// Measure the advance width of `text` in logical pixels using the
1212/// app-wide `SharedTypesetter` (the same backend the field paints
1213/// with). Falls back to a per-character-class heuristic when no
1214/// typesetter is installed (headless tests) so the caller still gets
1215/// a non-zero width and any natural-width / cap logic behaves
1216/// reasonably even there. The fallback weights match Inter's body
1217/// proportions closely enough that the difference between an
1218/// underscore and a wide cap glyph (`M`) shows up in headless tests
1219/// — important for verifying the worst-case-glyph mask measurement
1220/// without booting a typesetter.
1221fn measure_width_px(ctx: &mut BuildContext, text: &str, style: &TextStyle) -> f32 {
1222 if text.is_empty() {
1223 return 0.0;
1224 }
1225 if let Some(ts) = ctx.app_state::<SharedTypesetter>() {
1226 let backend = ts.as_text_backend();
1227 let layout = backend.borrow_mut().layout_single_line(text, style, None);
1228 return layout.width;
1229 }
1230 let em = style.size;
1231 text.chars()
1232 .map(|c| match c {
1233 ' ' => 0.30,
1234 '_' => 0.45,
1235 ':' | '.' | ',' | ';' | '/' | '|' | '!' | 'i' | 'l' | 'I' => 0.30,
1236 '0'..='9' => 0.55,
1237 'M' | 'W' | 'm' | 'w' => 0.85,
1238 'A'..='Z' => 0.65,
1239 'a'..='z' => 0.50,
1240 _ => 0.55,
1241 })
1242 .map(|w: f32| w * em)
1243 .sum()
1244}
1245
1246#[cfg(test)]
1247mod text_run_tests {
1248 use super::*;
1249 use std::cell::RefCell;
1250 use teksilo_canvas::{MockTextBackend, SizeProposal};
1251 use teksilo_core::accesskit::Role;
1252 use teksilo_core::signal::Signal;
1253 use teksilo_core::widget_id::WidgetId;
1254 use teksilo_core::widget_tree::WidgetTree;
1255 use teksilo_text::text_document::MoveMode;
1256
1257 fn tree_with_mock_backend() -> WidgetTree {
1258 WidgetTree::new()
1259 .with_theme(teksilo_core::presets::intui::light())
1260 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
1261 }
1262
1263 fn state_of(tree: &WidgetTree, id: WidgetId) -> SharedState {
1264 tree.widget_as_any(id)
1265 .and_then(|w| w.downcast_ref::<TextInputField>())
1266 .map(|field| field.state().clone())
1267 .expect("a built TextInputField")
1268 }
1269
1270 #[test]
1271 fn a_caret_past_255_chars_lands_in_the_second_chunk() {
1272 // `accesskit_consumer` probes a position's character index as a `u8`,
1273 // so the emitter splits a long line into 255-character runs. The
1274 // field still speaks in document character offsets, and a caret at
1275 // 260 has to resolve to the run that holds it — reporting it against
1276 // the first run would put the caret 255 characters behind the text.
1277 let mut tree = tree_with_mock_backend();
1278 let id = tree.add(TextInputField::new(Signal::new("a".repeat(300))));
1279 tree.layout(SizeProposal::exact(200.0, 20.0));
1280
1281 state_of(&tree, id)
1282 .borrow_mut()
1283 .cursor
1284 .set_position(260, MoveMode::MoveAnchor);
1285
1286 let update = tree.sync_accessibility();
1287 let (_, input) = update
1288 .nodes
1289 .iter()
1290 .find(|(_, node)| node.role() == Role::TextInput)
1291 .expect("the field reports a text-input node");
1292 let runs = input.children();
1293 assert_eq!(runs.len(), 2, "300 characters split at the 255 cap");
1294
1295 let selection = input
1296 .text_selection()
1297 .expect("the field exposes its caret to assistive technology");
1298 assert_eq!(selection.focus.node, runs[1]);
1299 assert_eq!(selection.focus.character_index, 5);
1300 }
1301
1302 #[test]
1303 fn a_protected_field_emits_no_text_runs() {
1304 // A run publishes the character count, the per-character extents and
1305 // the word boundaries of what it carries. On a masked field that is a
1306 // description of the password, so the protected branch emits the
1307 // bullet string and nothing else.
1308 let mut tree = tree_with_mock_backend();
1309 let _id = tree
1310 .add(TextInputField::new(Signal::new("hunter2".to_string())).secure(EchoMode::Masked));
1311 tree.layout(SizeProposal::exact(200.0, 20.0));
1312
1313 let update = tree.sync_accessibility();
1314 let (_, field) = update
1315 .nodes
1316 .iter()
1317 .find(|(_, node)| node.role() == Role::PasswordInput)
1318 .expect("a masked field reports Role::PasswordInput");
1319 assert_eq!(field.value(), Some("•••••••"));
1320 assert!(
1321 field.children().is_empty(),
1322 "a masked field must own no text runs"
1323 );
1324 assert!(
1325 !update
1326 .nodes
1327 .iter()
1328 .any(|(_, node)| node.role() == Role::TextRun),
1329 "no text run may be emitted anywhere for a masked field"
1330 );
1331 assert!(
1332 field.text_selection().is_none(),
1333 "the caret model stays opaque so no structure about the secret leaks"
1334 );
1335 }
1336}
1337
1338#[cfg(test)]
1339mod window_active_tests;
1340
1341/// **Pointer editing, both devices.** The mouse half is a baseline the touch
1342/// work needed before it could change `mouse.rs`: nothing in this stack
1343/// dispatched a press-move-release pair or a multi-click at a field, so
1344/// "the suite still passes" was satisfiable with drag-select broken.
1345#[cfg(test)]
1346mod pointer_tests;
1347
1348/// **A key the platform decorates with control text must still bubble.**
1349///
1350/// These dispatch `KeyDown` with the `text` a real keyboard carries. Every
1351/// synthetic helper in the workspace sends `text: None`, which skips the branch
1352/// under test entirely — so a test written with `press_key` passes on the bug.
1353#[cfg(test)]
1354mod key_text_bubbling_tests;
1355
1356/// A live handle on a [`TextInputField`] — its text-editing commands, for a
1357/// caller outside the widget.
1358///
1359/// Every method is a no-op before the field is built (and after it is
1360/// destroyed), which is the honest answer rather than a panic: a menu row bound
1361/// to a field that is no longer on screen should do nothing, not crash.
1362#[derive(Clone)]
1363pub struct TextFieldHandle {
1364 slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>>,
1365 focus_signal: Signal<bool>,
1366}
1367
1368impl std::fmt::Debug for TextFieldHandle {
1369 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1370 f.debug_struct("TextFieldHandle")
1371 .field("live", &self.slot.borrow().is_some())
1372 .field("focused", &self.focus_signal.get())
1373 .finish()
1374 }
1375}
1376
1377impl TextFieldHandle {
1378 /// A handle not yet attached to any field — for a composing widget that
1379 /// hands one out before building the field it will delegate to. Every
1380 /// method answers "nothing" until [`TextInputField::share_handle`] binds it.
1381 pub fn detached() -> Self {
1382 Self {
1383 slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
1384 focus_signal: Signal::new(false),
1385 }
1386 }
1387
1388 /// `true` while this field holds the keyboard focus. Observable, so a
1389 /// router can follow the caret without polling.
1390 pub fn focused_signal(&self) -> Signal<bool> {
1391 self.focus_signal.clone()
1392 }
1393
1394 /// Is the widget built and still alive?
1395 pub fn is_live(&self) -> bool {
1396 self.slot.borrow().is_some()
1397 }
1398
1399 fn with<R>(&self, f: impl FnOnce(&mut TextInputState) -> R) -> Option<R> {
1400 let slot = self.slot.borrow();
1401 let state = slot.as_ref()?;
1402 let mut st = state.borrow_mut();
1403 Some(f(&mut st))
1404 }
1405
1406 /// The field's current text.
1407 pub fn text(&self) -> String {
1408 self.with(|st| st.document.to_plain_text().unwrap_or_default())
1409 .unwrap_or_default()
1410 }
1411
1412 /// Is any text selected right now?
1413 pub fn has_selection(&self) -> bool {
1414 self.with(|st| st.cursor.has_selection()).unwrap_or(false)
1415 }
1416
1417 /// May this field's content be copied at all? A password field says no —
1418 /// see [`TextInputField::allow_copy`].
1419 pub fn allows_copy(&self) -> bool {
1420 self.with(|st| st.allow_copy).unwrap_or(false)
1421 }
1422
1423 /// Is the field refusing edits? Cut and Paste are meaningless when it is.
1424 pub fn is_read_only(&self) -> bool {
1425 self.with(|st| st.read_only).unwrap_or(true)
1426 }
1427
1428 /// Select the whole field.
1429 pub fn select_all(&self) {
1430 self.with(|st| st.cursor.select(SelectionType::Document));
1431 }
1432
1433 /// Copy the selection to the clipboard.
1434 pub fn copy(&self, ctx: &EventContext) {
1435 self.with(|st| keyboard::clipboard_copy(st, ctx));
1436 }
1437
1438 /// Cut the selection to the clipboard.
1439 pub fn cut(&self, ctx: &EventContext) {
1440 self.with(|st| keyboard::clipboard_cut(st, ctx));
1441 }
1442
1443 /// Paste over the selection.
1444 pub fn paste(&self, ctx: &EventContext) {
1445 self.with(|st| keyboard::clipboard_paste(st, ctx));
1446 }
1447
1448 /// Undo this field's own last edit.
1449 pub fn undo(&self) {
1450 self.with(|st| {
1451 let _ = st.document.undo();
1452 });
1453 }
1454
1455 /// Redo this field's own last undone edit.
1456 pub fn redo(&self) {
1457 self.with(|st| {
1458 let _ = st.document.redo();
1459 });
1460 }
1461
1462 /// Is there anything to undo? Debounced like the editor's twin.
1463 pub fn can_undo(&self) -> Signal<bool> {
1464 self.with(|st| st.can_undo.clone())
1465 .unwrap_or_else(|| Signal::new(false))
1466 }
1467
1468 /// Is there anything to redo?
1469 pub fn can_redo(&self) -> Signal<bool> {
1470 self.with(|st| st.can_redo.clone())
1471 .unwrap_or_else(|| Signal::new(false))
1472 }
1473}
1474
1475// ── The framework's uniform view of a text-editing widget ────────────────────
1476
1477impl teksilo_core::text_surface::TextSurface for TextFieldHandle {
1478 fn can_undo(&self) -> bool {
1479 TextFieldHandle::can_undo(self).get()
1480 }
1481
1482 fn can_redo(&self) -> bool {
1483 TextFieldHandle::can_redo(self).get()
1484 }
1485
1486 fn undo(&self) {
1487 TextFieldHandle::undo(self);
1488 }
1489
1490 fn redo(&self) {
1491 TextFieldHandle::redo(self);
1492 }
1493
1494 fn has_selection(&self) -> bool {
1495 TextFieldHandle::has_selection(self)
1496 }
1497
1498 fn is_read_only(&self) -> bool {
1499 TextFieldHandle::is_read_only(self)
1500 }
1501
1502 fn allows_copy(&self) -> bool {
1503 TextFieldHandle::allows_copy(self)
1504 }
1505
1506 fn cut(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
1507 TextFieldHandle::cut(self, ctx);
1508 }
1509
1510 fn copy(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
1511 TextFieldHandle::copy(self, ctx);
1512 }
1513
1514 fn paste(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
1515 TextFieldHandle::paste(self, ctx);
1516 }
1517
1518 /// A one-line field carries no formatting to strip, so the plain paste
1519 /// *is* the paste. Answering "nothing" here would make Edit ▸ Paste without
1520 /// formatting silently dead over a rename box.
1521 fn paste_plain(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
1522 TextFieldHandle::paste(self, ctx);
1523 }
1524
1525 fn select_all(&self) {
1526 TextFieldHandle::select_all(self);
1527 }
1528}