teksilo_widgets/styles/recipe_text_input_style.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `TextInputStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeTextInputStyle` ships the IntUI chrome: a bordered rectangle
7//! around the editor area with a horizontal-padding inset, the border
8//! thickening and recolouring on focus, and validation tints (error /
9//! warning / corrected) overriding focus when set. The validation
10//! strip below the field is the widget's responsibility — the trait
11//! recipe is just the bordered frame.
12//!
13//! The recipe describes border / fill / corner radius only; the rest
14//! stays on the widget. Caret blinking, IME composition, placeholder
15//! layering, leading / trailing slots, clear button, the
16//! ValidationStrip below — all stay on the public `TextInput` widget.
17//!
18//! Variants:
19//!
20//! - `Outlined` (default) — 1 dp border in the theme's default border
21//! role; thickens to `focus_ring_width` on focus.
22//! - `Filled` — accent-subtle background, no border. Material 3 style.
23//! - `Underline` — transparent surface with a single bottom border.
24//! For now this is rendered as Outlined with the same border on all
25//! sides; a true bottom-only stroke arrives once `BorderPosition` /
26//! per-side stroke recipes land.
27//! - `Bare` — no chrome at all. Returns the editor verbatim. Used by
28//! parents that own the chrome themselves (search fields, combo box
29//! filter input).
30
31use teksilo_core::build_context::BuildContext;
32use teksilo_core::color_prop::ColorProp;
33use teksilo_core::signal::Signal;
34use teksilo_core::styles::{
35 TextInputStyle, TextInputStyleConfig, TextInputValidationLevel, TextInputVariant,
36};
37use teksilo_core::widget_id::WidgetId;
38use teksilo_tokens::{BorderRole, CornerRadius, InputTokens, SurfaceRole, TargetRole};
39
40use crate::primitives::{MinSize, Padding, RectWidget, ZStack};
41use teksilo_core::styles::density::{dp, spacing};
42
43// IntUI design tokens for TextInput / TextInputField (also used by
44// SpinBox, DateEdit, DateRangeEdit, DateTimeEdit since they share the
45// same form-field baseline). The recipe and form-field composers own
46// these constants.
47pub const TEXT_FIELD_HEIGHT: f32 = 28.0;
48pub const TEXT_FIELD_PADDING_HORIZONTAL: f32 = 4.0;
49pub const TEXT_FIELD_PADDING_VERTICAL: f32 = 4.0;
50pub const TEXT_FIELD_BORDER_WIDTH: f32 = 1.0;
51pub const TEXT_FIELD_CORNER_RADIUS: f32 = 4.0;
52pub const TEXT_FIELD_CARET_WIDTH: f32 = 1.0;
53pub const TEXT_FIELD_VALIDATION_STRIP_GAP: f32 = 4.0;
54pub const TEXT_FIELD_ERROR_PULSE_DURATION_MS: u32 = 240;
55pub const TEXT_FIELD_CORRECTED_PULSE_DURATION_MS: u32 = 1500;
56pub const TEXT_FIELD_MASK_PLACEHOLDER_CHAR: char = '_';
57
58/// Dimension recipe for [`RecipeTextInputStyle`].
59///
60/// Every `pub const TEXT_FIELD_*` is mirrored as a typed field so callers
61/// can override individual dimensions without writing a full custom style.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct TextInputRecipe {
64 pub height: f32,
65 pub padding_horizontal: f32,
66 pub padding_vertical: f32,
67 pub border_width: f32,
68 pub corner_radius: f32,
69 pub caret_width: f32,
70 pub validation_strip_gap: f32,
71 pub error_pulse_duration_ms: u32,
72 pub corrected_pulse_duration_ms: u32,
73 pub mask_placeholder_char: char,
74}
75
76impl TextInputRecipe {
77 /// This recipe's dimensions resolved against a density's [`InputTokens`].
78 ///
79 /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
80 /// ladder — so the shipped values below are the Compact column by
81 /// construction and cannot drift from it.
82 pub fn for_tokens(tokens: &InputTokens) -> Self {
83 Self {
84 height: dp(TEXT_FIELD_HEIGHT, TargetRole::Target, tokens),
85 padding_horizontal: spacing(TEXT_FIELD_PADDING_HORIZONTAL, tokens),
86 padding_vertical: spacing(TEXT_FIELD_PADDING_VERTICAL, tokens),
87 border_width: TEXT_FIELD_BORDER_WIDTH,
88 corner_radius: TEXT_FIELD_CORNER_RADIUS,
89 caret_width: TEXT_FIELD_CARET_WIDTH,
90 validation_strip_gap: spacing(TEXT_FIELD_VALIDATION_STRIP_GAP, tokens),
91 error_pulse_duration_ms: TEXT_FIELD_ERROR_PULSE_DURATION_MS,
92 corrected_pulse_duration_ms: TEXT_FIELD_CORRECTED_PULSE_DURATION_MS,
93 mask_placeholder_char: TEXT_FIELD_MASK_PLACEHOLDER_CHAR,
94 }
95 }
96}
97
98impl Default for TextInputRecipe {
99 fn default() -> Self {
100 Self::for_tokens(&InputTokens::default())
101 }
102}
103
104/// The heights a `TextInput` builds its field with, resolved in one place so
105/// the text area, the widget's own minimum and the style's minimum cannot
106/// disagree.
107///
108/// The text area is one line of text and the minimum is a floor under the
109/// whole frame; the frame is the larger of the two plus its chrome, and a frame
110/// taller than its content centres the line. A minimum never squeezes the text
111/// area — the line would be clipped — so a floor below what the text needs has
112/// no visible effect.
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub(crate) struct TextFieldHeights {
115 /// The text area's height to hand the inner field, at a text scale of 1.
116 /// `None` when a text style is set: the field then sizes it from the
117 /// style's line, and follows the theme when the style is a role.
118 pub text_area: Option<f32>,
119 /// The frame's minimum height.
120 pub min_height: f32,
121 /// The minimum the style is told
122 /// ([`TextInputStyleConfig::min_height`]). `None` leaves the style its
123 /// own, which is the default path.
124 pub style_min_height: Option<f32>,
125}
126
127impl TextFieldHeights {
128 /// Resolve the heights from the app's explicit `min_height`, whether a
129 /// text style is set, and the density.
130 ///
131 /// - Neither set: the IntUI field exactly as it has always been — an
132 /// 18 dp text area (the control height less its border and padding)
133 /// in a frame of at least [`TextInputRecipe::height`].
134 /// - A text style: the style's line sizes the text area, and the floor
135 /// drops to the density's target size, the WCAG 2.5.8 bar, so a small
136 /// style makes a short field and a large style a tall one.
137 /// - An explicit minimum: honoured as given, in both the frame and the
138 /// style.
139 pub(crate) fn resolve(min_height: Option<f32>, styled: bool, tokens: &InputTokens) -> Self {
140 let floor = match (min_height, styled) {
141 (Some(height), _) => Some(height),
142 (None, true) => Some(tokens.target_size),
143 (None, false) => None,
144 };
145 let text_area = (!styled).then(|| {
146 (TEXT_FIELD_HEIGHT - 2.0 * TEXT_FIELD_BORDER_WIDTH - 2.0 * TEXT_FIELD_PADDING_VERTICAL)
147 .max(0.0)
148 });
149 Self {
150 text_area,
151 min_height: floor.unwrap_or_else(|| TextInputRecipe::for_tokens(tokens).height),
152 style_min_height: floor,
153 }
154 }
155}
156
157/// Default `TextInputStyle` shipped with Teksilo.
158#[derive(Debug, Default, Clone, Copy)]
159pub struct RecipeTextInputStyle {
160 pub recipe: TextInputRecipe,
161}
162
163impl RecipeTextInputStyle {
164 pub fn new(recipe: TextInputRecipe) -> Self {
165 Self { recipe }
166 }
167
168 /// This style with every dimension resolved against a density's
169 /// [`InputTokens`], as `RecipeTextInputStyle::for_tokens(&ctx.theme().input)` at
170 /// the widget's own build site.
171 ///
172 /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
173 /// tree gets exactly the values this module documents.
174 pub fn for_tokens(tokens: &InputTokens) -> Self {
175 Self {
176 recipe: TextInputRecipe::for_tokens(tokens),
177 }
178 }
179}
180
181impl TextInputStyle for RecipeTextInputStyle {
182 fn make_body(&self, cfg: &TextInputStyleConfig, ctx: &mut BuildContext) -> WidgetId {
183 let theme = ctx.theme();
184 let border_width = self.recipe.border_width;
185 let focus_ring_width = theme.shape.focus_ring_width;
186 let padding_h = self.recipe.padding_horizontal;
187 let corner_radius = self.recipe.corner_radius;
188 // The widget's resolved minimum wins over the recipe's control height:
189 // an app that asked for a shorter or taller field gets it here too.
190 let height = cfg.min_height.unwrap_or(self.recipe.height);
191
192 // Bare variant: no chrome at all. Just hand the editor back
193 // wrapped in a MinSize so consumers still get a predictable
194 // intrinsic height.
195 if matches!(cfg.variant, TextInputVariant::Bare) {
196 return ctx.add(MinSize::new(0.0, height).child(cfg.editor));
197 }
198
199 // Derived border role: disabled trumps everything (an inert field
200 // must not shout a validation error the user cannot act on), then
201 // validation outcome trumps focus, and focus trumps default.
202 let border_role = derive_border_role(
203 cfg.is_focused.clone(),
204 cfg.validation.clone(),
205 cfg.is_disabled.clone(),
206 );
207
208 // Border width: thickens to focus_ring_width when focused,
209 // regardless of validation. For `Filled`, force 0.
210 let variant = cfg.variant;
211 let border_width_signal = cfg.is_focused.map(move |focused| match variant {
212 TextInputVariant::Filled => 0.0,
213 _ => {
214 if *focused {
215 focus_ring_width
216 } else {
217 border_width
218 }
219 }
220 });
221
222 // Background role. `SurfaceRole::Field` is `Content`'s twin for
223 // *interactive* surfaces: identical while enabled, but it dims to
224 // `SurfaceRole::Disabled` inside `ColorProp::resolve` at paint time.
225 // Going through that hook (rather than switching the role from
226 // `cfg.is_disabled` here) is what makes a field dim when an
227 // *ancestor* is disabled — `is_disabled` is derived from
228 // `effective_enabled_signal`, which cannot see ancestors, since a
229 // widget's parent is not wired yet during its own `build()`.
230 // Filled keeps its faint tint, and dims from the signal.
231 let bg_role: ColorProp = match variant {
232 TextInputVariant::Filled => ColorProp::DynamicSurfaceRole(cfg.is_disabled.map(|d| {
233 if *d {
234 SurfaceRole::Disabled
235 } else {
236 SurfaceRole::Hover
237 }
238 })),
239 _ => SurfaceRole::Field.into(),
240 };
241
242 let bg = RectWidget::new()
243 .background(bg_role)
244 .border_color(border_role)
245 .border_width(border_width_signal)
246 .corner_radius(CornerRadius::uniform(corner_radius));
247 let bg_id = ctx.add(bg);
248
249 // Horizontal-only padding so leading / trailing slots inside
250 // the editor row sit flush against top and bottom of the frame.
251 let padded_id = ctx.add(Padding::new(0.0, padding_h, 0.0, padding_h).child(cfg.editor));
252
253 let zstack_id = ctx.add(ZStack::new().child(bg_id).child(padded_id));
254 ctx.add(MinSize::new(0.0, height).child(zstack_id))
255 }
256}
257
258/// Derive the border role from disabled + focus + validation. Disabled
259/// outranks both — an inert field reads as grey, not as a live error the
260/// user could still fix. Below that, validation tints override the focus
261/// tint, so a typo in a focused field still reads as an error rather than
262/// as "focused and fine".
263fn derive_border_role(
264 is_focused: Signal<bool>,
265 validation: Signal<TextInputValidationLevel>,
266 is_disabled: Signal<bool>,
267) -> Signal<BorderRole> {
268 is_focused
269 .zip3(&validation, &is_disabled)
270 .map(|(focused, level, disabled)| {
271 if *disabled {
272 return BorderRole::Disabled;
273 }
274 match *level {
275 TextInputValidationLevel::Error => BorderRole::Error,
276 TextInputValidationLevel::Warning => BorderRole::Warning,
277 // Corrected: accent tint (matches the IntUI "we changed
278 // something — look here briefly" cue). The decay back to
279 // default is driven by the widget setting the validation
280 // signal back to None after the corrected pulse.
281 TextInputValidationLevel::Corrected | TextInputValidationLevel::Info => {
282 BorderRole::Focused
283 }
284 TextInputValidationLevel::None => {
285 if *focused {
286 BorderRole::Focused
287 } else {
288 // `Field`, not `Default`: same colour while enabled,
289 // but it dims at paint time even when the field is
290 // only disabled via an ancestor. See `bg_role`.
291 BorderRole::Field
292 }
293 }
294 }
295 })
296}