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