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}