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::{MenuItemMetrics, MenuItemStyle, MenuItemStyleConfig};
22use teksilo_core::widget_id::WidgetId;
23use teksilo_tokens::{CornerRadius, InputTokens, SurfaceRole, TargetRole};
24
25use crate::primitives::{Expand, FixedSize, HStack, MinSize, Padding, RectWidget, Spacer, ZStack};
26use teksilo_core::styles::density::{dp, spacing};
27
28// IntUI design tokens for MenuItem / MenuList rows. The recipe owns
29// its own dimensions. The MenuList / MenuBar / ComboBox panel widgets
30// import these constants when they need menu-related row dimensions
31// (item height, separator height). The menu *panel* surface (corner
32// radius, border, shadow density) is owned by `PopoverStyle` (the
33// `Menu` variant).
34pub const MENU_ITEM_HEIGHT: f32 = 24.0;
35
36/// [`MENU_ITEM_HEIGHT`] raised to the density's `target_size`
37/// (24 / 32 / 44 dp). The identity at Compact.
38///
39/// The same projection `MenuItemRecipe::for_tokens` applies to `item_height`,
40/// exposed on its own for the two menu-shaped surfaces that measure a row
41/// without going through the recipe: the ComboBox dropdown's rows and the
42/// viewport that shows `max_visible_items` of them.
43pub fn menu_item_height(tokens: &InputTokens) -> f32 {
44    dp(MENU_ITEM_HEIGHT, TargetRole::Target, tokens)
45}
46/// Right-side padding column (also used as chevron column width).
47pub const MENU_ITEM_PADDING_HORIZONTAL: f32 = 12.0;
48/// Leading-side padding before the icon/check column.
49pub const MENU_ITEM_PADDING_LEADING: f32 = 6.0;
50pub const MENU_ICON_COLUMN_WIDTH: f32 = 16.0;
51pub const MENU_ICON_LABEL_GAP: f32 = 6.0;
52pub const MENU_SHORTCUT_LEFT_GAP: f32 = 24.0;
53pub const MENU_SEPARATOR_HEIGHT: f32 = 9.0;
54/// Corner radius of the per-row hover / pressed highlight rect.
55pub const MENU_ITEM_CORNER_RADIUS: f32 = 8.0;
56
57/// Recipe dimensions for [`RecipeMenuItemStyle`].
58///
59/// All fields default to the corresponding module-level `pub const`.
60/// Override individual fields to tune the menu row without writing a full
61/// custom `MenuItemStyle` impl.
62///
63/// Four of them describe parts of the row this style does not build itself
64/// — the leading icon column and the trailing chevron column are slots
65/// `MenuItem` fills before any style runs, and the separator belongs to
66/// `MenuSeparator`. They reach those call sites through
67/// [`MenuItemStyle::metrics`], so a style that delegates its row here
68/// should delegate `metrics` here too.
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct MenuItemRecipe {
71    pub item_height: f32,
72    pub padding_horizontal: f32,
73    pub padding_leading: f32,
74    pub icon_column_width: f32,
75    pub icon_label_gap: f32,
76    pub shortcut_left_gap: f32,
77    pub separator_height: f32,
78    pub item_corner_radius: f32,
79}
80
81impl MenuItemRecipe {
82    /// This recipe's dimensions resolved against a density's [`InputTokens`].
83    ///
84    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
85    /// ladder — so the shipped values below are the Compact column by
86    /// construction and cannot drift from it.
87    pub fn for_tokens(tokens: &InputTokens) -> Self {
88        Self {
89            item_height: dp(MENU_ITEM_HEIGHT, TargetRole::Target, tokens),
90            padding_horizontal: spacing(MENU_ITEM_PADDING_HORIZONTAL, tokens),
91            padding_leading: spacing(MENU_ITEM_PADDING_LEADING, tokens),
92            icon_column_width: MENU_ICON_COLUMN_WIDTH,
93            icon_label_gap: spacing(MENU_ICON_LABEL_GAP, tokens),
94            shortcut_left_gap: spacing(MENU_SHORTCUT_LEFT_GAP, tokens),
95            separator_height: spacing(MENU_SEPARATOR_HEIGHT, tokens),
96            item_corner_radius: MENU_ITEM_CORNER_RADIUS,
97        }
98    }
99}
100
101impl Default for MenuItemRecipe {
102    fn default() -> Self {
103        Self::for_tokens(&InputTokens::default())
104    }
105}
106
107/// Default `MenuItemStyle` shipped with Teksilo. Chrome roles come from
108/// the active theme.
109#[derive(Debug, Default, Clone, Copy)]
110pub struct RecipeMenuItemStyle {
111    pub recipe: MenuItemRecipe,
112}
113
114impl RecipeMenuItemStyle {
115    pub fn new(recipe: MenuItemRecipe) -> Self {
116        Self { recipe }
117    }
118
119    /// This style with every dimension resolved against a density's
120    /// [`InputTokens`], as `RecipeMenuItemStyle::for_tokens(&ctx.theme().input)` at
121    /// the widget's own build site.
122    ///
123    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
124    /// tree gets exactly the values this module documents.
125    pub fn for_tokens(tokens: &InputTokens) -> Self {
126        Self {
127            recipe: MenuItemRecipe::for_tokens(tokens),
128        }
129    }
130}
131
132impl MenuItemStyle for RecipeMenuItemStyle {
133    fn make_body(&self, cfg: &MenuItemStyleConfig, ctx: &mut BuildContext) -> WidgetId {
134        let recipe = self.recipe;
135
136        // Row composition: leading | gap | label | Spacer | trailing.
137        // HStack spacing is 0 — the only inter-child gap is between
138        // leading and label (`icon_label_gap`); everything else is
139        // tight (Spacer handles stretch).
140        let mut row = HStack::new().spacing(0.0);
141
142        if let Some(leading) = cfg.leading {
143            row = row.child(leading);
144            // Explicit icon-to-label gap. Fixed-width Spacer rather than
145            // HStack::spacing so we don't inject gaps around every other
146            // child (which would push the trailing slot inward).
147            let gap_spacer = ctx.add(Spacer::new());
148            let gap = ctx.add(
149                FixedSize::new()
150                    .width(recipe.icon_label_gap)
151                    .height(1.0_f32)
152                    .child(gap_spacer),
153            );
154            row = row.child(gap);
155        }
156
157        row = row.child(cfg.label);
158        // MinSize ensures the shortcut never abuts the label when the menu
159        // is narrower than label + shortcut combined.
160        row = row.child(MinSize::width(recipe.shortcut_left_gap).child(Spacer::new()));
161
162        if let Some(trailing) = cfg.trailing {
163            row = row.child(trailing);
164        }
165
166        let row_id = ctx.add(row);
167
168        // Padding: vertical derived so the row has the full
169        // `item_height` after the body line height. Horizontal: only
170        // left padding here — the trailing slot is responsible for
171        // its own right-padding column (matches the pre-refactor
172        // MenuItem semantic so submenu and regular items line up).
173        let body = &ctx.theme().typography.body;
174        let body_line = body.size * body.line_height;
175        let pad_v = ((recipe.item_height - body_line) * 0.5).max(0.0);
176        let padding =
177            ctx.add(Padding::new(pad_v, 0.0, pad_v, recipe.padding_leading).child(row_id));
178
179        // Background — Hover / Highlighted both use AccentSubtle (the
180        // same row tint), Pressed uses Pressed, Disabled stays
181        // Transparent.
182        let bg_role = bg_signal(
183            &cfg.is_pressed,
184            &cfg.is_hovered,
185            &cfg.is_highlighted,
186            &cfg.is_disabled,
187        );
188        let bg = ctx.add(
189            RectWidget::new()
190                .background(bg_role)
191                .corner_radius(CornerRadius::uniform(recipe.item_corner_radius)),
192        );
193
194        let stack = ctx.add(ZStack::new().child(bg).child(padding));
195
196        // Claim the full width the row is given.
197        //
198        // The row's own arithmetic already assumes this — the trailing slot's
199        // right edge IS the row's right edge, and every label in a menu lines
200        // up on one leading inset — but a bare `ZStack` reports its children's
201        // *intrinsic* max width, so it only filled by accident: the internal
202        // `Spacer` made the padded content flexible, and the enclosing
203        // `MenuList` stretched it.
204        //
205        // That accident stops the moment a preset *layers* this row (Fluent
206        // and macOS both stack it over their own highlight rect to swap one
207        // colour). The outer `ZStack` measures this one at its intrinsic
208        // width, finds it narrower than the panel, and CENTRES it — so every
209        // label landed on a different inset, ragged by half the difference
210        // between its own text width and the widest row's. `respect_intrinsic`
211        // keeps the natural width as the floor, so the panel still sizes to
212        // its widest item; the fill is what the row was always meant to do.
213        ctx.add(Expand::horizontal().respect_intrinsic().child(stack))
214    }
215
216    fn metrics(&self) -> MenuItemMetrics {
217        MenuItemMetrics {
218            icon_column_width: self.recipe.icon_column_width,
219            trailing_column_width: self.recipe.padding_horizontal,
220            separator_height: self.recipe.separator_height,
221            item_height: self.recipe.item_height,
222        }
223    }
224}
225
226fn bg_signal(
227    is_pressed: &Signal<bool>,
228    is_hovered: &Signal<bool>,
229    is_highlighted: &Signal<bool>,
230    is_disabled: &Signal<bool>,
231) -> Signal<SurfaceRole> {
232    let combined = is_pressed.zip3(is_hovered, is_highlighted);
233    combined
234        .zip(is_disabled)
235        .map(|((pressed, hovered, highlighted), disabled)| {
236            if *disabled {
237                SurfaceRole::Transparent
238            } else if *pressed {
239                SurfaceRole::Pressed
240            } else if *hovered || *highlighted {
241                SurfaceRole::AccentSubtle
242            } else {
243                SurfaceRole::Transparent
244            }
245        })
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use teksilo_canvas::{MockTextBackend, SizeProposal};
252    use teksilo_core::styles::MenuItemStyleConfig;
253    use teksilo_core::widget_tree::WidgetTree;
254    use teksilo_i18n::lit;
255
256    use crate::menu_item::MenuItem;
257    use crate::menu_list::{MenuList, MenuSeparator};
258    use crate::primitives::FixedSize;
259
260    /// A style shaped like the ones the Fluent and macOS presets ship: paint
261    /// our own row highlight, then stack the shared recipe row on top of it
262    /// to reuse its slot arithmetic.
263    #[derive(Debug, Default, Clone, Copy)]
264    struct LayeringStyle;
265
266    impl MenuItemStyle for LayeringStyle {
267        fn make_body(&self, cfg: &MenuItemStyleConfig, ctx: &mut BuildContext) -> WidgetId {
268            let backdrop = ctx.add(RectWidget::new().background(SurfaceRole::Hover));
269            let row = RecipeMenuItemStyle::for_tokens(&ctx.theme().input).make_body(cfg, ctx);
270            ctx.add(ZStack::new().child(backdrop).child(row))
271        }
272    }
273
274    /// Leading x of each painted text row, keyed by the row's y.
275    fn label_left_edges(tree: &mut WidgetTree) -> Vec<(i32, i32)> {
276        let mut rows: std::collections::BTreeMap<i32, f32> = std::collections::BTreeMap::new();
277        let frame = tree.render();
278        for g in &frame.glyphs {
279            let e = rows.entry(g.screen[1].round() as i32).or_insert(f32::MAX);
280            *e = e.min(g.screen[0]);
281        }
282        rows.into_iter()
283            .map(|(y, x)| (y, x.round() as i32))
284            .collect()
285    }
286
287    /// Two rows, labels of very different widths, in a menu far wider than
288    /// either — under a style that layers the shared row over its own
289    /// highlight.
290    fn two_row_menu(style: bool) -> Vec<(i32, i32)> {
291        let mut tree = WidgetTree::new()
292            .with_theme(teksilo_core::presets::intui::light())
293            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
294                MockTextBackend::new(),
295            )));
296        let mut short = MenuItem::new(lit!("Cut"));
297        let mut long = MenuItem::new(lit!("Save all as…"));
298        if style {
299            short = short.style(LayeringStyle);
300            long = long.style(LayeringStyle);
301        }
302        let menu = MenuList::new().item(short).item(long);
303        tree.add(FixedSize::new().width(400.0).child(menu));
304        tree.layout(SizeProposal::exact(600.0, 300.0));
305        label_left_edges(&mut tree)
306    }
307
308    /// Every label in a menu starts on one leading inset — the reserved
309    /// icon/check column exists precisely so an icon-less row lines up with
310    /// an icon'd one. WinUI, AppKit and Material all leading-align them;
311    /// none centres.
312    ///
313    /// The regression this pins is not the plain row (which was always
314    /// right) but the *layered* one. `ZStack` sizes to its children's
315    /// intrinsic max and centres a child narrower than its bounds, so a
316    /// preset that stacked this row over its own highlight rect got each
317    /// label pushed in by half its own slack — "Cut" further right than
318    /// "Save all as…", ragged by label width, in Fluent and macOS both.
319    #[test]
320    fn labels_share_one_leading_inset_under_a_layering_style() {
321        let rows = two_row_menu(true);
322        assert_eq!(rows.len(), 2, "two rows painted: {rows:?}");
323        assert_eq!(
324            rows[0].1, rows[1].1,
325            "labels of different widths must start at the same x: {rows:?}"
326        );
327    }
328
329    /// …and the unlayered row is unchanged by the fill, on the same inset.
330    #[test]
331    fn a_layering_style_lands_on_the_same_inset_as_the_plain_row() {
332        let plain = two_row_menu(false);
333        let layered = two_row_menu(true);
334        assert_eq!(plain, layered, "layering must not move the labels");
335    }
336
337    // --- `MenuItemStyle::metrics` ---
338
339    /// A recipe whose every dimension differs from the IntUI default, so a
340    /// call site that ignored it and used the module constant is caught.
341    fn odd_recipe() -> MenuItemRecipe {
342        MenuItemRecipe {
343            item_height: 40.0,
344            padding_leading: 7.0,
345            icon_column_width: 30.0,
346            icon_label_gap: 5.0,
347            padding_horizontal: 21.0,
348            separator_height: 17.0,
349            ..MenuItemRecipe::default()
350        }
351    }
352
353    fn odd_style() -> RecipeMenuItemStyle {
354        RecipeMenuItemStyle::new(odd_recipe())
355    }
356
357    /// Leading x of the single row's label, which is `padding_leading +
358    /// icon_column_width + icon_label_gap` from the row's left edge.
359    fn single_row_label_x(style: Option<RecipeMenuItemStyle>) -> i32 {
360        let mut tree = WidgetTree::new()
361            .with_theme(teksilo_core::presets::intui::light())
362            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
363                MockTextBackend::new(),
364            )));
365        let mut item = MenuItem::new(lit!("Cut"));
366        if let Some(s) = style {
367            item = item.style(s);
368        }
369        tree.add(item);
370        tree.layout(SizeProposal::exact(400.0, 300.0));
371        label_left_edges(&mut tree)[0].1
372    }
373
374    /// The leading column is built by `MenuItem`, before any style runs, so
375    /// its width has to be *read from* the style. It used to be the IntUI
376    /// module constant, which made `MenuItemRecipe::icon_column_width` a
377    /// knob that changed nothing — macOS asked for 14 dp and got 16.
378    #[test]
379    fn the_icon_column_comes_from_the_active_style() {
380        assert_eq!(single_row_label_x(None), 6 + 16 + 6);
381        assert_eq!(single_row_label_x(Some(odd_style())), 7 + 30 + 5);
382    }
383
384    /// The separator is a sibling widget the style never sees, so it reads
385    /// the theme slot — the only style it can share with the rows around it.
386    #[test]
387    fn the_separator_height_comes_from_the_active_style() {
388        for (slot, expected) in [(None, MENU_SEPARATOR_HEIGHT), (Some(odd_style()), 17.0)] {
389            let mut theme = teksilo_core::presets::intui::light();
390            theme.style_slots.menu_item =
391                slot.map(|s| std::rc::Rc::new(s) as teksilo_core::styles::SharedMenuItemStyle);
392            let mut tree = WidgetTree::new().with_theme(theme);
393            let id = tree.add(MenuSeparator);
394            // Height left open: an `exact` proposal stretches the root to the
395            // window, which would hide the separator's own answer.
396            tree.layout(SizeProposal {
397                width: Some(400.0),
398                height: None,
399            });
400            assert_eq!(tree.bounds(id).height, expected);
401        }
402    }
403
404    /// `metrics` and `make_body` must describe the same row: the trailing
405    /// column doubles as the row's right-hand padding, so they share one
406    /// recipe field.
407    #[test]
408    fn the_metrics_mirror_the_recipe() {
409        let m = odd_style().metrics();
410        assert_eq!(m.icon_column_width, 30.0);
411        assert_eq!(m.trailing_column_width, 21.0);
412        assert_eq!(m.separator_height, 17.0);
413        assert_eq!(m.item_height, 40.0);
414    }
415}