teksilo_widgets/styles/
recipe_standard_item_style.rs1use 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
26pub 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;
37pub 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;
46pub const STANDARD_ITEM_FOCUS_RING_WIDTH: f32 = 1.5;
48pub const STANDARD_ITEM_SELECTION_EDGE_WIDTH: f32 = 1.0;
55
56#[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#[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 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 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 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 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 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 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 assert_eq!(role.get(), SurfaceRole::Selected);
247
248 window_active.set(false);
250 assert_eq!(role.get(), SurfaceRole::SelectedInactive);
251
252 window_active.set(true);
254 focused.set(false);
255 assert_eq!(role.get(), SurfaceRole::SelectedInactive);
256
257 focused.set(true);
259 assert_eq!(role.get(), SurfaceRole::Selected);
260 }
261}