Skip to main content

teksilo_widgets/
text_input.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TextInput` — styled single-line text field composite.
5//!
6//! Wraps the [`TextInputField`]
7//! editing primitive in a bordered, padded frame with placeholder
8//! overlay, validation, optional clear button, and leading/trailing
9//! slots. All actual text editing is delegated to the field: every
10//! configuration method here has a direct counterpart on the
11//! primitive.
12//!
13//! Most applications want `TextInput`. Choose
14//! [`TextInputField`] directly
15//! when you're building a composite of your own that already
16//! supplies its frame — `SpinBox` is the canonical in-tree example.
17//!
18//! # Example
19//!
20//! ```ignore
21//! let search = ctx.signal(String::new());
22//! TextInput::new(search.clone())
23//!     .placeholder("Search...")
24//!     .show_clear_button(true)
25//!     .leading_slot(IconWidget::from_svg(SEARCH_ICON))
26//!     .on_submit_fn(|ctx| ctx.send_intent(AppIntent::Search))
27//! ```
28//!
29//! ## Appearance
30//!
31//! - [`text_style`](TextInput::text_style) — the size, family and weight of
32//!   the typed text and of the placeholder, from a `TextStyleRole` (follows the
33//!   theme) or a fixed `TextStyle`. The field's height follows the style's
34//!   line, so a small style gives a short field and a large one a tall field.
35//! - [`text_alignment`](TextInput::text_alignment) — where a text narrower
36//!   than the field sits: start, centre or end, mirrored in a right-to-left
37//!   layout. A longer text scrolls as usual.
38//! - [`text_color`](TextInput::text_color) — the colour of the text and the
39//!   caret. A disabled field still greys out. For one colour at rest and
40//!   another while editing, map the field's focus:
41//!   `input.handle().focused_signal().map(|f| if *f { TextRole::Primary } else
42//!   { TextRole::Secondary })`.
43//! - [`min_height`](TextInput::min_height) — replaces the minimum height the
44//!   style and the density would give the field. Below 24 dp the field fails
45//!   WCAG 2.5.8 unless the app offers a larger target for it.
46//!
47//! None of them is set by default, and the field then looks as it always has.
48//!
49//! ## Touch and pen
50//!
51//! The trailing clear affordance is 16 dp of paint and a 24 dp target: raising
52//! its box would widen every field in the workspace at Compact, so the
53//! shortfall is made up between the pointer and the arena through
54//! `Widget::hit_outset` — declared by the slot that takes the tap, because the
55//! ring around an outset resolves to the declaring node rather than to a
56//! descendant, and by a direct child of the row, because an outset never
57//! escapes its parent. The slot keeps its 16 dp while the affordance is hidden
58//! so the row does not jump, and withdraws its outset while there is nothing to
59//! clear. The caret and selection behaviour of the field itself belongs to the
60//! touch-text package.
61
62mod widget_impl;
63
64#[cfg(test)]
65mod tests;
66
67use std::rc::Rc;
68
69use teksilo_canvas::{Point, Rect, SizeProposal};
70use teksilo_core::accessibility::AccessNodeBuilder;
71use teksilo_core::build_context::BuildContext;
72use teksilo_core::color_prop::{ColorProp, TextStyleProp};
73use teksilo_core::signal::{Prop, Signal};
74use teksilo_core::styles::{
75    SharedTextInputStyle, TextInputStyle, TextInputStyleConfig, TextInputValidationLevel,
76};
77use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
78use teksilo_core::widget_builder::WidgetBuilder;
79use teksilo_core::widget_id::WidgetId;
80use teksilo_tokens::{Alignment, HAlignment, TextRole, TextStyleRole};
81
82use crate::button::InteractionState;
83use crate::primitives::text_input_field::{TextInputField, ValidationFeedback};
84use crate::primitives::validation_strip::ValidationStrip;
85use crate::primitives::{Expand, HStack, MinSize, Padding, Shrinkable, TextWidget, VStack, ZStack};
86use crate::tooltip::{self, RichTooltipSource};
87
88// Re-export the variant enum at module top so callers can write
89// `TextInput::new(text).variant(TextInputVariant::Filled)` without a
90// deeper import path.
91pub use teksilo_core::styles::TextInputVariant;
92use teksilo_i18n::LocalizedString;
93
94/// Validation state for the text input field.
95///
96/// Drives the inline feedback strip and border tint of [`TextInput`].
97#[derive(Debug, Clone, Default)]
98pub enum ValidationState {
99    /// No validation message — the field is pristine or valid.
100    #[default]
101    None,
102    /// The committed value is invalid; `LocalizedString` is shown in red below the field.
103    Error(LocalizedString),
104    /// The committed value is suspicious but accepted; `LocalizedString` is shown as a warning.
105    Warning(LocalizedString),
106    /// Last commit was auto-corrected; the field's value has already
107    /// been replaced with the normalized form. The composite renders
108    /// the message in secondary text and tints the border accent
109    /// briefly (decay-managed by the framework's frame loop, not a
110    /// concern of this enum).
111    Corrected(LocalizedString),
112}
113
114/// Styled single-line text input composite.
115///
116/// See the [module-level documentation](self) for usage examples.
117pub struct TextInput {
118    // ── Configuration forwarded to the inner TextInputField ─────────
119    text: Signal<String>,
120    placeholder: LocalizedString,
121    /// Enabled state, static or reactive; forwarded to the arena and the
122    /// inner `TextInputField` at build time.
123    enabled: Prop<bool>,
124    read_only: bool,
125    max_length: Option<usize>,
126    on_submit: Option<Box<dyn Fn(&mut EventContext)>>,
127    on_access_set_value: Option<std::rc::Rc<dyn Fn(&str, &mut EventContext) -> bool>>,
128    on_blur: Option<Box<dyn Fn(&mut EventContext)>>,
129    char_filter: Option<std::rc::Rc<dyn Fn(char) -> bool>>,
130    suffix: String,
131    /// Optional input-mask grammar string (Qt syntax). Forwarded
132    /// 1:1 to `TextInputField::input_mask`. Used by composing
133    /// widgets like `DateEdit` that need a position-aware filter
134    /// + auto-derived placeholder template (`__/__/____`).
135    input_mask: Option<String>,
136    /// Semantic input purpose (WCAG 1.3.5) forwarded to the inner
137    /// `TextInputField` to select a specialised AT role.
138    input_purpose: crate::primitives::text_input_field::InputPurpose,
139    /// ARIA combobox wiring, forwarded verbatim to the inner
140    /// `TextInputField` (the node that actually holds focus).
141    active_descendant: Option<Signal<Option<WidgetId>>>,
142    controls: Option<Signal<Option<WidgetId>>>,
143    /// Optional validator closure. Forwarded 1:1 to
144    /// `TextInputField::validator`. Runs on commit (Enter, Tab-out,
145    /// blur). Set this AND `validation_feedback` together for
146    /// the standard validator → feedback display pattern.
147    validator: Option<crate::primitives::text_input_field::ValidatorFn>,
148    /// Captured pre-build so composing widgets can read live caret
149    /// position (DateEdit-style segment-stepping). Populated by
150    /// `caret_position()` on first call; the inner field's own
151    /// signal is mirrored into it during `build`.
152    caret_position_slot: std::rc::Rc<std::cell::RefCell<Option<Signal<usize>>>>,
153    /// Same idea as `caret_position_slot` but for the setter
154    /// closure. Captured pre-build by `caret_setter()`.
155    caret_setter_slot: std::rc::Rc<std::cell::RefCell<Option<std::rc::Rc<dyn Fn(usize)>>>>,
156    /// Handed out by [`Self::handle`] before build, adopted by the inner field
157    /// at build time — so the two are one handle, not two that agree by luck.
158    field_handle: crate::primitives::TextFieldHandle,
159    /// Arena id of the inner `TextInputField`, filled in by `build`.
160    /// Shared, so a handle taken before `ctx.add` sees it afterwards.
161    field_id_slot: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
162    /// Mirrored from the inner field's `validation_feedback_signal`
163    /// during `build`. Composing widgets that install a `validator`
164    /// read this to compose feedback across multiple fields (range
165    /// editor's worse-of-two ladder, etc.).
166    feedback_signal: Signal<ValidationFeedback>,
167
168    // ── Configuration owned by this composite only ──────────────────
169    label: Option<LocalizedString>,
170    /// Optional override for the frame's intrinsic minimum width
171    /// (default 65 dp). Composing widgets like `DateEdit` /
172    /// `TimeEdit` raise this so the frame stays at the design
173    /// width even when typed content shrinks. Wired into the inner
174    /// `MinSize` wrapper around the ZStack frame — NOT the outer
175    /// VStack — so the floor doesn't fight the VStack's
176    /// `proposal.width.unwrap_or(max_width)` rule.
177    min_width: Option<f32>,
178    /// See [`TextInput::min_height`]. `None` keeps the style's own minimum.
179    min_height: Option<f32>,
180    /// Forwarded to the inner field's `text_style`, and given to the
181    /// placeholder so the two read alike.
182    text_style: Option<TextStyleProp>,
183    /// Forwarded to the inner field's `text_alignment`, and applied to the
184    /// placeholder.
185    text_alignment: Option<HAlignment>,
186    /// Forwarded to the inner field's `text_color`.
187    text_color: Option<ColorProp>,
188    show_clear_button: bool,
189    leading_slot: Option<Box<dyn Widget>>,
190    trailing_slot: Option<Box<dyn Widget>>,
191    validation: Signal<ValidationState>,
192    /// Set by `.validation_feedback(...)`; wired via `ctx.effect`
193    /// in `build()` so the bridge outlives construction.
194    feedback_to_bridge: Option<Signal<ValidationFeedback>>,
195    tooltip_text: Option<LocalizedString>,
196    rich_tooltip_source: Option<RichTooltipSource>,
197    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
198
199    /// Tier-1 design-language variant. Drives which chrome the active
200    /// `TextInputStyle` paints around the editor (Outlined / Filled /
201    /// Underline / Bare).
202    variant: TextInputVariant,
203    /// Per-call style override.
204    style_override: Option<SharedTextInputStyle>,
205
206    // ── Internal (set during build) ─────────────────────────────────
207    interaction: Signal<InteractionState>,
208    root_child_id: Option<WidgetId>,
209}
210
211impl std::fmt::Debug for TextInput {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("TextInput")
214            .field("placeholder", &self.placeholder)
215            .field("enabled", &self.enabled.get())
216            .finish_non_exhaustive()
217    }
218}
219
220impl TextInput {
221    /// Construct a new text input bound to `text`.
222    pub fn new(text: Signal<String>) -> Self {
223        Self {
224            text,
225            placeholder: LocalizedString::literal(String::new()),
226            enabled: Prop::Static(true),
227            read_only: false,
228            max_length: None,
229            on_submit: None,
230            on_access_set_value: None,
231            on_blur: None,
232            char_filter: None,
233            suffix: String::new(),
234            input_mask: None,
235            input_purpose: crate::primitives::text_input_field::InputPurpose::Normal,
236            active_descendant: None,
237            controls: None,
238            validator: None,
239            caret_position_slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
240            caret_setter_slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
241            field_handle: crate::primitives::TextFieldHandle::detached(),
242            field_id_slot: std::rc::Rc::new(std::cell::Cell::new(None)),
243            feedback_signal: Signal::new(ValidationFeedback::Pristine),
244            label: None,
245            min_width: None,
246            min_height: None,
247            text_style: None,
248            text_alignment: None,
249            text_color: None,
250            show_clear_button: false,
251            leading_slot: None,
252            trailing_slot: None,
253            validation: Signal::new(ValidationState::None),
254            feedback_to_bridge: None,
255            tooltip_text: None,
256            rich_tooltip_source: None,
257            composite_tooltip_content: None,
258            variant: TextInputVariant::default(),
259            style_override: None,
260            interaction: Signal::new(InteractionState::Idle),
261            root_child_id: None,
262        }
263    }
264
265    /// Pick a Tier-1 design-language variant
266    /// ([`TextInputVariant::Outlined`] / `Filled` / `Underline` / `Bare`).
267    /// The IntUI default ([`crate::styles::RecipeTextInputStyle`]) honours
268    /// `Outlined`, `Filled`, and `Bare`; `Underline` falls back to
269    /// `Outlined` until per-side stroke recipes land.
270    pub fn variant(mut self, variant: TextInputVariant) -> Self {
271        self.variant = variant;
272        self
273    }
274
275    /// Override the active [`TextInputStyle`] for this widget instance
276    /// only. The widget keeps responsibility for caret blinking, IME
277    /// composition, the placeholder layering, the leading / trailing
278    /// slots and the validation strip — the style only paints the
279    /// frame (border / fill / corner radius).
280    pub fn style(mut self, style: impl TextInputStyle) -> Self {
281        self.style_override = Some(Rc::new(style));
282        self
283    }
284
285    // ── Builder methods ─────────────────────────────────────────────
286    //
287    // Every method below that has a direct analogue on
288    // `TextInputField` forwards to it 1:1 at build time — the
289    // `TextInput` composite just owns the framing around the field.
290
291    /// Set the placeholder text shown when the field is empty.
292    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
293        let ls: LocalizedString = text.into();
294        self.placeholder = ls;
295        self
296    }
297
298    /// Accessible name for the field.
299    ///
300    /// Applied to the inner `TextInputField` — the node that carries
301    /// `Role::TextInput`, holds focus, and reports the document's value.
302    /// It deliberately does *not* go on the composite's outer node: that
303    /// node is a `Role::GenericContainer`, which
304    /// `accesskit_consumer::common_filter` drops from the filtered tree
305    /// unconditionally, so a name placed there would be invisible to every
306    /// screen reader on every platform.
307    ///
308    /// Stays locale-reactive: a `tr!(...)` name is re-resolved when the
309    /// locale changes, without a rebuild.
310    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
311        let ls: LocalizedString = label.into();
312        self.label = Some(ls);
313        self
314    }
315
316    /// Set the enabled state, statically or reactively. Forwarded to the
317    /// arena and the inner `TextInputField` at build time.
318    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
319        self.enabled = enabled.into();
320        self
321    }
322
323    /// Set the field read-only: text is selectable and copyable but not editable.
324    pub fn read_only(mut self, read_only: bool) -> Self {
325        self.read_only = read_only;
326        self
327    }
328
329    /// Limit the number of Unicode scalar values the field will accept.
330    pub fn max_length(mut self, max_length: usize) -> Self {
331        self.max_length = Some(max_length);
332        self
333    }
334
335    /// Show or hide the trailing ✕ button that clears the field text. Default: hidden.
336    pub fn show_clear_button(mut self, show: bool) -> Self {
337        self.show_clear_button = show;
338        self
339    }
340
341    /// Override the frame's intrinsic minimum width (default 65 dp).
342    /// Use to express a design width for date / time / phone-number
343    /// fields whose content is well-known and whose collapse to the
344    /// generic 65 dp floor would look out of place.
345    pub fn min_width(mut self, w: f32) -> Self {
346        self.min_width = Some(w.max(0.0));
347        self
348    }
349
350    /// Override the field's minimum height, in logical pixels.
351    ///
352    /// By default the field is at least the style's control height — 28 dp
353    /// in the IntUI style, raised to the density's target size — or, when a
354    /// [`text_style`](Self::text_style) is set, at least the density's target
355    /// size, with the style's line deciding the rest. This replaces that
356    /// floor with `height`, as given: the field still grows to fit its text,
357    /// and is centred in a taller box. The value reaches the active
358    /// [`TextInputStyle`] through [`TextInputStyleConfig::min_height`]; a
359    /// style that ignores it keeps its own minimum.
360    ///
361    /// The field is the pointer target: a press anywhere in it places the
362    /// caret. Below 24 dp it fails WCAG 2.2 SC 2.5.8 *Target Size (Minimum)*
363    /// unless the app gives the same function a larger target elsewhere — an
364    /// enclosing row that focuses the field when pressed, say.
365    pub fn min_height(mut self, height: f32) -> Self {
366        self.min_height = Some(height.max(0.0));
367        self
368    }
369
370    /// The style the typed text is shaped in: its size, family and weight.
371    ///
372    /// Forwarded to [`TextInputField::text_style`], where the details live,
373    /// and given to the placeholder too, so the hint and the text it stands in
374    /// for read alike. The field's height follows the style's line: an 11 px
375    /// style gives a short field, a large one a tall field (see
376    /// [`min_height`](Self::min_height) for the floor). A role tracks the
377    /// theme; the global text scale multiplies the size.
378    pub fn text_style(mut self, style: impl Into<TextStyleProp>) -> Self {
379        self.text_style = Some(style.into());
380        self
381    }
382
383    /// Where the text — and the placeholder — sits when it is narrower than
384    /// the field: at its start, in the middle, or at its end, mirrored in a
385    /// right-to-left layout. Text wider than the field scrolls as usual.
386    /// Forwarded to [`TextInputField::text_alignment`].
387    pub fn text_alignment(mut self, alignment: HAlignment) -> Self {
388        self.text_alignment = Some(alignment);
389        self
390    }
391
392    /// The colour of the typed text and of the caret: a colour, a theme role
393    /// or a signal of either, resolved every paint. A disabled field still
394    /// paints the theme's disabled text colour. Forwarded to
395    /// [`TextInputField::text_color`].
396    ///
397    /// A different colour at rest and while editing follows the field's
398    /// focus, which [`handle`](Self::handle) exposes before the input is
399    /// built:
400    ///
401    /// ```ignore
402    /// let input = TextInput::new(text);
403    /// let focused = input.handle().focused_signal();
404    /// let input = input.text_color(focused.map(|f| {
405    ///     if *f { TextRole::Primary } else { TextRole::Secondary }
406    /// }));
407    /// ```
408    pub fn text_color(mut self, color: impl Into<ColorProp>) -> Self {
409        self.text_color = Some(color.into());
410        self
411    }
412
413    /// Set an arbitrary widget in the leading slot (before the text area).
414    /// Typically an `IconButton` or `IconWidget`.
415    pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
416        self.leading_slot = Some(Box::new(widget));
417        self
418    }
419
420    /// Set an arbitrary widget in the trailing slot (after the text area).
421    /// Typically an `IconButton` or `IconWidget`.
422    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
423        self.trailing_slot = Some(Box::new(widget));
424        self
425    }
426
427    /// Closure invoked on Enter. Forwarded to `TextInputField`.
428    pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
429        self.on_submit = Some(Box::new(f));
430        self
431    }
432
433    /// Handle an assistive technology's whole-value write, given the string it
434    /// set. Forwarded 1:1 to `TextInputField::on_access_set_value`, where the
435    /// reasoning lives. Composites whose text projects a typed value —
436    /// `SpinBox`, the date and time editors — install one.
437    pub fn on_access_set_value(
438        mut self,
439        f: impl Fn(&str, &mut EventContext) -> bool + 'static,
440    ) -> Self {
441        self.on_access_set_value = Some(std::rc::Rc::new(f));
442        self
443    }
444
445    /// Closure invoked on focus loss. Forwarded to `TextInputField`.
446    pub fn on_blur_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
447        self.on_blur = Some(Box::new(f));
448        self
449    }
450
451    /// Per-character input-filter predicate. Forwarded to
452    /// `TextInputField`.
453    pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
454        self.char_filter = Some(std::rc::Rc::new(f));
455        self
456    }
457
458    /// Non-editable trailing string (Qt's `QSpinBox::suffix`).
459    /// Forwarded to `TextInputField`.
460    pub fn suffix(mut self, text: impl Into<String>) -> Self {
461        self.suffix = text.into();
462        self
463    }
464
465    /// Install an input mask (Qt grammar). Forwarded 1:1 to
466    /// [`TextInputField::input_mask`]. Composing widgets like
467    /// `DateEdit` use this to project the date format pattern
468    /// onto the editing surface.
469    pub fn input_mask(mut self, mask: impl Into<String>) -> Self {
470        self.input_mask = Some(mask.into());
471        self
472    }
473
474    /// Declare the field's semantic [`InputPurpose`](crate::primitives::InputPurpose)
475    /// (WCAG 1.3.5), forwarded to the inner `TextInputField` to select a
476    /// specialised AT role (e.g. `Role::EmailInput`).
477    pub fn input_purpose(
478        mut self,
479        purpose: crate::primitives::text_input_field::InputPurpose,
480    ) -> Self {
481        self.input_purpose = purpose;
482        self
483    }
484
485    /// Publish `active_descendant` on the inner field, pointing at the row a
486    /// separate listbox is currently highlighting (the ARIA combobox pattern).
487    /// Forwarded 1:1 to [`TextInputField::active_descendant`], which is where
488    /// it has to land: AT follows the *focused* node's active descendant, and
489    /// the inner field is the focusable one.
490    pub fn active_descendant(mut self, active: Signal<Option<WidgetId>>) -> Self {
491        self.active_descendant = Some(active);
492        self
493    }
494
495    /// Publish a `controls` relation to the listbox this input drives.
496    /// Forwarded 1:1 to [`TextInputField::controls`].
497    pub fn controls(mut self, listbox: Signal<Option<WidgetId>>) -> Self {
498        self.controls = Some(listbox);
499        self
500    }
501
502    /// Install a commit-time validator. Forwarded 1:1 to
503    /// [`TextInputField::validator`]. Pair with
504    /// [`Self::validation_feedback_signal`] (or
505    /// [`Self::validation_feedback`]) to surface the outcome
506    /// in the inline strip.
507    pub fn validator(
508        mut self,
509        f: impl Fn(&str) -> crate::primitives::text_input_field::ValidationOutcome + 'static,
510    ) -> Self {
511        self.validator = Some(std::rc::Rc::new(f));
512        self
513    }
514
515    /// Reactive caret position. Mirrors the inner field's
516    /// [`TextInputField::caret_position`] after `build`. Capture
517    /// before `ctx.add(text_input)` — used by composing widgets
518    /// (`DateEdit` segment-stepping) that need to know which
519    /// segment Up/Down should step.
520    pub fn caret_position(&self) -> Signal<usize> {
521        let mut slot = self.caret_position_slot.borrow_mut();
522        if slot.is_none() {
523            *slot = Some(Signal::new(0));
524        }
525        slot.as_ref().unwrap().clone()
526    }
527
528    /// A live handle on the inner field — its text-editing commands, for a
529    /// host outside the widget.
530    ///
531    /// Mirrors [`TextInputField::handle`], and exists for the same reason: an
532    /// application that routes Undo, Cut, Copy, Paste and Select All to
533    /// "whichever text surface holds the caret" must be able to reach *every*
534    /// such surface. A `TextInput` that could not be reached would silently
535    /// lose its own Ctrl+Z to whatever the host routed the chord at instead.
536    ///
537    /// Like [`caret_setter`](Self::caret_setter), safe to take before `build`:
538    /// the handle reaches the field through a slot the widget fills in.
539    pub fn handle(&self) -> crate::primitives::TextFieldHandle {
540        self.field_handle.clone()
541    }
542
543    /// The arena id of the inner field: the node that holds focus, carries
544    /// `Role::TextInput` and reports the document's value.
545    ///
546    /// A `TextInput` is a composite whose outer node is a
547    /// `Role::GenericContainer`. That node is neither focusable nor present in
548    /// the filtered accessibility tree, so a host that has to *name* the focus
549    /// target cannot use the id `ctx.add` returned it. Two cases need the
550    /// name: a form sending focus back to the field a validator refused, and a
551    /// modal whose own `initial_focus_hint` picks one field out of several.
552    ///
553    /// Empty until `build` runs, like [`caret_setter`](Self::caret_setter);
554    /// take the handle before `ctx.add(text_input)` and read it after.
555    ///
556    /// A host that only needs "focus this input, whichever node that is" wants
557    /// [`EventContext::request_focus_into`] on the outer id instead, and no
558    /// handle at all.
559    ///
560    /// [`EventContext::request_focus_into`]: teksilo_core::widget::EventContext::request_focus_into
561    pub fn field_id(&self) -> std::rc::Rc<std::cell::Cell<Option<WidgetId>>> {
562        self.field_id_slot.clone()
563    }
564
565    /// Programmatic caret setter. Mirrors the inner field's
566    /// [`TextInputField::caret_setter`]. Returns a closure that
567    /// is a no-op until `build` runs; afterwards it walks the
568    /// inner field's state and moves the document cursor. Capture
569    /// before `ctx.add(text_input)`.
570    pub fn caret_setter(&self) -> std::rc::Rc<dyn Fn(usize)> {
571        let slot = self.caret_setter_slot.clone();
572        std::rc::Rc::new(move |position: usize| {
573            if let Some(setter) = slot.borrow().as_ref() {
574                (setter)(position);
575            }
576        })
577    }
578
579    /// Reactive published validation feedback. Mirrors the inner
580    /// field's [`TextInputField::validation_feedback_signal`]
581    /// after `build`. Composing widgets observe this to compose
582    /// feedback across multiple fields (range editor's
583    /// worse-of-two ladder, etc.).
584    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
585        self.feedback_signal.clone()
586    }
587
588    /// Bind an external [`ValidationState`] signal directly (e.g. when
589    /// validation runs server-side), or set a fixed initial value. Use
590    /// [`validation_feedback`](Self::validation_feedback)
591    /// when wiring a local validator's output.
592    ///
593    /// A bound `Signal` becomes the shared write target used internally
594    /// (by the validator-feedback bridge) and externally by the caller —
595    /// preserving the two-way channel this method has always offered. A
596    /// static value seeds a fresh, unshared signal.
597    pub fn validation(mut self, validation: impl Into<Prop<ValidationState>>) -> Self {
598        self.validation = validation.into().as_signal();
599        self
600    }
601
602    /// Bridge a `Signal<ValidationFeedback>` (typically from a
603    /// validator-equipped widget like `DateEdit::validation_feedback_signal`
604    /// or a custom `TextInputField`) into this composite's
605    /// `ValidationState`. The feedback is mirrored on every change,
606    /// translating outcomes into the composite's display vocabulary:
607    ///
608    /// - `Pristine` / `Valid` → `ValidationState::None`
609    /// - `Corrected { message, .. }` → `ValidationState::Corrected(message)`
610    /// - `Invalid { message }` → `ValidationState::Error(message)`
611    pub fn validation_feedback(mut self, feedback: Signal<ValidationFeedback>) -> Self {
612        let target = self.validation.clone();
613        // Snapshot once now so we observe the current state at construction
614        // time too (subsequent changes flow via the field's own commit
615        // pipeline; ctx.effect installed in build() does the live tracking).
616        target.set(feedback_to_state(&feedback.get()));
617        self.feedback_to_bridge = Some(feedback);
618        self
619    }
620
621    /// Attach a plain tooltip. Accepts `tr!(...)` or `lit!(...)`.
622    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
623        self.tooltip_text = Some(text.into());
624        self.rich_tooltip_source = None;
625        self.composite_tooltip_content = None;
626        self
627    }
628
629    /// Attach a registry-driven rich tooltip by key. Mutually exclusive with
630    /// `tooltip` and `composite_tooltip` (last call wins).
631    pub fn rich_tooltip_key(mut self, key: impl Into<String>) -> Self {
632        self.rich_tooltip_source = Some(RichTooltipSource::Key(key.into()));
633        self.tooltip_text = None;
634        self.composite_tooltip_content = None;
635        self
636    }
637
638    /// Attach an inline rich tooltip from a pre-built [`tooltip::TooltipContent`].
639    /// Mutually exclusive with `tooltip` and `composite_tooltip` (last call wins).
640    pub fn rich_tooltip(mut self, content: tooltip::TooltipContent) -> Self {
641        self.rich_tooltip_source = Some(RichTooltipSource::Content(content));
642        self.tooltip_text = None;
643        self.composite_tooltip_content = None;
644        self
645    }
646
647    /// Attach an inline rich tooltip from a pre-built [`tooltip::TooltipContent`].
648    /// Canonical alias for [`Self::rich_tooltip`] — matches the name used by
649    /// `Button`, `ComboBox`, and other widgets. Mutually exclusive with
650    /// `tooltip` and `composite_tooltip` (last call wins).
651    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
652        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
653        self.tooltip_text = None;
654        self.composite_tooltip_content = None;
655        self
656    }
657
658    /// Attach a composite tooltip — third tier, hosting an arbitrary
659    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
660    pub fn composite_tooltip(
661        mut self,
662        content: impl teksilo_core::widget::Widget + 'static,
663    ) -> Self {
664        self.composite_tooltip_content = Some(Box::new(content));
665        self.tooltip_text = None;
666        self.rich_tooltip_source = None;
667        self
668    }
669
670    // ── Signal accessors (call before add to tree) ──────────────────
671
672    /// The reactive text content signal.
673    pub fn text(&self) -> Signal<String> {
674        self.text.clone()
675    }
676}
677
678/// Project a `ValidationFeedback` (validator-pipeline outcome) onto a
679/// `ValidationState` (composite display state). `Pristine` and `Valid`
680/// both clear; `Corrected` and `Invalid` carry their messages through.
681fn feedback_to_state(fb: &ValidationFeedback) -> ValidationState {
682    match fb {
683        ValidationFeedback::Pristine | ValidationFeedback::Valid => ValidationState::None,
684        ValidationFeedback::Corrected { message, .. } => {
685            ValidationState::Corrected(message.clone())
686        }
687        ValidationFeedback::Invalid { message } => ValidationState::Error(message.clone()),
688    }
689}