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#[cfg(test)]
30mod tests;
31
32use std::rc::Rc;
33
34use teksilo_canvas::{Point, Rect, SizeProposal};
35use teksilo_core::accessibility::AccessNodeBuilder;
36use teksilo_core::build_context::BuildContext;
37use teksilo_core::signal::{Prop, Signal};
38use teksilo_core::styles::{
39    SharedTextInputStyle, TextInputStyle, TextInputStyleConfig, TextInputValidationLevel,
40};
41use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
42use teksilo_core::widget_builder::WidgetBuilder;
43use teksilo_core::widget_id::WidgetId;
44use teksilo_tokens::{Alignment, TextRole, TextStyleRole};
45
46use crate::button::InteractionState;
47use crate::primitives::text_input_field::{TextInputField, ValidationFeedback};
48use crate::primitives::validation_strip::ValidationStrip;
49use crate::primitives::{Expand, HStack, MinSize, Padding, Shrinkable, TextWidget, VStack, ZStack};
50use crate::tooltip::{self, RichTooltipSource};
51
52// Re-export the variant enum at module top so callers can write
53// `TextInput::new(text).variant(TextInputVariant::Filled)` without a
54// deeper import path.
55pub use teksilo_core::styles::TextInputVariant;
56use teksilo_i18n::LocalizedString;
57
58/// Validation state for the text input field.
59///
60/// Drives the inline feedback strip and border tint of [`TextInput`].
61#[derive(Debug, Clone, Default)]
62pub enum ValidationState {
63    /// No validation message — the field is pristine or valid.
64    #[default]
65    None,
66    /// The committed value is invalid; `LocalizedString` is shown in red below the field.
67    Error(LocalizedString),
68    /// The committed value is suspicious but accepted; `LocalizedString` is shown as a warning.
69    Warning(LocalizedString),
70    /// Last commit was auto-corrected; the field's value has already
71    /// been replaced with the normalized form. The composite renders
72    /// the message in secondary text and tints the border accent
73    /// briefly (decay-managed by the framework's frame loop, not a
74    /// concern of this enum).
75    Corrected(LocalizedString),
76}
77
78/// Styled single-line text input composite.
79///
80/// See the [module-level documentation](self) for usage examples.
81pub struct TextInput {
82    // ── Configuration forwarded to the inner TextInputField ─────────
83    text: Signal<String>,
84    placeholder: LocalizedString,
85    /// Enabled state, static or reactive; forwarded to the arena and the
86    /// inner `TextInputField` at build time.
87    enabled: Prop<bool>,
88    read_only: bool,
89    max_length: Option<usize>,
90    on_submit: Option<Box<dyn Fn(&mut EventContext)>>,
91    on_blur: Option<Box<dyn Fn(&mut EventContext)>>,
92    char_filter: Option<std::rc::Rc<dyn Fn(char) -> bool>>,
93    suffix: String,
94    /// Optional input-mask grammar string (Qt syntax). Forwarded
95    /// 1:1 to `TextInputField::input_mask`. Used by composing
96    /// widgets like `DateEdit` that need a position-aware filter
97    /// + auto-derived placeholder template (`__/__/____`).
98    input_mask: Option<String>,
99    /// Semantic input purpose (WCAG 1.3.5) forwarded to the inner
100    /// `TextInputField` to select a specialised AT role.
101    input_purpose: crate::primitives::text_input_field::InputPurpose,
102    /// ARIA combobox wiring, forwarded verbatim to the inner
103    /// `TextInputField` (the node that actually holds focus).
104    active_descendant: Option<Signal<Option<WidgetId>>>,
105    controls: Option<Signal<Option<WidgetId>>>,
106    /// Optional validator closure. Forwarded 1:1 to
107    /// `TextInputField::validator`. Runs on commit (Enter, Tab-out,
108    /// blur). Set this AND `validation_feedback` together for
109    /// the standard validator → feedback display pattern.
110    validator: Option<crate::primitives::text_input_field::ValidatorFn>,
111    /// Captured pre-build so composing widgets can read live caret
112    /// position (DateEdit-style segment-stepping). Populated by
113    /// `caret_position()` on first call; the inner field's own
114    /// signal is mirrored into it during `build`.
115    caret_position_slot: std::rc::Rc<std::cell::RefCell<Option<Signal<usize>>>>,
116    /// Same idea as `caret_position_slot` but for the setter
117    /// closure. Captured pre-build by `caret_setter()`.
118    caret_setter_slot: std::rc::Rc<std::cell::RefCell<Option<std::rc::Rc<dyn Fn(usize)>>>>,
119    /// Handed out by [`Self::handle`] before build, adopted by the inner field
120    /// at build time — so the two are one handle, not two that agree by luck.
121    field_handle: crate::primitives::TextFieldHandle,
122    /// Mirrored from the inner field's `validation_feedback_signal`
123    /// during `build`. Composing widgets that install a `validator`
124    /// read this to compose feedback across multiple fields (range
125    /// editor's worse-of-two ladder, etc.).
126    feedback_signal: Signal<ValidationFeedback>,
127
128    // ── Configuration owned by this composite only ──────────────────
129    label: Option<LocalizedString>,
130    /// Optional override for the frame's intrinsic minimum width
131    /// (default 65 dp). Composing widgets like `DateEdit` /
132    /// `TimeEdit` raise this so the frame stays at the design
133    /// width even when typed content shrinks. Wired into the inner
134    /// `MinSize` wrapper around the ZStack frame — NOT the outer
135    /// VStack — so the floor doesn't fight the VStack's
136    /// `proposal.width.unwrap_or(max_width)` rule.
137    min_width: Option<f32>,
138    show_clear_button: bool,
139    leading_slot: Option<Box<dyn Widget>>,
140    trailing_slot: Option<Box<dyn Widget>>,
141    validation: Signal<ValidationState>,
142    /// Set by `.validation_feedback(...)`; wired via `ctx.effect`
143    /// in `build()` so the bridge outlives construction.
144    feedback_to_bridge: Option<Signal<ValidationFeedback>>,
145    tooltip_text: Option<LocalizedString>,
146    rich_tooltip_source: Option<RichTooltipSource>,
147    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
148
149    /// Tier-1 design-language variant. Drives which chrome the active
150    /// `TextInputStyle` paints around the editor (Outlined / Filled /
151    /// Underline / Bare).
152    variant: TextInputVariant,
153    /// Per-call style override.
154    style_override: Option<SharedTextInputStyle>,
155
156    // ── Internal (set during build) ─────────────────────────────────
157    interaction: Signal<InteractionState>,
158    root_child_id: Option<WidgetId>,
159}
160
161impl std::fmt::Debug for TextInput {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("TextInput")
164            .field("placeholder", &self.placeholder)
165            .field("enabled", &self.enabled.get())
166            .finish_non_exhaustive()
167    }
168}
169
170impl TextInput {
171    /// Construct a new text input bound to `text`.
172    pub fn new(text: Signal<String>) -> Self {
173        Self {
174            text,
175            placeholder: LocalizedString::literal(String::new()),
176            enabled: Prop::Static(true),
177            read_only: false,
178            max_length: None,
179            on_submit: None,
180            on_blur: None,
181            char_filter: None,
182            suffix: String::new(),
183            input_mask: None,
184            input_purpose: crate::primitives::text_input_field::InputPurpose::Normal,
185            active_descendant: None,
186            controls: None,
187            validator: None,
188            caret_position_slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
189            caret_setter_slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
190            field_handle: crate::primitives::TextFieldHandle::detached(),
191            feedback_signal: Signal::new(ValidationFeedback::Pristine),
192            label: None,
193            min_width: None,
194            show_clear_button: false,
195            leading_slot: None,
196            trailing_slot: None,
197            validation: Signal::new(ValidationState::None),
198            feedback_to_bridge: None,
199            tooltip_text: None,
200            rich_tooltip_source: None,
201            composite_tooltip_content: None,
202            variant: TextInputVariant::default(),
203            style_override: None,
204            interaction: Signal::new(InteractionState::Idle),
205            root_child_id: None,
206        }
207    }
208
209    /// Pick a Tier-1 design-language variant
210    /// ([`TextInputVariant::Outlined`] / `Filled` / `Underline` / `Bare`).
211    /// The IntUI default ([`crate::styles::RecipeTextInputStyle`]) honours
212    /// `Outlined`, `Filled`, and `Bare`; `Underline` falls back to
213    /// `Outlined` until per-side stroke recipes land.
214    pub fn variant(mut self, variant: TextInputVariant) -> Self {
215        self.variant = variant;
216        self
217    }
218
219    /// Override the active [`TextInputStyle`] for this widget instance
220    /// only. The widget keeps responsibility for caret blinking, IME
221    /// composition, the placeholder layering, the leading / trailing
222    /// slots and the validation strip — the style only paints the
223    /// frame (border / fill / corner radius).
224    pub fn style(mut self, style: impl TextInputStyle) -> Self {
225        self.style_override = Some(Rc::new(style));
226        self
227    }
228
229    // ── Builder methods ─────────────────────────────────────────────
230    //
231    // Every method below that has a direct analogue on
232    // `TextInputField` forwards to it 1:1 at build time — the
233    // `TextInput` composite just owns the framing around the field.
234
235    /// Set the placeholder text shown when the field is empty.
236    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
237        let ls: LocalizedString = text.into();
238        self.placeholder = ls;
239        self
240    }
241
242    /// Accessible name for the composite. Propagated to the outer
243    /// container's a11y node; the inner `TextInputField` still
244    /// carries `Role::TextInput` with the document's value.
245    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
246        let ls: LocalizedString = label.into();
247        self.label = Some(ls);
248        self
249    }
250
251    /// Set the enabled state, statically or reactively. Forwarded to the
252    /// arena and the inner `TextInputField` at build time.
253    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
254        self.enabled = enabled.into();
255        self
256    }
257
258    /// Set the field read-only: text is selectable and copyable but not editable.
259    pub fn read_only(mut self, read_only: bool) -> Self {
260        self.read_only = read_only;
261        self
262    }
263
264    /// Limit the number of Unicode scalar values the field will accept.
265    pub fn max_length(mut self, max_length: usize) -> Self {
266        self.max_length = Some(max_length);
267        self
268    }
269
270    /// Show or hide the trailing ✕ button that clears the field text. Default: hidden.
271    pub fn show_clear_button(mut self, show: bool) -> Self {
272        self.show_clear_button = show;
273        self
274    }
275
276    /// Override the frame's intrinsic minimum width (default 65 dp).
277    /// Use to express a design width for date / time / phone-number
278    /// fields whose content is well-known and whose collapse to the
279    /// generic 65 dp floor would look out of place.
280    pub fn min_width(mut self, w: f32) -> Self {
281        self.min_width = Some(w.max(0.0));
282        self
283    }
284
285    /// Set an arbitrary widget in the leading slot (before the text area).
286    /// Typically an `IconButton` or `IconWidget`.
287    pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
288        self.leading_slot = Some(Box::new(widget));
289        self
290    }
291
292    /// Set an arbitrary widget in the trailing slot (after the text area).
293    /// Typically an `IconButton` or `IconWidget`.
294    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
295        self.trailing_slot = Some(Box::new(widget));
296        self
297    }
298
299    /// Closure invoked on Enter. Forwarded to `TextInputField`.
300    pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
301        self.on_submit = Some(Box::new(f));
302        self
303    }
304
305    /// Closure invoked on focus loss. Forwarded to `TextInputField`.
306    pub fn on_blur_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
307        self.on_blur = Some(Box::new(f));
308        self
309    }
310
311    /// Per-character input-filter predicate. Forwarded to
312    /// `TextInputField`.
313    pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
314        self.char_filter = Some(std::rc::Rc::new(f));
315        self
316    }
317
318    /// Non-editable trailing string (Qt's `QSpinBox::suffix`).
319    /// Forwarded to `TextInputField`.
320    pub fn suffix(mut self, text: impl Into<String>) -> Self {
321        self.suffix = text.into();
322        self
323    }
324
325    /// Install an input mask (Qt grammar). Forwarded 1:1 to
326    /// [`TextInputField::input_mask`]. Composing widgets like
327    /// `DateEdit` use this to project the date format pattern
328    /// onto the editing surface.
329    pub fn input_mask(mut self, mask: impl Into<String>) -> Self {
330        self.input_mask = Some(mask.into());
331        self
332    }
333
334    /// Declare the field's semantic [`InputPurpose`](crate::primitives::InputPurpose)
335    /// (WCAG 1.3.5), forwarded to the inner `TextInputField` to select a
336    /// specialised AT role (e.g. `Role::EmailInput`).
337    pub fn input_purpose(
338        mut self,
339        purpose: crate::primitives::text_input_field::InputPurpose,
340    ) -> Self {
341        self.input_purpose = purpose;
342        self
343    }
344
345    /// Publish `active_descendant` on the inner field, pointing at the row a
346    /// separate listbox is currently highlighting (the ARIA combobox pattern).
347    /// Forwarded 1:1 to [`TextInputField::active_descendant`], which is where
348    /// it has to land: AT follows the *focused* node's active descendant, and
349    /// the inner field is the focusable one.
350    pub fn active_descendant(mut self, active: Signal<Option<WidgetId>>) -> Self {
351        self.active_descendant = Some(active);
352        self
353    }
354
355    /// Publish a `controls` relation to the listbox this input drives.
356    /// Forwarded 1:1 to [`TextInputField::controls`].
357    pub fn controls(mut self, listbox: Signal<Option<WidgetId>>) -> Self {
358        self.controls = Some(listbox);
359        self
360    }
361
362    /// Install a commit-time validator. Forwarded 1:1 to
363    /// [`TextInputField::validator`]. Pair with
364    /// [`Self::validation_feedback_signal`] (or
365    /// [`Self::validation_feedback`]) to surface the outcome
366    /// in the inline strip.
367    pub fn validator(
368        mut self,
369        f: impl Fn(&str) -> crate::primitives::text_input_field::ValidationOutcome + 'static,
370    ) -> Self {
371        self.validator = Some(std::rc::Rc::new(f));
372        self
373    }
374
375    /// Reactive caret position. Mirrors the inner field's
376    /// [`TextInputField::caret_position`] after `build`. Capture
377    /// before `ctx.add(text_input)` — used by composing widgets
378    /// (`DateEdit` segment-stepping) that need to know which
379    /// segment Up/Down should step.
380    pub fn caret_position(&self) -> Signal<usize> {
381        let mut slot = self.caret_position_slot.borrow_mut();
382        if slot.is_none() {
383            *slot = Some(Signal::new(0));
384        }
385        slot.as_ref().unwrap().clone()
386    }
387
388    /// A live handle on the inner field — its text-editing commands, for a
389    /// host outside the widget.
390    ///
391    /// Mirrors [`TextInputField::handle`], and exists for the same reason: an
392    /// application that routes Undo, Cut, Copy, Paste and Select All to
393    /// "whichever text surface holds the caret" must be able to reach *every*
394    /// such surface. A `TextInput` that could not be reached would silently
395    /// lose its own Ctrl+Z to whatever the host routed the chord at instead.
396    ///
397    /// Like [`caret_setter`](Self::caret_setter), safe to take before `build`:
398    /// the handle reaches the field through a slot the widget fills in.
399    pub fn handle(&self) -> crate::primitives::TextFieldHandle {
400        self.field_handle.clone()
401    }
402
403    /// Programmatic caret setter. Mirrors the inner field's
404    /// [`TextInputField::caret_setter`]. Returns a closure that
405    /// is a no-op until `build` runs; afterwards it walks the
406    /// inner field's state and moves the document cursor. Capture
407    /// before `ctx.add(text_input)`.
408    pub fn caret_setter(&self) -> std::rc::Rc<dyn Fn(usize)> {
409        let slot = self.caret_setter_slot.clone();
410        std::rc::Rc::new(move |position: usize| {
411            if let Some(setter) = slot.borrow().as_ref() {
412                (setter)(position);
413            }
414        })
415    }
416
417    /// Reactive published validation feedback. Mirrors the inner
418    /// field's [`TextInputField::validation_feedback_signal`]
419    /// after `build`. Composing widgets observe this to compose
420    /// feedback across multiple fields (range editor's
421    /// worse-of-two ladder, etc.).
422    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
423        self.feedback_signal.clone()
424    }
425
426    /// Bind an external [`ValidationState`] signal directly (e.g. when
427    /// validation runs server-side), or set a fixed initial value. Use
428    /// [`validation_feedback`](Self::validation_feedback)
429    /// when wiring a local validator's output.
430    ///
431    /// A bound `Signal` becomes the shared write target used internally
432    /// (by the validator-feedback bridge) and externally by the caller —
433    /// preserving the two-way channel this method has always offered. A
434    /// static value seeds a fresh, unshared signal.
435    pub fn validation(mut self, validation: impl Into<Prop<ValidationState>>) -> Self {
436        self.validation = validation.into().as_signal();
437        self
438    }
439
440    /// Bridge a `Signal<ValidationFeedback>` (typically from a
441    /// validator-equipped widget like `DateEdit::validation_feedback_signal`
442    /// or a custom `TextInputField`) into this composite's
443    /// `ValidationState`. The feedback is mirrored on every change,
444    /// translating outcomes into the composite's display vocabulary:
445    ///
446    /// - `Pristine` / `Valid` → `ValidationState::None`
447    /// - `Corrected { message, .. }` → `ValidationState::Corrected(message)`
448    /// - `Invalid { message }` → `ValidationState::Error(message)`
449    pub fn validation_feedback(mut self, feedback: Signal<ValidationFeedback>) -> Self {
450        let target = self.validation.clone();
451        // Snapshot once now so we observe the current state at construction
452        // time too (subsequent changes flow via the field's own commit
453        // pipeline; ctx.effect installed in build() does the live tracking).
454        target.set(feedback_to_state(&feedback.get()));
455        self.feedback_to_bridge = Some(feedback);
456        self
457    }
458
459    /// Attach a plain tooltip. Accepts `tr!(...)` or `lit!(...)`.
460    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
461        self.tooltip_text = Some(text.into());
462        self.rich_tooltip_source = None;
463        self.composite_tooltip_content = None;
464        self
465    }
466
467    /// Attach a registry-driven rich tooltip by key. Mutually exclusive with
468    /// `tooltip` and `composite_tooltip` (last call wins).
469    pub fn rich_tooltip_key(mut self, key: impl Into<String>) -> Self {
470        self.rich_tooltip_source = Some(RichTooltipSource::Key(key.into()));
471        self.tooltip_text = None;
472        self.composite_tooltip_content = None;
473        self
474    }
475
476    /// Attach an inline rich tooltip from a pre-built [`tooltip::TooltipContent`].
477    /// Mutually exclusive with `tooltip` and `composite_tooltip` (last call wins).
478    pub fn rich_tooltip(mut self, content: tooltip::TooltipContent) -> Self {
479        self.rich_tooltip_source = Some(RichTooltipSource::Content(content));
480        self.tooltip_text = None;
481        self.composite_tooltip_content = None;
482        self
483    }
484
485    /// Attach an inline rich tooltip from a pre-built [`tooltip::TooltipContent`].
486    /// Canonical alias for [`Self::rich_tooltip`] — matches the name used by
487    /// `Button`, `ComboBox`, and other widgets. Mutually exclusive with
488    /// `tooltip` and `composite_tooltip` (last call wins).
489    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
490        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
491        self.tooltip_text = None;
492        self.composite_tooltip_content = None;
493        self
494    }
495
496    /// Attach a composite tooltip — third tier, hosting an arbitrary
497    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
498    pub fn composite_tooltip(
499        mut self,
500        content: impl teksilo_core::widget::Widget + 'static,
501    ) -> Self {
502        self.composite_tooltip_content = Some(Box::new(content));
503        self.tooltip_text = None;
504        self.rich_tooltip_source = None;
505        self
506    }
507
508    // ── Signal accessors (call before add to tree) ──────────────────
509
510    /// The reactive text content signal.
511    pub fn text(&self) -> Signal<String> {
512        self.text.clone()
513    }
514}
515
516impl Widget for TextInput {
517    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
518        // TextInput is a heavy composite. We snapshot the theme once for
519        // static layout params (padding, border width, field height); the
520        // placeholder, clear-icon tint, and border/width are driven by
521        // roles and state signals, so theme switches repaint via the
522        // paint-time role resolver without riding through a zip here.
523        let _theme = ctx.theme();
524        use crate::styles::recipe_text_input_style as field_dims;
525        let self_id = ctx.self_id();
526        // Forward the enabled state into the arena; see IconButton.
527        ctx.enabled_when(self_id, self.enabled.clone());
528        let interaction = self.interaction.clone();
529        let validation = self.validation.clone();
530
531        // ── Build the inner editing primitive ──────────────────────
532        //
533        // The inner field owns the bound text signal, the document,
534        // engine, caret, clipboard, context menu — everything
535        // interactive. The composite just styles it.
536        let inner_height =
537            (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
538        let text_area_height =
539            (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
540
541        let mut field = TextInputField::new(self.text.clone()).share_handle(&self.field_handle);
542        field = field
543            .enabled(self.enabled.clone())
544            .read_only(self.read_only)
545            .placeholder(self.placeholder.clone())
546            .text_height(text_area_height)
547            .interaction_signal(interaction.clone());
548        if let Some(max) = self.max_length {
549            field = field.max_length(max);
550        }
551        if let Some(f) = self.char_filter.take() {
552            // Re-wrap the Rc'd closure into a plain closure for the
553            // primitive's builder surface, which owns its own Rc.
554            field = field.char_filter(move |c| (f)(c));
555        }
556        if let Some(cb) = self.on_submit.take() {
557            field = field.on_submit_fn(move |ctx| (cb)(ctx));
558        }
559        if let Some(cb) = self.on_blur.take() {
560            field = field.on_blur_fn(move |ctx| (cb)(ctx));
561        }
562        if !self.suffix.is_empty() {
563            field = field.suffix(std::mem::take(&mut self.suffix));
564        }
565        if let Some(mask) = self.input_mask.take() {
566            field = field.input_mask(mask);
567        }
568        field = field.input_purpose(self.input_purpose);
569        if let Some(active) = self.active_descendant.clone() {
570            field = field.active_descendant(active);
571        }
572        if let Some(controls) = self.controls.clone() {
573            field = field.controls(controls);
574        }
575        let validator_installed = self.validator.is_some();
576        if let Some(validator) = self.validator.take() {
577            // ValidatorFn is `Rc<dyn Fn(&str) -> ValidationOutcome>`.
578            // The primitive's builder takes a fresh closure; wrap the
579            // Rc in one so the caller can keep their own clones if
580            // they captured it before.
581            field = field.validator(move |s| (validator)(s));
582        }
583
584        // Expose the field's text signal for downstream reactivity
585        // (placeholder visibility, clear-button visibility) before
586        // the field is consumed by `ctx.add`.
587        let text_signal_for_vis = field.text();
588
589        // Capture the inner field's reactive accessors BEFORE
590        // `ctx.add` consumes it, so composing widgets that called
591        // `caret_position()` / `caret_setter()` /
592        // `validation_feedback_signal()` on us pre-build see live
593        // updates through the slots we mirror into.
594        let inner_caret = field.caret_position();
595        let inner_setter = field.caret_setter();
596        let inner_feedback = field.validation_feedback_signal();
597
598        // Add the field directly so we can capture its own WidgetId (needed to
599        // wire the validation strip as its `described_by`, below); wrap it by
600        // id instead of moving it into `Padding`.
601        let field_id = ctx.add(field);
602
603        // Text editing area, wrapped in vertical padding so slots
604        // (IconButton etc.) sit flush against top/bottom of the
605        // inner border area and are vertically centered by the HStack.
606        let padded_field = Padding::new(
607            field_dims::TEXT_FIELD_PADDING_VERTICAL,
608            0.0,
609            field_dims::TEXT_FIELD_PADDING_VERTICAL,
610            0.0,
611        )
612        .child_id(field_id);
613
614        // The placeholder lives in a local ZStack with the text field so
615        // it shares the same column in the HStack — no overlap with
616        // leading/trailing slots. The text field is the last ZStack child
617        // so it wins hit-testing (ZStack tests children in reverse order).
618        // `respect_intrinsic` on these `Expand` wrappers preserves the
619        // wrapped field's natural width (≈200 dp from `TextInputField`)
620        // as the column's intrinsic width. The enclosing `ZStack`
621        // measures its children with an unspecified proposal, so the
622        // parent's offered width never reaches the `HStack` during
623        // measurement — without auto-basis the column reports 0 dp and
624        // the whole composite collapses to `MinSize`'s 65 dp floor.
625        let text_column_id = if !self.placeholder.resolve_now().is_empty() {
626            // Match the inner TextInputField's text style + single-line
627            // behaviour so the placeholder layout box has the same
628            // intrinsic height as the rich-text engine's frame. Without
629            // `single_line()` the placeholder defaults to Wrap, which
630            // can report extra vertical leading space.
631            let ph = TextWidget::new(self.placeholder.clone())
632                .style(TextStyleRole::Body)
633                .color(TextRole::Secondary)
634                .single_line()
635                .a11y_hidden();
636            // Align the placeholder on the column's vertical midline,
637            // pinned to the leading edge where the typed text starts.
638            // `Padding(top=padding_vertical, bottom=padding_vertical)`
639            // pinned the placeholder to the top of its inset box, but
640            // the rich-text engine inside the field paints glyphs with
641            // its own line-leading offset, so the two paths drifted
642            // by a few pixels; aligning purely on the layout-box midline
643            // matches the engine's frame midline. Align mode measures the
644            // placeholder under the column's bounds, so the `single_line()`
645            // TextWidget caps itself at the available width and truncates
646            // with a trailing "…" when the field is too narrow, instead of
647            // painting its full line past the frame.
648            let ph_id = ctx.add(
649                Expand::new()
650                    .respect_intrinsic()
651                    .align_child(Alignment::CENTER_LEADING)
652                    .child(ph),
653            );
654            let visible = text_signal_for_vis.map(|t| t.is_empty());
655            ctx.visible_when(ph_id, visible);
656
657            // `Expand::horizontal().respect_intrinsic()` keeps the field's
658            // natural (mask-aware) width as the column's basis — so the
659            // composite reports a snug width when unconstrained and fills a
660            // wide frame via flex. Wrapping it in `Shrinkable` adds a shrink
661            // weight so a narrow row compresses the column below that basis and
662            // the field scrolls instead of overflowing.
663            ctx.add(
664                Shrinkable::new().child(
665                    Expand::horizontal().respect_intrinsic().child(
666                        ZStack::new()
667                            .add_child(ph_id) // below (placeholder)
668                            .child(padded_field), // on top (text field, gets hits)
669                    ),
670                ),
671            )
672        } else {
673            ctx.add(
674                Shrinkable::new()
675                    .child(Expand::horizontal().respect_intrinsic().child(padded_field)),
676            )
677        };
678
679        // HStack: [leading] [text_column] [clear] [trailing]
680        let mut row = HStack::new().spacing(4.0);
681
682        if let Some(leading) = self.leading_slot.take() {
683            let leading_id = ctx.add_boxed(leading);
684            row = row.add_child(leading_id);
685        }
686
687        row = row.add_child(text_column_id);
688
689        // Clear button (opt-in). The clear affordance clears the
690        // bound text signal — the field's ext→internal effect
691        // picks this up and wipes the document.
692        if self.show_clear_button {
693            let icon = (crate::icon_button::BuiltInIcons::global().clear)()
694                .icon_size(12.0)
695                .color(TextRole::Secondary);
696            let text_for_clear = self.text.clone();
697            let clear_id = ctx.add(
698                MinSize::new(16.0, 16.0)
699                    .child(crate::primitives::Center::new().child(icon))
700                    .on_tap(move |_pos, ctx| {
701                        text_for_clear.set(String::new());
702                        ctx.request_frame();
703                    })
704                    .cursor(CursorIcon::Pointer),
705            );
706            let visible = text_signal_for_vis.map(|t| !t.is_empty());
707            ctx.visible_when(clear_id, visible);
708            let reserve_id = ctx.add(
709                crate::primitives::FixedSize::new()
710                    .width(16.0_f32)
711                    .height(16.0_f32)
712                    .child_id(clear_id),
713            );
714            row = row.add_child(reserve_id);
715        }
716
717        if let Some(trailing) = self.trailing_slot.take() {
718            let trailing_id = ctx.add_boxed(trailing);
719            row = row.add_child(trailing_id);
720        }
721
722        let row_id = ctx.add(row);
723
724        // Derive the cfg signals the style needs. Map our internal
725        // `InteractionState` (5-way) to the trait's 3 boolean signals,
726        // and the composite `ValidationState` (carries a message) to
727        // the trait's flat `TextInputValidationLevel` enum.
728        let is_focused = interaction.map(|s| *s == InteractionState::Focused);
729        let is_hovered = interaction.map(|s| *s == InteractionState::Hovered);
730        // `is_disabled` derives from the arena (not from interaction).
731        let effective_enabled = ctx.effective_enabled_signal(self_id);
732        let is_disabled = effective_enabled.map(|on| !*on);
733        let validation_level = validation.map(|v| match v {
734            ValidationState::None => TextInputValidationLevel::None,
735            ValidationState::Error(_) => TextInputValidationLevel::Error,
736            ValidationState::Warning(_) => TextInputValidationLevel::Warning,
737            ValidationState::Corrected(_) => TextInputValidationLevel::Corrected,
738        });
739
740        // Resolve the active style: per-call override > theme slot >
741        // built-in `RecipeTextInputStyle` default. The style paints the
742        // bordered/filled frame + the corner radius + the horizontal
743        // padding around the editor row.
744        let style: SharedTextInputStyle = self
745            .style_override
746            .clone()
747            .or_else(|| ctx.theme().style_slots.text_input.clone())
748            .unwrap_or_else(|| Rc::new(crate::styles::RecipeTextInputStyle::default()));
749
750        let cfg = TextInputStyleConfig {
751            editor: row_id,
752            is_focused,
753            is_hovered,
754            is_disabled,
755            validation: validation_level,
756            variant: self.variant,
757        };
758        let chrome_id = style.make_body(&cfg, ctx);
759
760        let min_w = self.min_width.unwrap_or(65.0);
761        let frame_id =
762            ctx.add(MinSize::new(min_w, field_dims::TEXT_FIELD_HEIGHT).child_id(chrome_id));
763
764        // ── Inline validation strip ────────────────────────────────
765        // Maps `Signal<ValidationState>` to the `Signal<ValidationFeedback>`
766        // that `ValidationStrip` consumes. Empty/Pristine renders nothing
767        // (zero height) so the layout doesn't reflow.
768        let strip_feedback: Signal<ValidationFeedback> = self.validation.map(|v| match v {
769            ValidationState::None => ValidationFeedback::Pristine,
770            ValidationState::Error(msg) | ValidationState::Warning(msg) => {
771                ValidationFeedback::Invalid {
772                    message: msg.clone(),
773                }
774            }
775            ValidationState::Corrected(msg) => ValidationFeedback::Corrected {
776                message: msg.clone(),
777                since: std::time::Instant::now(),
778            },
779        });
780        let strip_id = ctx.add(ValidationStrip::new(strip_feedback));
781
782        // WCAG 3.3.1 / 3.3.3 (EN 301 549 11.5.2.7): associate the inline
783        // validation strip with the field so a screen reader announces the
784        // error / warning / correction message as the field's description when
785        // it gains focus. The strip renders nothing while Pristine, but the
786        // relation is harmless then and live the moment a message appears.
787        ctx.access_described_by(field_id, strip_id);
788
789        // Wrap frame + strip in a VStack with the configured gap. The frame is
790        // wrapped in `Expand::horizontal().respect_intrinsic()` so it claims
791        // the VStack's full width (a `VStack` lays a child out at its measured
792        // width, not stretched) while keeping the frame's natural width as the
793        // basis when unconstrained. A bounded proposal narrows it and the
794        // `Shrinkable` column compresses to fit.
795        let framed_id = ctx.add(Expand::horizontal().respect_intrinsic().child_id(frame_id));
796        let root_id = ctx.add(
797            VStack::new()
798                .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
799                .add_child(framed_id)
800                .add_child(strip_id),
801        );
802
803        // Tooltip — three mutually-exclusive setters; setters clear
804        // the others so exactly one branch runs.
805        if let Some(content) = self.composite_tooltip_content.take() {
806            let delay = ctx.theme().motion.tooltip_delay_heavy;
807            tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
808        } else if let Some(source) = self.rich_tooltip_source.take() {
809            let delay = ctx.theme().motion.tooltip_delay;
810            tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
811        } else if let Some(text) = self.tooltip_text.clone() {
812            let delay = ctx.theme().motion.tooltip_delay;
813            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
814        }
815
816        // The interaction signal no longer carries Disabled — the
817        // framework's arena enabled-state is the single source of
818        // truth. Style chrome that needs `is_disabled` derives it
819        // from `effective_enabled_signal(self_id)`.
820
821        // Bridge `validation_feedback` source → composite state.
822        // No dedupe — each commit changes the feedback identity even
823        // when the user-visible message stays the same (e.g. repeated
824        // Invalid commits), and the strip is cheap to repaint.
825        if let Some(src) = self.feedback_to_bridge.clone() {
826            let target = self.validation.clone();
827            ctx.effect(&src, move |fb| {
828                target.set(feedback_to_state(fb));
829            });
830        } else if validator_installed {
831            // Auto-bridge: a validator was installed but no explicit
832            // `validation_feedback` source was provided. Mirror
833            // the inner field's published outcome into our display
834            // state so calling `.validator(...)` on TextInput "just
835            // works" — the strip and border respond without a
836            // separate `.validation_feedback(...)` call.
837            let target = self.validation.clone();
838            let src = inner_feedback.clone();
839            ctx.effect(&src, move |fb| {
840                target.set(feedback_to_state(fb));
841            });
842        }
843
844        // Mirror inner field accessors into the slots that were
845        // captured before build by composing widgets.
846        //
847        // - caret_position: only mirror if the slot was lazy-initialized
848        //   (i.e. someone called `caret_position()` on us pre-build).
849        //   Seed with the current value, then forward changes.
850        // - caret_setter: store the inner field's setter Rc; the closure
851        //   we returned to callers forwards through this slot at call time.
852        // - validation_feedback_signal: always mirror (the slot's signal
853        //   is created in `new()` and may already have observers).
854        if let Some(target) = self.caret_position_slot.borrow().clone() {
855            target.set(inner_caret.get());
856            ctx.effect(&inner_caret, move |pos| {
857                if target.get() != *pos {
858                    target.set(*pos);
859                }
860            });
861        }
862        *self.caret_setter_slot.borrow_mut() = Some(inner_setter);
863        let outer_feedback = self.feedback_signal.clone();
864        outer_feedback.set(inner_feedback.get());
865        ctx.effect(&inner_feedback, move |fb| {
866            outer_feedback.set(fb.clone());
867        });
868
869        self.root_child_id = Some(root_id);
870        vec![root_id]
871    }
872
873    fn layout_response(
874        &self,
875        proposal: SizeProposal,
876        ctx: &LayoutContext,
877    ) -> teksilo_core::widget::LayoutResponse {
878        // The `Shrinkable` + `respect_intrinsic` editor column reports the
879        // field's natural (mask-aware) width when unconstrained, fills a wide
880        // frame via flex, and compresses on a deficit — so the composite just
881        // forwards its child's response.
882        self.root_child_id
883            .and_then(|id| ctx.child_size(id, proposal))
884            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
885            .into()
886    }
887
888    fn place_children(
889        &self,
890        bounds: Rect,
891        _proposal: SizeProposal,
892        children: &mut [WidgetPlacement],
893        _ctx: &LayoutContext,
894    ) {
895        if let Some(p) = children.first_mut() {
896            p.origin = Point::new(bounds.x, bounds.y);
897            p.size = bounds.size();
898        }
899    }
900
901    fn children(&self) -> Vec<WidgetId> {
902        self.root_child_id.into_iter().collect()
903    }
904
905    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
906        // The inner TextInputField handles Role::TextInput.
907        // The outer composite is transparent to a11y except for a
908        // pass-through label.
909        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
910        if let Some(ref label) = self.label {
911            builder.set_name(label.resolve_now());
912        }
913        // Framework a11y walker sets `set_disabled` from arena state.
914    }
915}
916
917/// Project a `ValidationFeedback` (validator-pipeline outcome) onto a
918/// `ValidationState` (composite display state). `Pristine` and `Valid`
919/// both clear; `Corrected` and `Invalid` carry their messages through.
920fn feedback_to_state(fb: &ValidationFeedback) -> ValidationState {
921    match fb {
922        ValidationFeedback::Pristine | ValidationFeedback::Valid => ValidationState::None,
923        ValidationFeedback::Corrected { message, .. } => {
924            ValidationState::Corrected(message.clone())
925        }
926        ValidationFeedback::Invalid { message } => ValidationState::Error(message.clone()),
927    }
928}