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}