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::{StandardItemStyle, StandardItemStyleConfig};
21use teksilo_core::widget_id::WidgetId;
22use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
23
24use crate::primitives::{Expand, Padding, RectWidget, ZStack};
25
26// IntUI design tokens for StandardListItem / StandardTreeItem.
27// The recipe owns its own dimensions.
28pub const STANDARD_ITEM_ICON_SIZE: f32 = 16.0;
29pub const STANDARD_ITEM_SUBTITLE_ICON_SIZE: f32 = 12.0;
30pub const STANDARD_ITEM_SLOT_GAP: f32 = 8.0;
31pub const STANDARD_ITEM_SUBTITLE_SLOT_GAP: f32 = 6.0;
32pub const STANDARD_ITEM_LABEL_SUBTITLE_GAP: f32 = 2.0;
33pub const STANDARD_ITEM_PADDING_HORIZONTAL: f32 = 8.0;
34pub const STANDARD_ITEM_PADDING_VERTICAL: f32 = 4.0;
35pub const STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE: f32 = 28.0;
36pub const STANDARD_ITEM_MIN_HEIGHT_TWO_LINE: f32 = 44.0;
37/// Compression floor for the label column when the caller opted its label or
38/// subtitle into truncation (`label_overflow` / `subtitle_overflow`). Wide
39/// enough that a truncated label keeps a few glyphs before the ellipsis rather
40/// than collapsing to nothing on a very narrow row.
41pub const STANDARD_ITEM_LABEL_COLUMN_MIN_WIDTH: f32 = 48.0;
42pub const STANDARD_ITEM_CHEVRON_COLUMN_WIDTH: f32 = 16.0;
43pub const STANDARD_ITEM_TREE_INDENT_STEP: f32 = 16.0;
44pub const STANDARD_ITEM_ITEM_CORNER_RADIUS: f32 = 8.0;
45pub const STANDARD_ITEM_BG_HORIZONTAL_INSET: f32 = 4.0;
46/// Keyboard-focus ring thickness for the current item while its view is focused.
47pub const STANDARD_ITEM_FOCUS_RING_WIDTH: f32 = 1.5;
48/// Border thickness drawn around a *selected* item (in the `BorderRole::Focused`
49/// accent colour) even without keyboard focus. A non-color-alone, >= 3:1
50/// boundary cue so selection is perceivable beyond the low-contrast
51/// `surface_selected` wash (WCAG 1.4.1 Use of Color / 1.4.11 Non-text
52/// Contrast). Thinner than the keyboard-focus ring so the two stay
53/// distinguishable.
54pub const STANDARD_ITEM_SELECTION_EDGE_WIDTH: f32 = 1.0;
55
56/// Configurable dimensions for [`RecipeStandardItemStyle`].
57///
58/// The [`Default`] implementation reads the module-level `pub const` tokens,
59/// so a `RecipeStandardItemStyle::default()` is identical to the old unit struct.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct StandardItemRecipe {
62    pub icon_size: f32,
63    pub subtitle_icon_size: f32,
64    pub slot_gap: f32,
65    pub subtitle_slot_gap: f32,
66    pub label_subtitle_gap: f32,
67    pub padding_horizontal: f32,
68    pub padding_vertical: f32,
69    pub min_height_single_line: f32,
70    pub min_height_two_line: f32,
71    pub chevron_column_width: f32,
72    pub tree_indent_step: f32,
73    pub item_corner_radius: f32,
74    pub bg_horizontal_inset: f32,
75    pub focus_ring_width: f32,
76    pub selection_edge_width: f32,
77}
78
79impl Default for StandardItemRecipe {
80    fn default() -> Self {
81        Self {
82            icon_size: STANDARD_ITEM_ICON_SIZE,
83            subtitle_icon_size: STANDARD_ITEM_SUBTITLE_ICON_SIZE,
84            slot_gap: STANDARD_ITEM_SLOT_GAP,
85            subtitle_slot_gap: STANDARD_ITEM_SUBTITLE_SLOT_GAP,
86            label_subtitle_gap: STANDARD_ITEM_LABEL_SUBTITLE_GAP,
87            padding_horizontal: STANDARD_ITEM_PADDING_HORIZONTAL,
88            padding_vertical: STANDARD_ITEM_PADDING_VERTICAL,
89            min_height_single_line: STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE,
90            min_height_two_line: STANDARD_ITEM_MIN_HEIGHT_TWO_LINE,
91            chevron_column_width: STANDARD_ITEM_CHEVRON_COLUMN_WIDTH,
92            tree_indent_step: STANDARD_ITEM_TREE_INDENT_STEP,
93            item_corner_radius: STANDARD_ITEM_ITEM_CORNER_RADIUS,
94            bg_horizontal_inset: STANDARD_ITEM_BG_HORIZONTAL_INSET,
95            focus_ring_width: STANDARD_ITEM_FOCUS_RING_WIDTH,
96            selection_edge_width: STANDARD_ITEM_SELECTION_EDGE_WIDTH,
97        }
98    }
99}
100
101/// Default `StandardItemStyle` shipped with Teksilo.
102#[derive(Debug, Default, Clone, Copy)]
103pub struct RecipeStandardItemStyle {
104    pub recipe: StandardItemRecipe,
105}
106
107impl RecipeStandardItemStyle {
108    pub fn new(recipe: StandardItemRecipe) -> Self {
109        Self { recipe }
110    }
111}
112
113impl StandardItemStyle for RecipeStandardItemStyle {
114    fn make_body(&self, cfg: &StandardItemStyleConfig, ctx: &mut BuildContext) -> WidgetId {
115        // Background — `Selected` (when selected, regardless of hover)
116        // > `Pressed` > `AccentSubtle` (hover) > Transparent. Disabled
117        // is always Transparent (it shouldn't appear "pickable").
118        let bg_role = bg_signal(
119            &cfg.is_selected,
120            &cfg.is_pressed,
121            &cfg.is_hovered,
122            &cfg.is_disabled,
123            &cfg.is_focused,
124            &cfg.is_window_active,
125        );
126
127        // Keyboard-focus ring: drawn on the *current* item (selected, in a
128        // single-selection view) while that view holds keyboard focus AND the
129        // last input was keyboard (`:focus-visible`) — so it appears during Tab
130        // / arrow navigation but NOT on a mouse click, and clears on any pointer
131        // input. Width collapses to 0 otherwise. `BorderRole::Focused` is the
132        // theme's focus-ring color.
133        // Border width: the strong keyboard-focus ring on the current item
134        // during keyboard navigation, OR a thinner always-on selection boundary
135        // for any selected item (WCAG 1.4.1 / 1.4.11: a non-color, >= 3:1 cue so
136        // selection is perceivable beyond the pale wash), OR none.
137        let focus_ring_width = self.recipe.focus_ring_width;
138        let selection_edge_width = self.recipe.selection_edge_width;
139        let ring_width = cfg
140            .is_selected
141            .zip3(&cfg.is_focused, &cfg.is_focus_visible)
142            .map(move |(sel, foc, vis)| {
143                if *sel && *foc && *vis {
144                    focus_ring_width
145                } else if *sel {
146                    selection_edge_width
147                } else {
148                    0.0
149                }
150            });
151
152        // Selection rect, inset horizontally so the rounded corners
153        // visually float inside the row's full-width hit area.
154        let bg_rect = ctx.add(
155            RectWidget::new()
156                .background(bg_role)
157                .corner_radius(CornerRadius::uniform(self.recipe.item_corner_radius))
158                .border_color(BorderRole::Focused)
159                .border_width(ring_width),
160        );
161        let bg_padded = ctx.add(
162            Padding::new(
163                0.0,
164                self.recipe.bg_horizontal_inset,
165                0.0,
166                self.recipe.bg_horizontal_inset,
167            )
168            .child_id(bg_rect),
169        );
170
171        // Content padding so slot widgets don't touch the bg edges.
172        // `Expand::horizontal` makes the row claim the full row width
173        // even when its slot widgets have small intrinsic sizes —
174        // without this, ZStack would center the natural-width content
175        // and leave (e.g.) tree chevrons shifted off the leading edge.
176        let content_expanded = ctx.add(Expand::horizontal().child_id(cfg.content));
177        let content_padded = ctx.add(
178            Padding::symmetric(self.recipe.padding_vertical, self.recipe.padding_horizontal)
179                .child_id(content_expanded),
180        );
181
182        ctx.add(ZStack::new().add_child(bg_padded).add_child(content_padded))
183    }
184}
185
186fn bg_signal(
187    is_selected: &Signal<bool>,
188    is_pressed: &Signal<bool>,
189    is_hovered: &Signal<bool>,
190    is_disabled: &Signal<bool>,
191    is_focused: &Signal<bool>,
192    is_window_active: &Signal<bool>,
193) -> Signal<SurfaceRole> {
194    // Effective focus = the view holds keyboard focus AND the host window is
195    // active. A selected row in an inactive window desaturates exactly as it
196    // does when focus moves elsewhere in the same window.
197    let effective_focus = is_focused.and(is_window_active);
198    let combined = is_selected.zip3(is_pressed, is_hovered);
199    combined.zip3(is_disabled, &effective_focus).map(
200        |((selected, pressed, hovered), disabled, focused)| {
201            if *disabled {
202                SurfaceRole::Transparent
203            } else if *selected {
204                // Vivid selection while focused AND window-active; muted
205                // "inactive" selection otherwise.
206                if *focused {
207                    SurfaceRole::Selected
208                } else {
209                    SurfaceRole::SelectedInactive
210                }
211            } else if *pressed {
212                SurfaceRole::Pressed
213            } else if *hovered {
214                SurfaceRole::AccentSubtle
215            } else {
216                SurfaceRole::Transparent
217            }
218        },
219    )
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use teksilo_tokens::SurfaceRole;
226
227    #[test]
228    fn selection_role_requires_focus_and_window_active() {
229        let selected = Signal::new(true);
230        let pressed = Signal::new(false);
231        let hovered = Signal::new(false);
232        let disabled = Signal::new(false);
233        let focused = Signal::new(true);
234        let window_active = Signal::new(true);
235
236        let role = bg_signal(
237            &selected,
238            &pressed,
239            &hovered,
240            &disabled,
241            &focused,
242            &window_active,
243        );
244
245        // Selected, view-focused, window-active → vivid.
246        assert_eq!(role.get(), SurfaceRole::Selected);
247
248        // Window goes inactive (view focus retained) → muted.
249        window_active.set(false);
250        assert_eq!(role.get(), SurfaceRole::SelectedInactive);
251
252        // Window active again but view focus lost → still muted.
253        window_active.set(true);
254        focused.set(false);
255        assert_eq!(role.get(), SurfaceRole::SelectedInactive);
256
257        // Both satisfied again → vivid.
258        focused.set(true);
259        assert_eq!(role.get(), SurfaceRole::Selected);
260    }
261}