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, SurfaceRole};
39
40use crate::primitives::{MinSize, Padding, RectWidget, ZStack};
41
42// IntUI design tokens for TextInput / TextInputField (also used by
43// SpinBox, DateEdit, DateRangeEdit, DateTimeEdit since they share the
44// same form-field baseline). The recipe and form-field composers own
45// these constants.
46pub const TEXT_FIELD_HEIGHT: f32 = 28.0;
47pub const TEXT_FIELD_PADDING_HORIZONTAL: f32 = 4.0;
48pub const TEXT_FIELD_PADDING_VERTICAL: f32 = 4.0;
49pub const TEXT_FIELD_BORDER_WIDTH: f32 = 1.0;
50pub const TEXT_FIELD_CORNER_RADIUS: f32 = 4.0;
51pub const TEXT_FIELD_CARET_WIDTH: f32 = 1.0;
52pub const TEXT_FIELD_VALIDATION_STRIP_GAP: f32 = 4.0;
53pub const TEXT_FIELD_ERROR_PULSE_DURATION_MS: u32 = 240;
54pub const TEXT_FIELD_CORRECTED_PULSE_DURATION_MS: u32 = 1500;
55pub const TEXT_FIELD_MASK_PLACEHOLDER_CHAR: char = '_';
56
57/// Dimension recipe for [`RecipeTextInputStyle`].
58///
59/// Every `pub const TEXT_FIELD_*` is mirrored as a typed field so callers
60/// can override individual dimensions without writing a full custom style.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct TextInputRecipe {
63    pub height: f32,
64    pub padding_horizontal: f32,
65    pub padding_vertical: f32,
66    pub border_width: f32,
67    pub corner_radius: f32,
68    pub caret_width: f32,
69    pub validation_strip_gap: f32,
70    pub error_pulse_duration_ms: u32,
71    pub corrected_pulse_duration_ms: u32,
72    pub mask_placeholder_char: char,
73}
74
75impl Default for TextInputRecipe {
76    fn default() -> Self {
77        Self {
78            height: TEXT_FIELD_HEIGHT,
79            padding_horizontal: TEXT_FIELD_PADDING_HORIZONTAL,
80            padding_vertical: TEXT_FIELD_PADDING_VERTICAL,
81            border_width: TEXT_FIELD_BORDER_WIDTH,
82            corner_radius: TEXT_FIELD_CORNER_RADIUS,
83            caret_width: TEXT_FIELD_CARET_WIDTH,
84            validation_strip_gap: TEXT_FIELD_VALIDATION_STRIP_GAP,
85            error_pulse_duration_ms: TEXT_FIELD_ERROR_PULSE_DURATION_MS,
86            corrected_pulse_duration_ms: TEXT_FIELD_CORRECTED_PULSE_DURATION_MS,
87            mask_placeholder_char: TEXT_FIELD_MASK_PLACEHOLDER_CHAR,
88        }
89    }
90}
91
92/// Default `TextInputStyle` shipped with Teksilo.
93#[derive(Debug, Default, Clone, Copy)]
94pub struct RecipeTextInputStyle {
95    pub recipe: TextInputRecipe,
96}
97
98impl RecipeTextInputStyle {
99    pub fn new(recipe: TextInputRecipe) -> Self {
100        Self { recipe }
101    }
102}
103
104impl TextInputStyle for RecipeTextInputStyle {
105    fn make_body(&self, cfg: &TextInputStyleConfig, ctx: &mut BuildContext) -> WidgetId {
106        let theme = ctx.theme();
107        let border_width = self.recipe.border_width;
108        let focus_ring_width = theme.shape.focus_ring_width;
109        let padding_h = self.recipe.padding_horizontal;
110        let corner_radius = self.recipe.corner_radius;
111        let height = self.recipe.height;
112
113        // Bare variant: no chrome at all. Just hand the editor back
114        // wrapped in a MinSize so consumers still get a predictable
115        // intrinsic height.
116        if matches!(cfg.variant, TextInputVariant::Bare) {
117            return ctx.add(MinSize::new(0.0, height).child_id(cfg.editor));
118        }
119
120        // Derived border role: disabled trumps everything (an inert field
121        // must not shout a validation error the user cannot act on), then
122        // validation outcome trumps focus, and focus trumps default.
123        let border_role = derive_border_role(
124            cfg.is_focused.clone(),
125            cfg.validation.clone(),
126            cfg.is_disabled.clone(),
127        );
128
129        // Border width: thickens to focus_ring_width when focused,
130        // regardless of validation. For `Filled`, force 0.
131        let variant = cfg.variant;
132        let border_width_signal = cfg.is_focused.map(move |focused| match variant {
133            TextInputVariant::Filled => 0.0,
134            _ => {
135                if *focused {
136                    focus_ring_width
137                } else {
138                    border_width
139                }
140            }
141        });
142
143        // Background role. `SurfaceRole::Field` is `Content`'s twin for
144        // *interactive* surfaces: identical while enabled, but it dims to
145        // `SurfaceRole::Disabled` inside `ColorProp::resolve` at paint time.
146        // Going through that hook (rather than switching the role from
147        // `cfg.is_disabled` here) is what makes a field dim when an
148        // *ancestor* is disabled — `is_disabled` is derived from
149        // `effective_enabled_signal`, which cannot see ancestors, since a
150        // widget's parent is not wired yet during its own `build()`.
151        // Filled keeps its faint tint, and dims from the signal.
152        let bg_role: ColorProp = match variant {
153            TextInputVariant::Filled => ColorProp::DynamicSurfaceRole(cfg.is_disabled.map(|d| {
154                if *d {
155                    SurfaceRole::Disabled
156                } else {
157                    SurfaceRole::Hover
158                }
159            })),
160            _ => SurfaceRole::Field.into(),
161        };
162
163        let bg = RectWidget::new()
164            .background(bg_role)
165            .border_color(border_role)
166            .border_width(border_width_signal)
167            .corner_radius(CornerRadius::uniform(corner_radius));
168        let bg_id = ctx.add(bg);
169
170        // Horizontal-only padding so leading / trailing slots inside
171        // the editor row sit flush against top and bottom of the frame.
172        let padded_id = ctx.add(Padding::new(0.0, padding_h, 0.0, padding_h).child_id(cfg.editor));
173
174        let zstack_id = ctx.add(ZStack::new().add_child(bg_id).add_child(padded_id));
175        ctx.add(MinSize::new(0.0, height).child_id(zstack_id))
176    }
177}
178
179/// Derive the border role from disabled + focus + validation. Disabled
180/// outranks both — an inert field reads as grey, not as a live error the
181/// user could still fix. Below that, validation tints override the focus
182/// tint, so a typo in a focused field still reads as an error rather than
183/// as "focused and fine".
184fn derive_border_role(
185    is_focused: Signal<bool>,
186    validation: Signal<TextInputValidationLevel>,
187    is_disabled: Signal<bool>,
188) -> Signal<BorderRole> {
189    is_focused
190        .zip3(&validation, &is_disabled)
191        .map(|(focused, level, disabled)| {
192            if *disabled {
193                return BorderRole::Disabled;
194            }
195            match *level {
196                TextInputValidationLevel::Error => BorderRole::Error,
197                TextInputValidationLevel::Warning => BorderRole::Warning,
198                // Corrected: accent tint (matches the IntUI "we changed
199                // something — look here briefly" cue). The decay back to
200                // default is driven by the widget setting the validation
201                // signal back to None after the corrected pulse.
202                TextInputValidationLevel::Corrected | TextInputValidationLevel::Info => {
203                    BorderRole::Focused
204                }
205                TextInputValidationLevel::None => {
206                    if *focused {
207                        BorderRole::Focused
208                    } else {
209                        // `Field`, not `Default`: same colour while enabled,
210                        // but it dims at paint time even when the field is
211                        // only disabled via an ancestor. See `bg_role`.
212                        BorderRole::Field
213                    }
214                }
215            }
216        })
217}