1use 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
27pub 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;
38pub 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;
47pub const STANDARD_ITEM_FOCUS_RING_WIDTH: f32 = 1.5;
49pub const STANDARD_ITEM_SELECTION_EDGE_WIDTH: f32 = 1.0;
56
57#[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 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#[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 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 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 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 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 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 fn min_height_single_line(&self, _tokens: &InputTokens) -> f32 {
222 self.recipe.min_height_single_line
223 }
224
225 fn min_height_two_line(&self, _tokens: &InputTokens) -> f32 {
228 self.recipe.min_height_two_line
229 }
230}
231
232pub(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 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 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 assert_eq!(role.get(), SurfaceRole::Selected);
306
307 window_active.set(false);
309 assert_eq!(role.get(), SurfaceRole::SelectedInactive);
310
311 window_active.set(true);
313 focused.set(false);
314 assert_eq!(role.get(), SurfaceRole::SelectedInactive);
315
316 focused.set(true);
318 assert_eq!(role.get(), SurfaceRole::Selected);
319 }
320
321 #[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 #[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}