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