Skip to main content

teksilo_widgets/text_input/
widget_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementation for [`TextInput`]: the composed
5//! frame (border, placeholder overlay, clear button, slots), layout,
6//! placement and accessibility.
7
8use super::*;
9impl Widget for TextInput {
10    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
11        // TextInput is a heavy composite. We snapshot the theme once for
12        // static layout params (padding, border width, field height); the
13        // placeholder, clear-icon tint, and border/width are driven by
14        // roles and state signals, so theme switches repaint via the
15        // paint-time role resolver without riding through a zip here.
16        let _theme = ctx.theme();
17        use crate::styles::recipe_text_input_style as field_dims;
18        let self_id = ctx.self_id();
19        // Forward the enabled state into the arena; see IconButton.
20        ctx.enabled_when(self_id, self.enabled.clone());
21        let interaction = self.interaction.clone();
22        let validation = self.validation.clone();
23
24        // ── Build the inner editing primitive ──────────────────────
25        //
26        // The inner field owns the bound text signal, the document,
27        // engine, caret, clipboard, context menu — everything
28        // interactive. The composite just styles it.
29        //
30        // Both heights come from one resolution, so the text area and the
31        // frame cannot disagree: see `field_dims::TextFieldHeights`.
32        let heights = field_dims::TextFieldHeights::resolve(
33            self.min_height,
34            self.text_style.is_some(),
35            &ctx.theme().input,
36        );
37
38        let mut field = TextInputField::new(self.text.clone()).share_handle(&self.field_handle);
39        field = field
40            .enabled(self.enabled.clone())
41            .read_only(self.read_only)
42            .placeholder(self.placeholder.clone())
43            .interaction_signal(interaction.clone());
44        if let Some(text_height) = heights.text_area {
45            field = field.text_height(text_height);
46        }
47        if let Some(style) = self.text_style.clone() {
48            field = field.text_style(style);
49        }
50        if let Some(alignment) = self.text_alignment {
51            field = field.text_alignment(alignment);
52        }
53        if let Some(color) = self.text_color.clone() {
54            field = field.text_color(color);
55        }
56        if let Some(max) = self.max_length {
57            field = field.max_length(max);
58        }
59        if let Some(f) = self.char_filter.take() {
60            // Re-wrap the Rc'd closure into a plain closure for the
61            // primitive's builder surface, which owns its own Rc.
62            field = field.char_filter(move |c| (f)(c));
63        }
64        // Cloned, not taken: the payload is already an `Rc`, and `build` runs
65        // again on every rebuild of this widget. Taking it would leave the
66        // second build with no assistive-technology write path, so a `SpinBox`
67        // or date editor would silently stop committing an AT `SetValue` after
68        // the first rebuild.
69        if let Some(cb) = self.on_access_set_value.clone() {
70            field = field.on_access_set_value(move |text, ctx| (cb)(text, ctx));
71        }
72        if let Some(cb) = self.on_submit.take() {
73            field = field.on_submit_fn(move |ctx| (cb)(ctx));
74        }
75        if let Some(cb) = self.on_blur.take() {
76            field = field.on_blur_fn(move |ctx| (cb)(ctx));
77        }
78        if !self.suffix.is_empty() {
79            field = field.suffix(std::mem::take(&mut self.suffix));
80        }
81        if let Some(mask) = self.input_mask.take() {
82            field = field.input_mask(mask);
83        }
84        field = field.input_purpose(self.input_purpose);
85        if let Some(active) = self.active_descendant.clone() {
86            field = field.active_descendant(active);
87        }
88        if let Some(controls) = self.controls.clone() {
89            field = field.controls(controls);
90        }
91        let validator_installed = self.validator.is_some();
92        if let Some(validator) = self.validator.take() {
93            // ValidatorFn is `Rc<dyn Fn(&str) -> ValidationOutcome>`.
94            // The primitive's builder takes a fresh closure; wrap the
95            // Rc in one so the caller can keep their own clones if
96            // they captured it before.
97            field = field.validator(move |s| (validator)(s));
98        }
99
100        // Expose the field's text signal for downstream reactivity
101        // (placeholder visibility, clear-button visibility) before
102        // the field is consumed by `ctx.add`.
103        let text_signal_for_vis = field.text();
104
105        // Capture the inner field's reactive accessors BEFORE
106        // `ctx.add` consumes it, so composing widgets that called
107        // `caret_position()` / `caret_setter()` /
108        // `validation_feedback_signal()` on us pre-build see live
109        // updates through the slots we mirror into.
110        let inner_caret = field.caret_position();
111        let inner_setter = field.caret_setter();
112        let inner_feedback = field.validation_feedback_signal();
113
114        // Add the field directly so we can capture its own WidgetId (needed to
115        // wire the validation strip as its `described_by`, below); wrap it by
116        // id instead of moving it into `Padding`.
117        //
118        // The accessible name goes on the *field*, not on the composite's
119        // outer node. The outer node is a `Role::GenericContainer`, and
120        // `accesskit_consumer::common_filter` excludes that role from the
121        // filtered tree unconditionally — a name written there reaches no
122        // screen reader on any platform. The field is the node that carries
123        // `Role::TextInput` and holds focus, so it is the one that must be
124        // named. (`PasswordField` names its inner field the same way.)
125        // `LocalizedString -> Prop<String>` keeps the name locale-reactive.
126        let field_id = match self.label.clone() {
127            Some(label) => ctx.add(field.access_label(label)),
128            None => ctx.add(field),
129        };
130        self.field_id_slot.set(Some(field_id));
131
132        // Text editing area, wrapped in vertical padding so slots
133        // (IconButton etc.) sit flush against top/bottom of the
134        // inner border area and are vertically centered by the HStack.
135        let padded_field = Padding::new(
136            field_dims::TEXT_FIELD_PADDING_VERTICAL,
137            0.0,
138            field_dims::TEXT_FIELD_PADDING_VERTICAL,
139            0.0,
140        )
141        .child(field_id);
142
143        // The placeholder lives in a local ZStack with the text field so
144        // it shares the same column in the HStack — no overlap with
145        // leading/trailing slots. The text field is the last ZStack child
146        // so it wins hit-testing (ZStack tests children in reverse order).
147        // `respect_intrinsic` on these `Expand` wrappers preserves the
148        // wrapped field's natural width (≈200 dp from `TextInputField`)
149        // as the column's intrinsic width. The enclosing `ZStack`
150        // measures its children with an unspecified proposal, so the
151        // parent's offered width never reaches the `HStack` during
152        // measurement — without auto-basis the column reports 0 dp and
153        // the whole composite collapses to `MinSize`'s 65 dp floor.
154        let text_column_id = if !self.placeholder.resolve_now().is_empty() {
155            // Match the inner TextInputField's text style + single-line
156            // behaviour so the placeholder layout box has the same
157            // intrinsic height as the rich-text engine's frame. Without
158            // `single_line()` the placeholder defaults to Wrap, which
159            // can report extra vertical leading space.
160            let ph = TextWidget::new(self.placeholder.clone())
161                .style(
162                    self.text_style
163                        .clone()
164                        .unwrap_or(TextStyleProp::Role(TextStyleRole::Body)),
165                )
166                .color(TextRole::Secondary)
167                .single_line()
168                .a11y_hidden();
169            // Align the placeholder on the column's vertical midline,
170            // pinned to the leading edge where the typed text starts.
171            // `Padding(top=padding_vertical, bottom=padding_vertical)`
172            // pinned the placeholder to the top of its inset box, but
173            // the rich-text engine inside the field paints glyphs with
174            // its own line-leading offset, so the two paths drifted
175            // by a few pixels; aligning purely on the layout-box midline
176            // matches the engine's frame midline. Align mode measures the
177            // placeholder under the column's bounds, so the `single_line()`
178            // TextWidget caps itself at the available width and truncates
179            // with a trailing "…" when the field is too narrow, instead of
180            // painting its full line past the frame.
181            let ph_id = ctx.add(
182                Expand::new()
183                    .respect_intrinsic()
184                    .align_child(match self.text_alignment {
185                        None | Some(HAlignment::Leading) => Alignment::CENTER_LEADING,
186                        Some(HAlignment::Center) => Alignment::CENTER,
187                        Some(HAlignment::Trailing) => Alignment::CENTER_TRAILING,
188                    })
189                    .child(ph),
190            );
191            let visible = text_signal_for_vis.map(|t| t.is_empty());
192            ctx.visible_when(ph_id, visible);
193
194            // `Expand::horizontal().respect_intrinsic()` keeps the field's
195            // natural (mask-aware) width as the column's basis — so the
196            // composite reports a snug width when unconstrained and fills a
197            // wide frame via flex. Wrapping it in `Shrinkable` adds a shrink
198            // weight so a narrow row compresses the column below that basis and
199            // the field scrolls instead of overflowing.
200            ctx.add(
201                Shrinkable::new().child(
202                    Expand::horizontal().respect_intrinsic().child(
203                        ZStack::new()
204                            .child(ph_id) // below (placeholder)
205                            .child(padded_field), // on top (text field, gets hits)
206                    ),
207                ),
208            )
209        } else {
210            ctx.add(
211                Shrinkable::new()
212                    .child(Expand::horizontal().respect_intrinsic().child(padded_field)),
213            )
214        };
215
216        // HStack: [leading] [text_column] [clear] [trailing]
217        let mut row = HStack::new().spacing(4.0);
218
219        if let Some(leading) = self.leading_slot.take() {
220            let leading_id = ctx.add_boxed(leading);
221            row = row.child(leading_id);
222        }
223
224        row = row.child(text_column_id);
225
226        // Clear button (opt-in). The clear affordance clears the
227        // bound text signal — the field's ext→internal effect
228        // picks this up and wipes the document.
229        if self.show_clear_button {
230            let icon = (crate::icon_button::BuiltInIcons::global().clear)()
231                .icon_size(12.0)
232                .color(TextRole::Secondary);
233            let text_for_clear = self.text.clone();
234            let visible = text_signal_for_vis.map(|t| !t.is_empty());
235
236            // 16 dp of paint, 24 dp of target — the density rule's shape for a
237            // control genuinely below the floor that cannot grow: the slot's
238            // 16 dp reserves room inside the field's trailing edge, and raising
239            // it would widen every `TextInput` in the workspace at Compact. The
240            // shortfall is made up between the pointer and the arena instead.
241            //
242            // Three things about the arrangement are load-bearing. The outset
243            // has to be declared by the node that *takes* the press (the ring
244            // around an outset resolves to the declaring node, never to a
245            // descendant); it has to be declared by a direct child of the row
246            // (an outset never escapes its parent, so a wrapper pinned to the
247            // affordance's own size would have nowhere to grow); and the slot
248            // has to keep its size while the affordance is hidden or the row
249            // would jump — hence `fixed`, with the visibility on the glyph
250            // inside rather than on the slot. `active` withdraws the outset
251            // when the field is empty, so an invisible affordance never punches
252            // a hole in the field behind it.
253            let glyph_id = ctx.add(crate::primitives::Center::new().child(icon));
254            ctx.visible_when(glyph_id, visible.clone());
255            let clear_id = ctx.add(
256                crate::button::HitTarget::new()
257                    .fixed(16.0, 16.0)
258                    .active(visible.clone())
259                    .child_id(glyph_id)
260                    .on_tap(move |_pos, ctx| {
261                        if text_for_clear.get().is_empty() {
262                            return;
263                        }
264                        text_for_clear.set(String::new());
265                        ctx.request_frame();
266                    })
267                    .cursor(CursorIcon::Pointer),
268            );
269            row = row.child(clear_id);
270        }
271
272        if let Some(trailing) = self.trailing_slot.take() {
273            let trailing_id = ctx.add_boxed(trailing);
274            row = row.child(trailing_id);
275        }
276
277        let row_id = ctx.add(row);
278
279        // Derive the cfg signals the style needs. Map our internal
280        // `InteractionState` (5-way) to the trait's 3 boolean signals,
281        // and the composite `ValidationState` (carries a message) to
282        // the trait's flat `TextInputValidationLevel` enum.
283        let is_focused = interaction.map(|s| *s == InteractionState::Focused);
284        let is_hovered = interaction.map(|s| *s == InteractionState::Hovered);
285        // `is_disabled` derives from the arena (not from interaction).
286        let effective_enabled = ctx.effective_enabled_signal(self_id);
287        let is_disabled = effective_enabled.map(|on| !*on);
288        let validation_level = validation.map(|v| match v {
289            ValidationState::None => TextInputValidationLevel::None,
290            ValidationState::Error(_) => TextInputValidationLevel::Error,
291            ValidationState::Warning(_) => TextInputValidationLevel::Warning,
292            ValidationState::Corrected(_) => TextInputValidationLevel::Corrected,
293        });
294
295        // Resolve the active style: per-call override > theme slot >
296        // built-in `RecipeTextInputStyle` default. The style paints the
297        // bordered/filled frame + the corner radius + the horizontal
298        // padding around the editor row.
299        let style: SharedTextInputStyle = self
300            .style_override
301            .clone()
302            .or_else(|| ctx.theme().style_slots.text_input.clone())
303            .unwrap_or_else(|| {
304                Rc::new(crate::styles::RecipeTextInputStyle::for_tokens(
305                    &ctx.theme().input,
306                ))
307            });
308
309        let cfg = TextInputStyleConfig {
310            editor: row_id,
311            is_focused,
312            is_hovered,
313            is_disabled,
314            validation: validation_level,
315            variant: self.variant,
316            min_height: heights.style_min_height,
317        };
318        let chrome_id = style.make_body(&cfg, ctx);
319
320        let min_w = self.min_width.unwrap_or(65.0);
321        let frame_id = ctx.add(MinSize::new(min_w, heights.min_height).child(chrome_id));
322
323        // ── Inline validation strip ────────────────────────────────
324        // Maps `Signal<ValidationState>` to the `Signal<ValidationFeedback>`
325        // that `ValidationStrip` consumes. Empty/Pristine renders nothing
326        // (zero height) so the layout doesn't reflow.
327        let strip_feedback: Signal<ValidationFeedback> = self.validation.map(|v| match v {
328            ValidationState::None => ValidationFeedback::Pristine,
329            ValidationState::Error(msg) | ValidationState::Warning(msg) => {
330                ValidationFeedback::Invalid {
331                    message: msg.clone(),
332                }
333            }
334            ValidationState::Corrected(msg) => ValidationFeedback::Corrected {
335                message: msg.clone(),
336                since: std::time::Instant::now(),
337            },
338        });
339        let strip_id = ctx.add(ValidationStrip::new(strip_feedback));
340
341        // WCAG 3.3.1 / 3.3.3 (EN 301 549 11.5.2.7): the field points at the
342        // strip. The relation alone reaches no screen reader through AccessKit
343        // 0.25, so the tree writes the strip's message into the field's
344        // description, which every adapter exposes and a reader says as focus
345        // arrives (`teksilo_core`'s `accessibility_description_impl`). The strip is
346        // the voice for a message appearing, as a live region; the tree keeps
347        // it out of the field's description while focus stays, so a reader
348        // that speaks description changes does not say it twice. Harmless
349        // while Pristine: an empty strip describes nothing.
350        ctx.access_described_by(field_id, strip_id);
351
352        // Wrap frame + strip in a VStack with the configured gap. The frame is
353        // wrapped in `Expand::horizontal().respect_intrinsic()` so it claims
354        // the VStack's full width (a `VStack` lays a child out at its measured
355        // width, not stretched) while keeping the frame's natural width as the
356        // basis when unconstrained. A bounded proposal narrows it and the
357        // `Shrinkable` column compresses to fit.
358        let framed_id = ctx.add(Expand::horizontal().respect_intrinsic().child(frame_id));
359        let root_id = ctx.add(
360            VStack::new()
361                .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
362                .child(framed_id)
363                .child(strip_id),
364        );
365
366        // Tooltip — three mutually-exclusive setters; setters clear
367        // the others so exactly one branch runs.
368        if let Some(content) = self.composite_tooltip_content.take() {
369            let delay = ctx.theme().motion.tooltip_delay_heavy;
370            tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
371        } else if let Some(source) = self.rich_tooltip_source.take() {
372            let delay = ctx.theme().motion.tooltip_delay;
373            tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
374        } else if let Some(text) = self.tooltip_text.clone() {
375            let delay = ctx.theme().motion.tooltip_delay;
376            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
377        }
378
379        // The interaction signal no longer carries Disabled — the
380        // framework's arena enabled-state is the single source of
381        // truth. Style chrome that needs `is_disabled` derives it
382        // from `effective_enabled_signal(self_id)`.
383
384        // Bridge `validation_feedback` source → composite state.
385        // No dedupe — each commit changes the feedback identity even
386        // when the user-visible message stays the same (e.g. repeated
387        // Invalid commits), and the strip is cheap to repaint.
388        if let Some(src) = self.feedback_to_bridge.clone() {
389            let target = self.validation.clone();
390            ctx.effect(&src, move |fb| {
391                target.set(feedback_to_state(fb));
392            });
393        } else if validator_installed {
394            // Auto-bridge: a validator was installed but no explicit
395            // `validation_feedback` source was provided. Mirror
396            // the inner field's published outcome into our display
397            // state so calling `.validator(...)` on TextInput "just
398            // works" — the strip and border respond without a
399            // separate `.validation_feedback(...)` call.
400            let target = self.validation.clone();
401            let src = inner_feedback.clone();
402            ctx.effect(&src, move |fb| {
403                target.set(feedback_to_state(fb));
404            });
405        }
406
407        // Mirror inner field accessors into the slots that were
408        // captured before build by composing widgets.
409        //
410        // - caret_position: only mirror if the slot was lazy-initialized
411        //   (i.e. someone called `caret_position()` on us pre-build).
412        //   Seed with the current value, then forward changes.
413        // - caret_setter: store the inner field's setter Rc; the closure
414        //   we returned to callers forwards through this slot at call time.
415        // - validation_feedback_signal: always mirror (the slot's signal
416        //   is created in `new()` and may already have observers).
417        if let Some(target) = self.caret_position_slot.borrow().clone() {
418            target.set(inner_caret.get());
419            ctx.effect(&inner_caret, move |pos| {
420                if target.get() != *pos {
421                    target.set(*pos);
422                }
423            });
424        }
425        *self.caret_setter_slot.borrow_mut() = Some(inner_setter);
426        let outer_feedback = self.feedback_signal.clone();
427        outer_feedback.set(inner_feedback.get());
428        ctx.effect(&inner_feedback, move |fb| {
429            outer_feedback.set(fb.clone());
430        });
431
432        self.root_child_id = Some(root_id);
433        vec![root_id]
434    }
435
436    fn layout_response(
437        &self,
438        proposal: SizeProposal,
439        ctx: &LayoutContext,
440    ) -> teksilo_core::widget::LayoutResponse {
441        // The `Shrinkable` + `respect_intrinsic` editor column reports the
442        // field's natural (mask-aware) width when unconstrained, fills a wide
443        // frame via flex, and compresses on a deficit — so the composite just
444        // forwards its child's response.
445        self.root_child_id
446            .and_then(|id| ctx.child_size(id, proposal))
447            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
448            .into()
449    }
450
451    fn place_children(
452        &self,
453        bounds: Rect,
454        _proposal: SizeProposal,
455        children: &mut [WidgetPlacement],
456        _ctx: &LayoutContext,
457    ) {
458        if let Some(p) = children.first_mut() {
459            p.origin = Point::new(bounds.x, bounds.y);
460            p.size = bounds.size();
461        }
462    }
463
464    fn children(&self) -> Vec<WidgetId> {
465        self.root_child_id.into_iter().collect()
466    }
467
468    /// Focus belongs on the inner field, never on this composite: the outer
469    /// node is a `Role::GenericContainer` and is not focusable at all.
470    ///
471    /// Without this, a `TextInput` inside deferred modal content could not be
472    /// focused on open. The modal pipeline asks the content tree for a hint
473    /// before falling back to the first focusable descendant, and a composite
474    /// that answered nothing was skipped over.
475    fn initial_focus_hint(&self) -> Option<WidgetId> {
476        self.field_id_slot.get()
477    }
478
479    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
480        // The inner TextInputField handles Role::TextInput. The outer
481        // composite is transparent to a11y: `Role::GenericContainer` is
482        // excluded from the filtered tree by
483        // `accesskit_consumer::common_filter`, so nothing written here
484        // reaches a screen reader. In particular the `label` is NOT set
485        // here — it is applied to the inner field in `build`, which is the
486        // node that survives the filter and holds focus.
487        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
488        // Framework a11y walker sets `set_disabled` from arena state.
489    }
490}