use crate::event::Rect;
use crate::types::WidgetId;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MenuBar {
pub id: WidgetId,
pub items: Vec<MenuBarItem>,
#[serde(default)]
pub open_item: Option<usize>,
#[serde(default)]
pub focused_item: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MenuBarItem {
pub id: WidgetId,
pub label: String,
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub submenu: Option<Vec<crate::primitives::context_menu::ContextMenuItem>>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MenuBarItemMeasure {
pub width: f32,
}
impl MenuBarItemMeasure {
pub fn new(width: f32) -> Self {
Self { width }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisibleMenuBarItem {
pub item_idx: usize,
pub id: WidgetId,
pub bounds: Rect,
pub clickable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuBarHit {
Item(usize),
Bar,
Outside,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MenuBarLayout {
pub bounds: Rect,
pub visible_items: Vec<VisibleMenuBarItem>,
pub hit_regions: Vec<(Rect, MenuBarHit)>,
}
impl MenuBarLayout {
pub fn hit_test(&self, x: f32, y: f32) -> MenuBarHit {
let inside = x >= self.bounds.x
&& x < self.bounds.x + self.bounds.width
&& y >= self.bounds.y
&& y < self.bounds.y + self.bounds.height;
if !inside {
return MenuBarHit::Outside;
}
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
MenuBarHit::Bar
}
}
impl MenuBar {
pub fn layout<F>(&self, bounds: Rect, measure_item: F) -> MenuBarLayout
where
F: Fn(usize) -> MenuBarItemMeasure,
{
let mut visible_items: Vec<VisibleMenuBarItem> = Vec::new();
let mut hit_regions: Vec<(Rect, MenuBarHit)> = Vec::new();
let mut cursor_x = bounds.x;
for (i, item) in self.items.iter().enumerate() {
let w = measure_item(i).width;
if cursor_x + w > bounds.x + bounds.width {
break;
}
let item_bounds = Rect::new(cursor_x, bounds.y, w, bounds.height);
let clickable = !item.disabled;
visible_items.push(VisibleMenuBarItem {
item_idx: i,
id: item.id.clone(),
bounds: item_bounds,
clickable,
});
if clickable {
hit_regions.push((item_bounds, MenuBarHit::Item(i)));
}
cursor_x += w;
}
MenuBarLayout {
bounds,
visible_items,
hit_regions,
}
}
pub fn layout_with_leading<F>(
&self,
bounds: Rect,
leading_width: f32,
measure_item: F,
) -> MenuBarLayout
where
F: Fn(usize) -> MenuBarItemMeasure,
{
let leading_width = leading_width.max(0.0).min(bounds.width);
let items_bounds = Rect::new(
bounds.x + leading_width,
bounds.y,
(bounds.width - leading_width).max(0.0),
bounds.height,
);
let mut layout = self.layout(items_bounds, measure_item);
layout.bounds = bounds;
layout
}
pub fn find_alt_target(&self, ch: char) -> Option<usize> {
let target = ch.to_ascii_lowercase();
for (i, item) in self.items.iter().enumerate() {
if item.disabled {
continue;
}
let marker = item.label.find('&').map(|p| p + 1);
let trigger = match marker {
Some(idx) => item.label.chars().nth(idx),
None => item.label.chars().next(),
};
if let Some(c) = trigger {
if c.to_ascii_lowercase() == target {
return Some(i);
}
}
}
None
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{MenuBar, MenuBarLayout};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
const UNDERLINE_HEIGHT: f32 = 2.0;
pub(super) fn display_text(label: &str) -> String {
label.chars().filter(|&c| c != '&').collect()
}
pub(super) fn alt_char_index(label: &str) -> Option<usize> {
let marker_byte = label.find('&')?;
Some(label[..marker_byte].chars().count())
}
pub(crate) fn paint(
bar: &MenuBar,
layout: &MenuBarLayout,
surface: &mut dyn PaintSurface,
theme: &Theme,
) {
surface.surface_fill_rect(layout.bounds, theme.tab_bar_bg);
for vi in &layout.visible_items {
let item = &bar.items[vi.item_idx];
let is_active =
bar.open_item == Some(vi.item_idx) || bar.focused_item == Some(vi.item_idx);
let (fg, bg) = if is_active {
(theme.tab_active_fg, theme.tab_active_bg)
} else if item.disabled {
(theme.muted_fg, theme.tab_bar_bg)
} else {
(theme.tab_inactive_fg, theme.tab_bar_bg)
};
if is_active {
surface.surface_fill_rect(vi.bounds, bg);
}
let text = display_text(&item.label);
let (text_w, text_h) = surface.surface_measure_text(&text);
let text_x = vi.bounds.x + (vi.bounds.width - text_w) / 2.0;
let text_y = vi.bounds.y + (vi.bounds.height - text_h) / 2.0;
surface.surface_draw_text_run(
Rect::new(text_x, text_y, text_w.max(0.0), text_h.max(0.0)),
&text,
fg,
);
if let Some(idx) = alt_char_index(&item.label) {
if let Some(ch) = text.chars().nth(idx) {
let prefix: String = text.chars().take(idx).collect();
let (prefix_w, _) = surface.surface_measure_text(&prefix);
let (char_w, _) = surface.surface_measure_text(&ch.to_string());
let underline_rect = Rect::new(
text_x + prefix_w,
text_y + text_h - UNDERLINE_HEIGHT,
char_w.max(1.0),
UNDERLINE_HEIGHT,
);
surface.surface_fill_rect(underline_rect, fg);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alt_char_index_none_without_ampersand() {
assert_eq!(alt_char_index("File"), None);
}
#[test]
fn alt_char_index_marks_char_after_ampersand() {
assert_eq!(alt_char_index("&File"), Some(0));
assert_eq!(alt_char_index("Sa&ve"), Some(2));
}
#[test]
fn alt_char_index_handles_empty_and_trailing_marker() {
assert_eq!(alt_char_index(""), None);
assert_eq!(alt_char_index("File&"), Some(4));
assert_eq!(display_text("File&").chars().nth(4), None);
}
#[test]
fn display_text_strips_ampersand() {
assert_eq!(display_text("&File"), "File");
assert_eq!(display_text("Sa&ve"), "Save");
assert_eq!(display_text("File"), "File");
}
use crate::backend::ImagePaintResult;
use crate::event::{Point, Viewport};
use crate::primitives::menu_bar::{MenuBarItem, MenuBarItemMeasure};
use crate::types::{Color, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
texts: Vec<(Rect, String, Color)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(300.0, 20.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.texts.push((rect, text.to_string(), color));
}
fn surface_draw_line(&mut self, _from: Point, _to: Point, _color: Color, _sw: f32) {}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn drift_bar() -> MenuBar {
MenuBar {
id: WidgetId::new("bar"),
items: vec![
MenuBarItem {
id: WidgetId::new("file"),
label: "&File".into(),
disabled: false,
submenu: None,
},
MenuBarItem {
id: WidgetId::new("help"),
label: "Help".into(),
disabled: false,
submenu: None,
},
],
open_item: Some(0),
focused_item: None,
}
}
fn drift_layout(bar: &MenuBar) -> MenuBarLayout {
let bounds = Rect::new(0.0, 0.0, 300.0, 20.0);
bar.layout(bounds, |i| {
let text = display_text(&bar.items[i].label);
MenuBarItemMeasure::new(text.chars().count() as f32 * 8.0 + 16.0)
})
}
#[test]
fn paints_alt_underline_as_a_filled_rect_under_the_activation_char() {
let bar = drift_bar();
let layout = drift_layout(&bar);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(&bar, &layout, &mut surface, &theme);
let (label_rect, _, _) = surface
.texts
.iter()
.find(|(_, t, _)| t == "File")
.expect("File label painted");
let expected = Rect::new(
label_rect.x,
label_rect.y + label_rect.height - UNDERLINE_HEIGHT,
8.0,
UNDERLINE_HEIGHT,
);
assert!(
surface
.fills
.iter()
.any(|(r, c)| (r.x - expected.x).abs() < 0.01
&& (r.y - expected.y).abs() < 0.01
&& (r.width - expected.width).abs() < 0.01
&& (r.height - expected.height).abs() < 0.01
&& *c == theme.tab_active_fg),
"expected an underline fill at {expected:?} in {:?}, got fills: {:?}",
theme.tab_active_fg,
surface.fills,
);
}
#[test]
fn no_ampersand_means_no_underline_fill_beneath_the_label() {
let bar = drift_bar();
let layout = drift_layout(&bar);
let mut surface = RecordingSurface::default();
paint(&bar, &layout, &mut surface, &Theme::default());
let help_item = layout
.visible_items
.iter()
.find(|vi| bar.items[vi.item_idx].label == "Help")
.expect("Help item visible");
let thin_fills_under_help = surface
.fills
.iter()
.filter(|(r, _)| {
r.height <= UNDERLINE_HEIGHT
&& r.x >= help_item.bounds.x
&& r.x < help_item.bounds.x + help_item.bounds.width
})
.count();
assert_eq!(
thin_fills_under_help, 0,
"Help has no '&' and must get no underline fill"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bar(n: usize) -> MenuBar {
MenuBar {
id: WidgetId("bar".into()),
items: (0..n)
.map(|i| MenuBarItem {
id: WidgetId(format!("item{i}")),
label: format!("Item{i}"),
disabled: false,
submenu: None,
})
.collect(),
open_item: None,
focused_item: None,
}
}
#[test]
fn leading_icon_shifts_item_x_offsets_by_its_width() {
let bounds = Rect::new(0.0, 0.0, 200.0, 1.0);
let measure = |_: usize| MenuBarItemMeasure::new(20.0);
let leading_width = 32.0;
let plain = bar(3).layout(bounds, measure);
let with_icon = bar(3).layout_with_leading(bounds, leading_width, measure);
assert_eq!(plain.visible_items.len(), with_icon.visible_items.len());
for (p, w) in plain.visible_items.iter().zip(&with_icon.visible_items) {
assert_eq!(w.bounds.x, p.bounds.x + leading_width);
assert_eq!(w.bounds.width, p.bounds.width);
}
}
#[test]
fn leading_icon_click_on_first_item_still_hits_it_at_its_shifted_x() {
let bounds = Rect::new(0.0, 0.0, 200.0, 1.0);
let measure = |_: usize| MenuBarItemMeasure::new(20.0);
let leading_width = 32.0;
let layout = bar(3).layout_with_leading(bounds, leading_width, measure);
let item0 = &layout.visible_items[0];
assert_eq!(item0.bounds.x, leading_width);
let hit = layout.hit_test(item0.bounds.x + 1.0, 0.0);
assert_eq!(hit, MenuBarHit::Item(0));
let icon_hit = layout.hit_test(leading_width / 2.0, 0.0);
assert_eq!(icon_hit, MenuBarHit::Bar);
}
#[test]
fn zero_leading_width_matches_plain_layout() {
let bounds = Rect::new(0.0, 0.0, 200.0, 1.0);
let measure = |_: usize| MenuBarItemMeasure::new(20.0);
let plain = bar(3).layout(bounds, measure);
let zero_leading = bar(3).layout_with_leading(bounds, 0.0, measure);
assert_eq!(plain, zero_leading);
}
#[test]
fn leading_width_is_clamped_to_bounds_width() {
let bounds = Rect::new(0.0, 0.0, 50.0, 1.0);
let measure = |_: usize| MenuBarItemMeasure::new(20.0);
let layout = bar(2).layout_with_leading(bounds, 10_000.0, measure);
assert!(layout.visible_items.is_empty());
}
fn mk_menu_item(id: &str, label: &str) -> MenuBarItem {
MenuBarItem {
id: WidgetId::new(id),
label: label.to_string(),
disabled: false,
submenu: None,
}
}
#[test]
fn menu_bar_layout_flat_items() {
let bar = MenuBar {
id: WidgetId::new("mb"),
items: vec![
mk_menu_item("file", "&File"),
mk_menu_item("edit", "&Edit"),
mk_menu_item("view", "&View"),
],
open_item: None,
focused_item: None,
};
let bounds = Rect::new(0.0, 0.0, 800.0, 20.0);
let layout = bar.layout(bounds, |_| MenuBarItemMeasure::new(60.0));
assert_eq!(layout.visible_items.len(), 3);
assert_eq!(layout.visible_items[0].bounds.x, 0.0);
assert_eq!(layout.visible_items[1].bounds.x, 60.0);
assert_eq!(layout.visible_items[2].bounds.x, 120.0);
match layout.hit_test(70.0, 10.0) {
MenuBarHit::Item(1) => {}
other => panic!("expected Item(1), got {other:?}"),
}
}
#[test]
fn menu_bar_alt_target_resolution() {
let bar = MenuBar {
id: WidgetId::new("mb"),
items: vec![
mk_menu_item("file", "&File"),
mk_menu_item("edit", "&Edit"),
mk_menu_item("view", "&View"),
],
open_item: None,
focused_item: None,
};
assert_eq!(bar.find_alt_target('f'), Some(0));
assert_eq!(bar.find_alt_target('E'), Some(1));
assert_eq!(bar.find_alt_target('v'), Some(2));
assert_eq!(bar.find_alt_target('x'), None);
}
#[test]
fn menu_bar_disabled_items_not_clickable() {
let bar = MenuBar {
id: WidgetId::new("mb"),
items: vec![
mk_menu_item("file", "&File"),
MenuBarItem {
id: WidgetId::new("tools"),
label: "&Tools".to_string(),
disabled: true,
submenu: None,
},
],
open_item: None,
focused_item: None,
};
let bounds = Rect::new(0.0, 0.0, 800.0, 20.0);
let layout = bar.layout(bounds, |_| MenuBarItemMeasure::new(60.0));
assert!(!layout.visible_items[1].clickable);
assert_eq!(layout.hit_test(70.0, 10.0), MenuBarHit::Bar);
assert_eq!(bar.find_alt_target('t'), None);
}
#[test]
fn menu_bar_click_outside() {
let bar = MenuBar {
id: WidgetId::new("mb"),
items: vec![mk_menu_item("file", "File")],
open_item: None,
focused_item: None,
};
let bounds = Rect::new(0.0, 0.0, 200.0, 20.0);
let layout = bar.layout(bounds, |_| MenuBarItemMeasure::new(50.0));
assert_eq!(layout.hit_test(100.0, 50.0), MenuBarHit::Outside);
}
}