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}