Skip to main content

teksilo_widgets/styles/
recipe_menu_item_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `MenuItemStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeMenuItemStyle` ships the IntUI menu-row chrome:
7//! transparent at rest, accent-subtle tint on hover (or highlight via
8//! keyboard navigation), pressed surface tint while clicked. The row
9//! is composed as `[leading?] [icon-label gap] [label] [Spacer]
10//! [trailing?]`, padded vertically to `item_height` and horizontally
11//! to `item_padding_horizontal`. The trailing slot's right edge IS
12//! the row's right edge — the slot widget should reserve its own
13//! right-padding column (typical: shortcut + chevron column inside
14//! a HStack).
15//!
16//! Apps that want a different look (Windows-11 row, macOS pull-down,
17//! brutalist square row) write their own `impl MenuItemStyle` block.
18
19use teksilo_core::build_context::BuildContext;
20use teksilo_core::signal::Signal;
21use teksilo_core::styles::{MenuItemStyle, MenuItemStyleConfig};
22use teksilo_core::widget_id::WidgetId;
23use teksilo_tokens::{CornerRadius, SurfaceRole};
24
25use crate::primitives::{FixedSize, HStack, MinSize, Padding, RectWidget, Spacer, ZStack};
26
27// IntUI design tokens for MenuItem / MenuList rows. The recipe owns
28// its own dimensions. The MenuList / MenuBar / ComboBox panel widgets
29// import these constants when they need menu-related row dimensions
30// (item height, separator height). The menu *panel* surface (corner
31// radius, border, shadow density) is owned by `PopoverStyle` (the
32// `Menu` variant).
33pub const MENU_ITEM_HEIGHT: f32 = 24.0;
34/// Right-side padding column (also used as chevron column width).
35pub const MENU_ITEM_PADDING_HORIZONTAL: f32 = 12.0;
36/// Leading-side padding before the icon/check column.
37pub const MENU_ITEM_PADDING_LEADING: f32 = 6.0;
38pub const MENU_ICON_COLUMN_WIDTH: f32 = 16.0;
39pub const MENU_ICON_LABEL_GAP: f32 = 6.0;
40pub const MENU_SHORTCUT_LEFT_GAP: f32 = 24.0;
41pub const MENU_SEPARATOR_HEIGHT: f32 = 9.0;
42/// Corner radius of the per-row hover / pressed highlight rect.
43pub const MENU_ITEM_CORNER_RADIUS: f32 = 8.0;
44
45/// Recipe dimensions for [`RecipeMenuItemStyle`].
46///
47/// All fields default to the corresponding module-level `pub const`.
48/// Override individual fields to tune the menu row without writing a full
49/// custom `MenuItemStyle` impl.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct MenuItemRecipe {
52    pub item_height: f32,
53    pub padding_horizontal: f32,
54    pub padding_leading: f32,
55    pub icon_column_width: f32,
56    pub icon_label_gap: f32,
57    pub shortcut_left_gap: f32,
58    pub separator_height: f32,
59    pub item_corner_radius: f32,
60}
61
62impl Default for MenuItemRecipe {
63    fn default() -> Self {
64        Self {
65            item_height: MENU_ITEM_HEIGHT,
66            padding_horizontal: MENU_ITEM_PADDING_HORIZONTAL,
67            padding_leading: MENU_ITEM_PADDING_LEADING,
68            icon_column_width: MENU_ICON_COLUMN_WIDTH,
69            icon_label_gap: MENU_ICON_LABEL_GAP,
70            shortcut_left_gap: MENU_SHORTCUT_LEFT_GAP,
71            separator_height: MENU_SEPARATOR_HEIGHT,
72            item_corner_radius: MENU_ITEM_CORNER_RADIUS,
73        }
74    }
75}
76
77/// Default `MenuItemStyle` shipped with Teksilo. Chrome roles come from
78/// the active theme.
79#[derive(Debug, Default, Clone, Copy)]
80pub struct RecipeMenuItemStyle {
81    pub recipe: MenuItemRecipe,
82}
83
84impl RecipeMenuItemStyle {
85    pub fn new(recipe: MenuItemRecipe) -> Self {
86        Self { recipe }
87    }
88}
89
90impl MenuItemStyle for RecipeMenuItemStyle {
91    fn make_body(&self, cfg: &MenuItemStyleConfig, ctx: &mut BuildContext) -> WidgetId {
92        let recipe = self.recipe;
93
94        // Row composition: leading | gap | label | Spacer | trailing.
95        // HStack spacing is 0 — the only inter-child gap is between
96        // leading and label (`icon_label_gap`); everything else is
97        // tight (Spacer handles stretch).
98        let mut row = HStack::new().spacing(0.0);
99
100        if let Some(leading) = cfg.leading {
101            row = row.add_child(leading);
102            // Explicit icon-to-label gap. Fixed-width Spacer rather than
103            // HStack::spacing so we don't inject gaps around every other
104            // child (which would push the trailing slot inward).
105            let gap_spacer = ctx.add(Spacer::new());
106            let gap = ctx.add(
107                FixedSize::new()
108                    .width(recipe.icon_label_gap)
109                    .height(1.0_f32)
110                    .child_id(gap_spacer),
111            );
112            row = row.add_child(gap);
113        }
114
115        row = row.add_child(cfg.label);
116        // MinSize ensures the shortcut never abuts the label when the menu
117        // is narrower than label + shortcut combined.
118        row = row.child(MinSize::width(recipe.shortcut_left_gap).child(Spacer::new()));
119
120        if let Some(trailing) = cfg.trailing {
121            row = row.add_child(trailing);
122        }
123
124        let row_id = ctx.add(row);
125
126        // Padding: vertical derived so the row has the full
127        // `item_height` after the body line height. Horizontal: only
128        // left padding here — the trailing slot is responsible for
129        // its own right-padding column (matches the pre-refactor
130        // MenuItem semantic so submenu and regular items line up).
131        let body = &ctx.theme().typography.body;
132        let body_line = body.size * body.line_height;
133        let pad_v = ((recipe.item_height - body_line) * 0.5).max(0.0);
134        let padding =
135            ctx.add(Padding::new(pad_v, 0.0, pad_v, recipe.padding_leading).child_id(row_id));
136
137        // Background — Hover / Highlighted both use AccentSubtle (the
138        // same row tint), Pressed uses Pressed, Disabled stays
139        // Transparent.
140        let bg_role = bg_signal(
141            &cfg.is_pressed,
142            &cfg.is_hovered,
143            &cfg.is_highlighted,
144            &cfg.is_disabled,
145        );
146        let bg = ctx.add(
147            RectWidget::new()
148                .background(bg_role)
149                .corner_radius(CornerRadius::uniform(recipe.item_corner_radius)),
150        );
151
152        ctx.add(ZStack::new().add_child(bg).add_child(padding))
153    }
154}
155
156fn bg_signal(
157    is_pressed: &Signal<bool>,
158    is_hovered: &Signal<bool>,
159    is_highlighted: &Signal<bool>,
160    is_disabled: &Signal<bool>,
161) -> Signal<SurfaceRole> {
162    let combined = is_pressed.zip3(is_hovered, is_highlighted);
163    combined
164        .zip(is_disabled)
165        .map(|((pressed, hovered, highlighted), disabled)| {
166            if *disabled {
167                SurfaceRole::Transparent
168            } else if *pressed {
169                SurfaceRole::Pressed
170            } else if *hovered || *highlighted {
171                SurfaceRole::AccentSubtle
172            } else {
173                SurfaceRole::Transparent
174            }
175        })
176}