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}