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}