Skip to main content

fret_ui_kit/recipes/
input.rs

1use fret_core::{Color, Corners, Edges, Px};
2use fret_ui::Theme;
3use fret_ui::element::{ContainerProps, LayoutStyle, Overflow, RingPlacement, RingStyle};
4
5use crate::style::PaddingRefinement;
6use crate::style::{ColorFallback, MetricFallback};
7use crate::{ChromeRefinement, Size};
8
9#[derive(Debug, Clone, Copy)]
10pub struct InputTokenKeys {
11    pub padding_x: Option<&'static str>,
12    pub padding_y: Option<&'static str>,
13    pub min_height: Option<&'static str>,
14    pub radius: Option<&'static str>,
15    pub border_width: Option<&'static str>,
16    pub bg: Option<&'static str>,
17    pub border: Option<&'static str>,
18    pub border_focus: Option<&'static str>,
19    pub fg: Option<&'static str>,
20    pub text_px: Option<&'static str>,
21    pub selection: Option<&'static str>,
22}
23
24impl InputTokenKeys {
25    pub const fn none() -> Self {
26        Self {
27            padding_x: None,
28            padding_y: None,
29            min_height: None,
30            radius: None,
31            border_width: None,
32            bg: None,
33            border: None,
34            border_focus: None,
35            fg: None,
36            text_px: None,
37            selection: None,
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy)]
43pub struct ResolvedInputChrome {
44    pub padding: Edges,
45    pub min_height: Px,
46    pub radius: Px,
47    pub border_width: Px,
48    pub background: Color,
49    pub border_color: Color,
50    pub border_color_focused: Color,
51    pub text_color: Color,
52    pub text_px: Px,
53    pub selection_color: Color,
54}
55
56pub fn input_chrome_container_props(
57    mut layout: LayoutStyle,
58    chrome: ResolvedInputChrome,
59    border_color: Color,
60) -> ContainerProps {
61    layout.overflow = Overflow::Clip;
62    ContainerProps {
63        layout,
64        padding: chrome.padding.into(),
65        background: Some(chrome.background),
66        background_paint: None,
67        shadow: None,
68        border: Edges::all(chrome.border_width),
69        border_color: Some(border_color),
70        border_paint: None,
71        border_dash: None,
72        focus_ring: None,
73        focus_ring_always_paint: false,
74        focus_border_color: None,
75        focus_within: false,
76        corner_radii: Corners::all(chrome.radius),
77        snap_to_device_pixels: false,
78    }
79}
80
81pub fn resolve_input_chrome(
82    theme: &Theme,
83    size: Size,
84    style: &ChromeRefinement,
85    keys: InputTokenKeys,
86) -> ResolvedInputChrome {
87    // Priority:
88    // 1) callsite style refinement
89    // 2) component-specific token keys (if provided)
90    // 3) shared input-family token keys
91    // 4) size/baseline theme fallbacks
92
93    // `ChromeRefinement` supports per-edge padding (`pt/pr/pb/pl`). Inputs honor that directly,
94    // while falling back to component tokens / size defaults for any edge not explicitly set.
95    //
96    // Note: we intentionally do *not* treat a single-edge refinement (e.g. `pr-*`) as setting the
97    // entire axis: in Tailwind, `pr-*` only affects the right edge, and inputs frequently use this
98    // to reserve space for trailing icons.
99    let padding_x = keys
100        .padding_x
101        .and_then(|k| theme.metric_by_key(k))
102        .or_else(|| theme.metric_by_key("component.input.padding_x"))
103        .unwrap_or_else(|| size.input_px(theme));
104    let padding_y = keys
105        .padding_y
106        .and_then(|k| theme.metric_by_key(k))
107        .or_else(|| theme.metric_by_key("component.input.padding_y"))
108        .unwrap_or_else(|| size.input_py(theme));
109    let min_height = style
110        .min_height
111        .as_ref()
112        .map(|m| m.resolve(theme))
113        .or_else(|| keys.min_height.and_then(|k| theme.metric_by_key(k)))
114        .or_else(|| theme.metric_by_key("component.input.min_height"))
115        .unwrap_or_else(|| size.input_h(theme));
116    let radius = style
117        .radius
118        .as_ref()
119        .map(|m| m.resolve(theme))
120        .or_else(|| keys.radius.and_then(|k| theme.metric_by_key(k)))
121        .or_else(|| theme.metric_by_key("component.input.radius"))
122        .unwrap_or_else(|| size.control_radius(theme));
123    let border_width = style
124        .border_width
125        .as_ref()
126        .map(|m| m.resolve(theme))
127        .or_else(|| keys.border_width.and_then(|k| theme.metric_by_key(k)))
128        .or_else(|| theme.metric_by_key("component.input.border_width"))
129        .unwrap_or(Px(1.0));
130
131    let background = style
132        .background
133        .as_ref()
134        .map(|c| c.resolve(theme))
135        .or_else(|| keys.bg.and_then(|k| theme.color_by_key(k)))
136        .or_else(|| theme.color_by_key("component.input.bg"))
137        .unwrap_or_else(|| theme.color_token("background"));
138    let border_color = style
139        .border_color
140        .as_ref()
141        .map(|c| c.resolve(theme))
142        .or_else(|| keys.border.and_then(|k| theme.color_by_key(k)))
143        .or_else(|| theme.color_by_key("component.input.border"))
144        .unwrap_or_else(|| theme.color_token("input"));
145    let border_color_focused = keys
146        .border_focus
147        .and_then(|k| theme.color_by_key(k))
148        .or_else(|| theme.color_by_key("component.input.border_focus"))
149        .unwrap_or_else(|| theme.color_token("ring"));
150    let text_color = style
151        .text_color
152        .as_ref()
153        .map(|c| c.resolve(theme))
154        .or_else(|| keys.fg.and_then(|k| theme.color_by_key(k)))
155        .or_else(|| theme.color_by_key("component.input.fg"))
156        .unwrap_or_else(|| theme.color_token("foreground"));
157    let text_px = keys
158        .text_px
159        .and_then(|k| theme.metric_by_key(k))
160        .or_else(|| theme.metric_by_key("component.input.text_px"))
161        .unwrap_or_else(|| size.control_text_px(theme));
162    let selection_color = keys
163        .selection
164        .and_then(|k| theme.color_by_key(k))
165        .or_else(|| theme.color_by_key("component.input.selection"))
166        .unwrap_or_else(|| theme.color_token("selection.background"));
167
168    let padding_top = style
169        .padding
170        .as_ref()
171        .and_then(|p| p.top.as_ref())
172        .map(|m| m.resolve(theme))
173        .unwrap_or(padding_y);
174    let padding_bottom = style
175        .padding
176        .as_ref()
177        .and_then(|p| p.bottom.as_ref())
178        .map(|m| m.resolve(theme))
179        .unwrap_or(padding_y);
180    let padding_left = style
181        .padding
182        .as_ref()
183        .and_then(|p| p.left.as_ref())
184        .map(|m| m.resolve(theme))
185        .unwrap_or(padding_x);
186    let padding_right = style
187        .padding
188        .as_ref()
189        .and_then(|p| p.right.as_ref())
190        .map(|m| m.resolve(theme))
191        .unwrap_or(padding_x);
192
193    ResolvedInputChrome {
194        padding: Edges {
195            top: Px(padding_top.0.max(0.0)),
196            right: Px(padding_right.0.max(0.0)),
197            bottom: Px(padding_bottom.0.max(0.0)),
198            left: Px(padding_left.0.max(0.0)),
199        },
200        min_height: Px(min_height.0.max(0.0)),
201        radius: Px(radius.0.max(0.0)),
202        border_width: Px(border_width.0.max(0.0)),
203        background,
204        border_color,
205        border_color_focused,
206        text_color,
207        text_px,
208        selection_color,
209    }
210}
211
212pub fn default_text_input_style(theme: &Theme) -> fret_ui::TextInputStyle {
213    let ring_width = theme
214        .metric_by_key("component.ring.width")
215        .unwrap_or(Px(2.0));
216    let ring_offset = theme
217        .metric_by_key("component.ring.offset")
218        .unwrap_or(Px(2.0));
219    // shadcn/new-york-v4 uses `ring-ring/50` for the ring color.
220    let ring_color = theme
221        .color_by_key("ring/50")
222        .or_else(|| theme.color_by_key("ring"))
223        .unwrap_or_else(|| theme.color_token("ring"));
224    let ring_offset_color = theme
225        .color_by_key("ring-offset-background")
226        .unwrap_or_else(|| theme.color_token("ring-offset-background"));
227
228    let background = theme
229        .color_by_key("component.input.bg")
230        .unwrap_or_else(|| theme.color_token("background"));
231    let border_color = theme
232        .color_by_key("component.input.border")
233        .unwrap_or_else(|| theme.color_token("input"));
234    // shadcn/new-york-v4 uses `focus-visible:border-ring`.
235    let border_color_focused = theme
236        .color_by_key("ring")
237        .unwrap_or_else(|| theme.color_token("ring"));
238    let radius = theme
239        .metric_by_key("component.input.radius")
240        .unwrap_or_else(|| theme.metric_token("metric.radius.sm"));
241    let selection = theme
242        .color_by_key("component.input.selection")
243        .unwrap_or_else(|| theme.color_token("selection.background"));
244    let preedit_bg_color = {
245        let mut bg = selection;
246        bg.a = (bg.a * 0.35).clamp(0.0, 1.0);
247        bg
248    };
249
250    fret_ui::TextInputStyle {
251        padding: Edges::all(Px(0.0)),
252        background,
253        border: Edges::all(Px(1.0)),
254        border_color,
255        border_color_focused,
256        focus_ring: Some(RingStyle {
257            placement: RingPlacement::Outset,
258            width: ring_width,
259            offset: ring_offset,
260            color: ring_color,
261            offset_color: (ring_offset.0 > 0.0).then_some(ring_offset_color),
262            corner_radii: Corners::all(radius),
263        }),
264        corner_radii: Corners::all(radius),
265        text_color: theme.color_token("foreground"),
266        placeholder_color: theme.color_token("muted-foreground"),
267        selection_color: Color {
268            a: 1.0,
269            ..selection
270        },
271        caret_color: theme.color_token("foreground"),
272        preedit_bg_color,
273        preedit_color: theme.color_token("primary"),
274        preedit_underline_color: theme.color_token("primary"),
275    }
276}
277
278pub fn input_base_refinement() -> ChromeRefinement {
279    ChromeRefinement {
280        padding: Some(PaddingRefinement {
281            top: Some(crate::MetricRef::Token {
282                key: "component.input.padding_y",
283                fallback: MetricFallback::ThemePaddingSm,
284            }),
285            right: Some(crate::MetricRef::Token {
286                key: "component.input.padding_x",
287                fallback: MetricFallback::ThemePaddingSm,
288            }),
289            bottom: Some(crate::MetricRef::Token {
290                key: "component.input.padding_y",
291                fallback: MetricFallback::ThemePaddingSm,
292            }),
293            left: Some(crate::MetricRef::Token {
294                key: "component.input.padding_x",
295                fallback: MetricFallback::ThemePaddingSm,
296            }),
297        }),
298        border_width: Some(crate::MetricRef::Token {
299            key: "component.input.border_width",
300            fallback: MetricFallback::Px(Px(1.0)),
301        }),
302        radius: Some(crate::MetricRef::Token {
303            key: "component.input.radius",
304            fallback: MetricFallback::ThemeRadiusSm,
305        }),
306        background: Some(crate::ColorRef::Token {
307            key: "component.input.bg",
308            fallback: ColorFallback::ThemePanelBackground,
309        }),
310        border_color: Some(crate::ColorRef::Token {
311            key: "component.input.border",
312            fallback: ColorFallback::ThemePanelBorder,
313        }),
314        text_color: Some(crate::ColorRef::Token {
315            key: "component.input.fg",
316            fallback: ColorFallback::ThemeTextPrimary,
317        }),
318        ..ChromeRefinement::default()
319    }
320}