Skip to main content

teksilo_widgets/styles/
recipe_standard_item_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `StandardItemStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeStandardItemStyle` reproduces the IntUI selection chrome
7//! used by `StandardListItem` / `StandardTreeItem`: a rounded
8//! selection background inset horizontally so the rounded corners
9//! show, with content padded inside. Selection / hover / pressed all
10//! cycle through the same `SurfaceRole::{Selected | AccentSubtle |
11//! Pressed}` cascade.
12//!
13//! The host widget composes the row contents (`[checkbox?]
14//! [leading?] [center?] label_column [Spacer] [trailing?]`) and
15//! passes the result as `cfg.content`; this style only owns the
16//! chrome.
17
18use teksilo_core::build_context::BuildContext;
19use teksilo_core::signal::Signal;
20use teksilo_core::styles::{SharedStandardItemStyle, StandardItemStyle, StandardItemStyleConfig};
21use teksilo_core::widget_id::WidgetId;
22use teksilo_tokens::{BorderRole, CornerRadius, InputTokens, SurfaceRole, TargetRole};
23
24use crate::primitives::{Expand, Padding, RectWidget, ZStack};
25use teksilo_core::styles::density::{dp, spacing};
26
27// IntUI design tokens for StandardListItem / StandardTreeItem.
28// The recipe owns its own dimensions.
29pub const STANDARD_ITEM_ICON_SIZE: f32 = 16.0;
30pub const STANDARD_ITEM_SUBTITLE_ICON_SIZE: f32 = 12.0;
31pub const STANDARD_ITEM_SLOT_GAP: f32 = 8.0;
32pub const STANDARD_ITEM_SUBTITLE_SLOT_GAP: f32 = 6.0;
33pub const STANDARD_ITEM_LABEL_SUBTITLE_GAP: f32 = 2.0;
34pub const STANDARD_ITEM_PADDING_HORIZONTAL: f32 = 8.0;
35pub const STANDARD_ITEM_PADDING_VERTICAL: f32 = 4.0;
36pub const STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE: f32 = 28.0;
37pub const STANDARD_ITEM_MIN_HEIGHT_TWO_LINE: f32 = 44.0;
38/// Compression floor for the label column when the caller opted its label or
39/// subtitle into truncation (`label_overflow` / `subtitle_overflow`). Wide
40/// enough that a truncated label keeps a few glyphs before the ellipsis rather
41/// than collapsing to nothing on a very narrow row.
42pub const STANDARD_ITEM_LABEL_COLUMN_MIN_WIDTH: f32 = 48.0;
43pub const STANDARD_ITEM_CHEVRON_COLUMN_WIDTH: f32 = 16.0;
44pub const STANDARD_ITEM_TREE_INDENT_STEP: f32 = 16.0;
45pub const STANDARD_ITEM_ITEM_CORNER_RADIUS: f32 = 8.0;
46pub const STANDARD_ITEM_BG_HORIZONTAL_INSET: f32 = 4.0;
47/// Keyboard-focus ring thickness for the current item while its view is focused.
48pub const STANDARD_ITEM_FOCUS_RING_WIDTH: f32 = 1.5;
49/// Border thickness drawn around a *selected* item (in the `BorderRole::Focused`
50/// accent colour) even without keyboard focus. A non-color-alone, >= 3:1
51/// boundary cue so selection is perceivable beyond the low-contrast
52/// `surface_selected` wash (WCAG 1.4.1 Use of Color / 1.4.11 Non-text
53/// Contrast). Thinner than the keyboard-focus ring so the two stay
54/// distinguishable.
55pub const STANDARD_ITEM_SELECTION_EDGE_WIDTH: f32 = 1.0;
56
57/// Configurable dimensions for [`RecipeStandardItemStyle`].
58///
59/// The [`Default`] implementation reads the module-level `pub const` tokens,
60/// so a `RecipeStandardItemStyle::default()` is identical to the old unit struct.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct StandardItemRecipe {
63    pub icon_size: f32,
64    pub subtitle_icon_size: f32,
65    pub slot_gap: f32,
66    pub subtitle_slot_gap: f32,
67    pub label_subtitle_gap: f32,
68    pub padding_horizontal: f32,
69    pub padding_vertical: f32,
70    pub min_height_single_line: f32,
71    pub min_height_two_line: f32,
72    pub chevron_column_width: f32,
73    pub tree_indent_step: f32,
74    pub item_corner_radius: f32,
75    pub bg_horizontal_inset: f32,
76    pub focus_ring_width: f32,
77    pub selection_edge_width: f32,
78}
79
80impl StandardItemRecipe {
81    /// This recipe's dimensions resolved against a density's [`InputTokens`].
82    ///
83    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
84    /// ladder — so the shipped values below are the Compact column by
85    /// construction and cannot drift from it.
86    pub fn for_tokens(tokens: &InputTokens) -> Self {
87        Self {
88            icon_size: STANDARD_ITEM_ICON_SIZE,
89            subtitle_icon_size: STANDARD_ITEM_SUBTITLE_ICON_SIZE,
90            slot_gap: spacing(STANDARD_ITEM_SLOT_GAP, tokens),
91            subtitle_slot_gap: spacing(STANDARD_ITEM_SUBTITLE_SLOT_GAP, tokens),
92            label_subtitle_gap: spacing(STANDARD_ITEM_LABEL_SUBTITLE_GAP, tokens),
93            padding_horizontal: spacing(STANDARD_ITEM_PADDING_HORIZONTAL, tokens),
94            padding_vertical: spacing(STANDARD_ITEM_PADDING_VERTICAL, tokens),
95            min_height_single_line: dp(
96                STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE,
97                TargetRole::Target,
98                tokens,
99            ),
100            min_height_two_line: dp(
101                STANDARD_ITEM_MIN_HEIGHT_TWO_LINE,
102                TargetRole::Target,
103                tokens,
104            ),
105            chevron_column_width: STANDARD_ITEM_CHEVRON_COLUMN_WIDTH,
106            tree_indent_step: spacing(STANDARD_ITEM_TREE_INDENT_STEP, tokens),
107            item_corner_radius: STANDARD_ITEM_ITEM_CORNER_RADIUS,
108            bg_horizontal_inset: spacing(STANDARD_ITEM_BG_HORIZONTAL_INSET, tokens),
109            focus_ring_width: STANDARD_ITEM_FOCUS_RING_WIDTH,
110            selection_edge_width: STANDARD_ITEM_SELECTION_EDGE_WIDTH,
111        }
112    }
113}
114
115impl Default for StandardItemRecipe {
116    fn default() -> Self {
117        Self::for_tokens(&InputTokens::default())
118    }
119}
120
121/// Default `StandardItemStyle` shipped with Teksilo.
122#[derive(Debug, Default, Clone, Copy)]
123pub struct RecipeStandardItemStyle {
124    pub recipe: StandardItemRecipe,
125}
126
127impl RecipeStandardItemStyle {
128    pub fn new(recipe: StandardItemRecipe) -> Self {
129        Self { recipe }
130    }
131
132    /// This style with every dimension resolved against a density's
133    /// [`InputTokens`], as `RecipeStandardItemStyle::for_tokens(&ctx.theme().input)` at
134    /// the widget's own build site.
135    ///
136    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
137    /// tree gets exactly the values this module documents.
138    pub fn for_tokens(tokens: &InputTokens) -> Self {
139        Self {
140            recipe: StandardItemRecipe::for_tokens(tokens),
141        }
142    }
143}
144
145impl StandardItemStyle for RecipeStandardItemStyle {
146    fn make_body(&self, cfg: &StandardItemStyleConfig, ctx: &mut BuildContext) -> WidgetId {
147        // Background — `Selected` (when selected, regardless of hover)
148        // > `Pressed` > `AccentSubtle` (hover) > Transparent. Disabled
149        // is always Transparent (it shouldn't appear "pickable").
150        let bg_role = bg_signal(
151            &cfg.is_selected,
152            &cfg.is_pressed,
153            &cfg.is_hovered,
154            &cfg.is_disabled,
155            &self
156                .selection_emphasis()
157                .emphasised(&cfg.is_focused, &cfg.is_window_active),
158        );
159
160        // Keyboard-focus ring: drawn on the *current* item (selected, in a
161        // single-selection view) while that view holds keyboard focus AND the
162        // last input was keyboard (`:focus-visible`) — so it appears during Tab
163        // / arrow navigation but NOT on a mouse click, and clears on any pointer
164        // input. Width collapses to 0 otherwise. `BorderRole::Focused` is the
165        // theme's focus-ring color.
166        // Border width: the strong keyboard-focus ring on the current item
167        // during keyboard navigation, OR a thinner always-on selection boundary
168        // for any selected item (WCAG 1.4.1 / 1.4.11: a non-color, >= 3:1 cue so
169        // selection is perceivable beyond the pale wash), OR none.
170        let focus_ring_width = self.recipe.focus_ring_width;
171        let selection_edge_width = self.recipe.selection_edge_width;
172        let ring_width = cfg
173            .is_selected
174            .zip3(&cfg.is_focused, &cfg.is_focus_visible)
175            .map(move |(sel, foc, vis)| {
176                if *sel && *foc && *vis {
177                    focus_ring_width
178                } else if *sel {
179                    selection_edge_width
180                } else {
181                    0.0
182                }
183            });
184
185        // Selection rect, inset horizontally so the rounded corners
186        // visually float inside the row's full-width hit area.
187        let bg_rect = ctx.add(
188            RectWidget::new()
189                .background(bg_role)
190                .corner_radius(CornerRadius::uniform(self.recipe.item_corner_radius))
191                .border_color(BorderRole::Focused)
192                .border_width(ring_width),
193        );
194        let bg_padded = ctx.add(
195            Padding::new(
196                0.0,
197                self.recipe.bg_horizontal_inset,
198                0.0,
199                self.recipe.bg_horizontal_inset,
200            )
201            .child(bg_rect),
202        );
203
204        // Content padding so slot widgets don't touch the bg edges.
205        // `Expand::horizontal` makes the row claim the full row width
206        // even when its slot widgets have small intrinsic sizes —
207        // without this, ZStack would center the natural-width content
208        // and leave (e.g.) tree chevrons shifted off the leading edge.
209        let content_expanded = ctx.add(Expand::horizontal().child(cfg.content));
210        let content_padded = ctx.add(
211            Padding::symmetric(self.recipe.padding_vertical, self.recipe.padding_horizontal)
212                .child(content_expanded),
213        );
214
215        ctx.add(ZStack::new().child(bg_padded).child(content_padded))
216    }
217
218    /// This recipe's own floor. The recipe was resolved against a density
219    /// when the style was built, so `tokens` is not consulted again: a preset
220    /// that wrote its own `min_height_single_line` gets exactly that number.
221    fn min_height_single_line(&self, _tokens: &InputTokens) -> f32 {
222        self.recipe.min_height_single_line
223    }
224
225    /// This recipe's own floor for a two-line row, on the same terms as
226    /// [`min_height_single_line`](Self::min_height_single_line).
227    fn min_height_two_line(&self, _tokens: &InputTokens) -> f32 {
228        self.recipe.min_height_two_line
229    }
230}
231
232/// The `StandardItemStyle` a row built here renders with when it carries no
233/// per-call override: the theme's slot, else the shipped recipe resolved
234/// against the theme's density.
235pub(crate) fn resolve_standard_item_style(ctx: &BuildContext) -> SharedStandardItemStyle {
236    ctx.theme()
237        .style_slots
238        .standard_item
239        .clone()
240        .unwrap_or_else(|| {
241            std::rc::Rc::new(RecipeStandardItemStyle::for_tokens(&ctx.theme().input))
242                as SharedStandardItemStyle
243        })
244}
245
246fn bg_signal(
247    is_selected: &Signal<bool>,
248    is_pressed: &Signal<bool>,
249    is_hovered: &Signal<bool>,
250    is_disabled: &Signal<bool>,
251    emphasised: &Signal<bool>,
252) -> Signal<SurfaceRole> {
253    // `emphasised` is the style's `SelectionEmphasis` condition; by default
254    // the view holds keyboard focus AND the host window is active, so a
255    // selected row in an inactive window desaturates exactly as it does when
256    // focus moves elsewhere in the same window.
257    let combined = is_selected.zip3(is_pressed, is_hovered);
258    combined.zip3(is_disabled, emphasised).map(
259        |((selected, pressed, hovered), disabled, focused)| {
260            if *disabled {
261                SurfaceRole::Transparent
262            } else if *selected {
263                // Vivid selection while focused AND window-active; muted
264                // "inactive" selection otherwise.
265                if *focused {
266                    SurfaceRole::Selected
267                } else {
268                    SurfaceRole::SelectedInactive
269                }
270            } else if *pressed {
271                SurfaceRole::Pressed
272            } else if *hovered {
273                SurfaceRole::AccentSubtle
274            } else {
275                SurfaceRole::Transparent
276            }
277        },
278    )
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use teksilo_core::styles::SelectionEmphasis;
285    use teksilo_tokens::SurfaceRole;
286
287    #[test]
288    fn selection_role_requires_focus_and_window_active() {
289        let selected = Signal::new(true);
290        let pressed = Signal::new(false);
291        let hovered = Signal::new(false);
292        let disabled = Signal::new(false);
293        let focused = Signal::new(true);
294        let window_active = Signal::new(true);
295
296        let role = bg_signal(
297            &selected,
298            &pressed,
299            &hovered,
300            &disabled,
301            &SelectionEmphasis::default().emphasised(&focused, &window_active),
302        );
303
304        // Selected, view-focused, window-active → vivid.
305        assert_eq!(role.get(), SurfaceRole::Selected);
306
307        // Window goes inactive (view focus retained) → muted.
308        window_active.set(false);
309        assert_eq!(role.get(), SurfaceRole::SelectedInactive);
310
311        // Window active again but view focus lost → still muted.
312        window_active.set(true);
313        focused.set(false);
314        assert_eq!(role.get(), SurfaceRole::SelectedInactive);
315
316        // Both satisfied again → vivid.
317        focused.set(true);
318        assert_eq!(role.get(), SurfaceRole::Selected);
319    }
320
321    /// The default style keeps the rule it had before the hook existed.
322    #[test]
323    fn the_recipe_keeps_the_default_selection_emphasis() {
324        assert_eq!(
325            RecipeStandardItemStyle::default().selection_emphasis(),
326            SelectionEmphasis::ViewFocusedAndWindowActive
327        );
328    }
329
330    /// Under `WindowActive` only the window mutes a selection: a view that
331    /// loses keyboard focus inside an active window stays vivid.
332    #[test]
333    fn window_active_emphasis_ignores_view_focus() {
334        let selected = Signal::new(true);
335        let pressed = Signal::new(false);
336        let hovered = Signal::new(false);
337        let disabled = Signal::new(false);
338        let focused = Signal::new(false);
339        let window_active = Signal::new(true);
340
341        let role = bg_signal(
342            &selected,
343            &pressed,
344            &hovered,
345            &disabled,
346            &SelectionEmphasis::WindowActive.emphasised(&focused, &window_active),
347        );
348
349        assert_eq!(role.get(), SurfaceRole::Selected);
350        window_active.set(false);
351        assert_eq!(role.get(), SurfaceRole::SelectedInactive);
352        focused.set(true);
353        assert_eq!(role.get(), SurfaceRole::SelectedInactive);
354        window_active.set(true);
355        assert_eq!(role.get(), SurfaceRole::Selected);
356    }
357}