Skip to main content

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}