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