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}