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