git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/container/menu.rs (52.9K)

   1 //! Narrow-trait `MenuBar` (Phase 5o) — a titled bar of dropdown menus with an optional
   2 //! context selector on the title. The menu buttons live in an EMBEDDED legacy [`ButtonStrip`]
   3 //! (owned by value in the model, driven through `WidgetHost` calls — events reach it via
   4 //! `EventCtx::ui`, and [`Layout::arrange_children`] parents it back to the adapter so its
   5 //! parent-chain styling walks keep working). Dropdowns are painted through the popover hooks
   6 //! ([`Paint::popover`] / [`Paint::draw_popover`]) and layered by [`Layout::z_order`]. The
   7 //! title supports the designer's curved circular-pane mode. Focus is conditional, exactly like
   8 //! legacy: the bar claims the global focus only while a dropdown is open or a menu selected
   9 //! ([`Input::is_focused`] reports that state, ignoring the base flag).
  10 //!
  11 //! The standalone `Menu` widget that used to live here was DELETED in this migration: it had
  12 //! zero constructors workspace-wide (dead code).
  13 //!
  14 //! Dropped with the migration: the `title_buf`/`curved_title_char_bufs`/`context_item_bufs`
  15 //! cosmic-text caches — `get_text_items` always returned empty, so `prepare_text` built buffers
  16 //! nothing ever read (an abandoned optimization). Also gone: the legacy `rect()` override's
  17 //! vertical-mode dynamic height (`with_vertical` has no callers workspace-wide; the vertical
  18 //! label/title geometry is kept for the strip's rotated mode, but the widget rect is the
  19 //! assigned base rect).
  20 
  21 use crate::colors;
  22 use crate::scene::layout::Rect;
  23 use crate::scene::paint::PaintCtx;
  24 use crate::widget::display::TextLabel;
  25 use crate::widget::{Adapted, ButtonStrip, WidgetHost, ElementState, Event, EventCtx, Input, Key, Layout, MenuController, MouseButton, NamedKey, PageSelector, Paint, DROPDOWN_ITEM_H, WidgetHostExt};
  26 
  27 pub struct MenuBar {
  28     pub visible: bool,
  29     pub network_opacity: f32,
  30     pub curved_circle: Option<(f32, f32, f32)>,
  31     pub blur: bool,
  32     pub color: Option<[f32; 4]>,
  33     /// Draw as a recess carved into the window root plate instead of as an opaque bar:
  34     /// no background fill of its own, just shaded edges, so the plate shows through.
  35     /// `color` is ignored while this is set — see [`Adapted::<MenuBar>::with_recess`].
  36     pub recessed: Option<bool>,
  37     pub title: String,
  38     pub menus: Adapted<ButtonStrip>,
  39     pub menu_items: Vec<String>,
  40     pub vertical_items: Vec<String>,
  41     pub menu_dropdowns: Vec<Vec<String>>,
  42     pub menu_dropdown_checked: Vec<Vec<Option<bool>>>,
  43     pub vertical: bool,
  44     pub focused: bool,
  45     pub z_level: i32,
  46     pub center_items: bool,
  47     pub title_pos: Option<(f32, f32)>,
  48     pub label: Option<String>,
  49     pub context_options: Vec<String>,
  50     pub context_selected: usize,
  51     pub context_dropdown_open: bool,
  52     pub context_just_changed: bool,
  53     pub context_hovered_item: Option<usize>,
  54     pub context_title_hovered: bool,
  55     pub right_align_title: bool,
  56     pub layout_dirty: bool,
  57     pub on_context_change_cb: Option<Box<dyn Fn(usize) + Send + Sync>>,
  58     pub on_menu_click_cb: Option<Box<dyn Fn(usize, usize) + Send + Sync>>,
  59     pub hovered_dropdown_item: Option<usize>,
  60     pub clicked_dropdown_item: Option<(usize, usize)>,
  61     last_arranged: Option<Rect>,
  62 }
  63 
  64 impl MenuBar {
  65     /// The style in force: the per-widget override (`with_recess`) when set, else
  66     /// the DE's `control_relief`, read live so a runtime switch
  67     /// (`layout::set_control_relief`) restyles every control at once.
  68     fn recessed(&self) -> bool {
  69         self.recessed.unwrap_or_else(crate::layout::control_relief)
  70     }
  71 
  72     pub fn new(x: f32, y: f32, w: f32, h: f32) -> Adapted<MenuBar> {
  73         let mut bar = Adapted::new(MenuBar {
  74             visible: true,
  75             network_opacity: 1.0,
  76             curved_circle: None,
  77             blur: false,
  78             color: None,
  79             recessed: None,
  80             title: String::new(),
  81             menus: Adapted::new(ButtonStrip::new(x, y, w, h).with_inherit_menubar_font(true)),
  82             menu_items: Vec::new(),
  83             vertical_items: Vec::new(),
  84             menu_dropdowns: Vec::new(),
  85             menu_dropdown_checked: Vec::new(),
  86             vertical: false,
  87             focused: false,
  88             z_level: 0,
  89             center_items: false,
  90             title_pos: None,
  91             label: None,
  92             context_options: Vec::new(),
  93             context_selected: 0,
  94             context_dropdown_open: false,
  95             context_just_changed: false,
  96             context_hovered_item: None,
  97             context_title_hovered: false,
  98             right_align_title: false,
  99             layout_dirty: true,
 100             on_context_change_cb: None,
 101             on_menu_click_cb: None,
 102             hovered_dropdown_item: None,
 103             clicked_dropdown_item: None,
 104             last_arranged: None,
 105         });
 106         WidgetHost::set_rect(&mut bar, x, y, w, h);
 107         bar
 108     }
 109 
 110     pub fn set_curved_circle(&mut self, circle: Option<(f32, f32, f32)>) {
 111         self.curved_circle = circle;
 112     }
 113 
 114     pub fn set_network_opacity(&mut self, opacity: f32) {
 115         self.network_opacity = opacity;
 116     }
 117 
 118     pub fn set_context_selected(&mut self, selected: usize) {
 119         self.context_selected = selected;
 120     }
 121 
 122     pub fn take_context_change(&mut self) -> Option<usize> {
 123         if self.context_just_changed {
 124             self.context_just_changed = false;
 125             Some(self.context_selected)
 126         } else {
 127             None
 128         }
 129     }
 130 
 131     fn display_title(&self) -> String {
 132         let mut display_title = self.title.clone();
 133         if !self.context_options.is_empty() {
 134             display_title.push_str(if self.vertical { "▼" } else { " ▼" });
 135         }
 136         display_title
 137     }
 138 
 139     pub fn title_rect(&self, rect: Rect) -> (f32, f32, f32, f32) {
 140         if self.title.is_empty() {
 141             return (0.0, 0.0, 0.0, 0.0);
 142         }
 143         let font_setting = crate::layout::menubar_font();
 144         let (_, font_size_opt) = crate::layout::parse_font_string(&font_setting);
 145         let font_size = font_size_opt.unwrap_or(12.0);
 146         let char_w = 7.5 * (font_size / 12.0);
 147         let padding_x = crate::layout::paginator_tab_padding_x();
 148 
 149         let mut display_title = self.title.clone();
 150         if !self.context_options.is_empty() {
 151             display_title.push_str(" ▼");
 152         }
 153 
 154         if self.curved_circle.is_some() {
 155             if let Some((tx, ty)) = self.title_pos {
 156                 let title_w = display_title.len() as f32 * char_w + 24.0;
 157                 (tx, ty, title_w, rect.height)
 158             } else {
 159                 (rect.x, rect.y, display_title.len() as f32 * char_w + 24.0, rect.height)
 160             }
 161         } else if self.vertical {
 162             let mut cy = 16.0;
 163             if let Some(ref label) = self.label {
 164                 let line_height = font_size * 1.2;
 165                 let label_h = label.chars().count() as f32 * line_height;
 166                 cy += label_h + 20.0;
 167             }
 168             let line_height = font_size * 1.2;
 169             let title_h = self.display_title().chars().count() as f32 * line_height;
 170             (rect.x, rect.y + cy, rect.width, title_h)
 171         } else {
 172             let mut start_x = 8.0;
 173             if self.center_items {
 174                 let mut total_width = 8.0;
 175                 total_width += display_title.len() as f32 * char_w + 24.0;
 176                 for btn_label in &self.menus.buttons {
 177                     total_width += btn_label.len() as f32 * char_w + 2.0 * padding_x;
 178                 }
 179                 if rect.width > total_width {
 180                     start_x = (rect.width - total_width) / 2.0;
 181                 }
 182             }
 183             let title_w = display_title.len() as f32 * char_w + 24.0;
 184             let tx = if self.right_align_title {
 185                 rect.x + rect.width - title_w - 20.0
 186             } else {
 187                 rect.x + start_x
 188             };
 189             (tx, rect.y, title_w, rect.height)
 190         }
 191     }
 192 
 193     pub fn context_popover_rect(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
 194         if self.context_options.is_empty() || !self.context_dropdown_open {
 195             return None;
 196         }
 197         let font_setting = crate::layout::menubar_font();
 198         let (_, font_size_opt) = crate::layout::parse_font_string(&font_setting);
 199         let font_size = font_size_opt.unwrap_or(12.0);
 200         let char_w = 7.5 * (font_size / 12.0);
 201 
 202         let max_len = self.context_options.iter().map(|s| s.len()).max().unwrap_or(0);
 203         let dw = (max_len as f32 * char_w + 40.0).max(140.0);
 204         let dh = self.context_options.len() as f32 * DROPDOWN_ITEM_H;
 205 
 206         let tr = self.title_rect(rect);
 207         let dx = if self.vertical { tr.0 + tr.2 } else { tr.0 };
 208         let dy = if self.vertical { tr.1 } else { tr.1 + tr.3 };
 209         Some((dx, dy, dw, dh))
 210     }
 211 
 212     pub fn menu_dropdown_rect(&self) -> Option<(f32, f32, f32, f32)> {
 213         let menu_idx = self.menus.selected?;
 214         let items = self.menu_dropdowns.get(menu_idx)?;
 215         if items.is_empty() {
 216             return None;
 217         }
 218         let hr = self.menus.item_rect(menu_idx);
 219         let dh = items.len() as f32 * DROPDOWN_ITEM_H;
 220         let max_len = items.iter().map(|s| s.len()).max().unwrap_or(0);
 221         let font_setting = crate::layout::menubar_font();
 222         let (_, font_size_opt) = crate::layout::parse_font_string(&font_setting);
 223         let font_size = font_size_opt.unwrap_or(12.0);
 224         let char_w = 7.5 * (font_size / 12.0);
 225         let dw = (max_len as f32 * char_w + 40.0).max(120.0);
 226 
 227         let dx = if self.vertical { hr.0 + hr.2 } else { hr.0 };
 228         let dy = if self.vertical { hr.1 } else { hr.1 + hr.3 };
 229         Some((dx, dy, dw, dh))
 230     }
 231 
 232     pub fn text_color(&self) -> [f32; 4] {
 233         crate::colors::menubar_tab_label_color()
 234     }
 235 
 236     pub fn is_blur_enabled(&self) -> bool {
 237         self.blur
 238     }
 239 
 240     fn update_menu_labels(&mut self) {
 241         let src = if self.vertical { &self.vertical_items } else { &self.menu_items };
 242         self.menus.buttons = src.clone();
 243         self.menus.generate_rotated_labels();
 244     }
 245 
 246     fn bg_color(&self) -> [f32; 4] {
 247         self.color.unwrap_or_else(colors::sidebar_bg_color)
 248     }
 249 
 250     /// Position the embedded strip inside `rect` — the legacy `set_rect` body, minus the
 251     /// parent clamping (that lives in [`Layout::adjust_rect`]) and the base assignment (the
 252     /// adapter's). Early-outs when the rect and content are unchanged, like legacy.
 253     fn layout_strip(&mut self, rect: Rect) {
 254         if self.last_arranged == Some(rect) && !self.layout_dirty {
 255             return;
 256         }
 257         self.layout_dirty = false;
 258         self.last_arranged = Some(rect);
 259         let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
 260 
 261         let font_setting = crate::layout::menubar_font();
 262         let font_info = crate::layout::parse_font_string(&font_setting);
 263         let font_fam = font_info.0;
 264         let font_size = font_info.1.unwrap_or(12.0);
 265 
 266         if self.vertical {
 267             let mut cy = 16.0;
 268             if let Some(ref label) = self.label {
 269                 let font_size = 12.0;
 270                 let line_height = font_size * 1.2;
 271                 let label_h = label.chars().count() as f32 * line_height;
 272                 cy += label_h + 20.0;
 273             }
 274             if !self.title.is_empty() {
 275                 let font_size = 12.0;
 276                 let line_height = font_size * 1.2;
 277                 let title_h = self.display_title().chars().count() as f32 * line_height;
 278                 cy += title_h + 36.0;
 279             }
 280             let menus_y = (y + cy).clamp(y, y + h);
 281             let menus_h = (h - cy).min(y + h - menus_y).max(0.0);
 282             self.menus.vertical = true;
 283             self.menus.set_rect(x, menus_y, w, menus_h);
 284         } else {
 285             let padding = crate::layout::button_padding();
 286             let spacing = crate::layout::button_strip_spacing();
 287             let mut cx = 8.0;
 288             if self.center_items {
 289                 let mut total_width = 8.0;
 290                 if !self.title.is_empty() && !self.right_align_title {
 291                     let mut display_title = self.title.clone();
 292                     if !self.context_options.is_empty() {
 293                         display_title.push_str(" ▼");
 294                     }
 295                     total_width += crate::widget::display::measure_text_width(&display_title, &font_fam, font_size) + 24.0;
 296                 }
 297                 let mut btn_strip_w = 0.0;
 298                 for (i, btn_label) in self.menus.buttons.iter().enumerate() {
 299                     let text_w = crate::widget::display::measure_text_width(btn_label, &font_fam, font_size);
 300                     btn_strip_w += text_w + 2.0 * padding;
 301                     if i > 0 {
 302                         btn_strip_w += spacing;
 303                     }
 304                 }
 305                 total_width += btn_strip_w;
 306                 if w > total_width {
 307                     cx = (w - total_width) / 2.0;
 308                 }
 309             }
 310             if !self.title.is_empty() && !self.right_align_title {
 311                 let mut display_title = self.title.clone();
 312                 if !self.context_options.is_empty() {
 313                     display_title.push_str(" ▼");
 314                 }
 315                 cx += crate::widget::display::measure_text_width(&display_title, &font_fam, font_size) + 24.0;
 316             }
 317             let mut btn_strip_w = 0.0;
 318             for (i, btn_label) in self.menus.buttons.iter().enumerate() {
 319                 let text_w = crate::widget::display::measure_text_width(btn_label, &font_fam, font_size);
 320                 btn_strip_w += text_w + 2.0 * padding;
 321                 if i > 0 {
 322                     btn_strip_w += spacing;
 323                 }
 324             }
 325             let menus_x = (x + cx).clamp(x, x + w);
 326             let menus_w = btn_strip_w.min(x + w - menus_x);
 327             self.menus.vertical = false;
 328             self.menus.set_rect(menus_x, y, menus_w, h);
 329         }
 330     }
 331 
 332     /// Close every open dropdown and drop internal focus state — the state half of the legacy
 333     /// `unfocus` (the global-focus release is the caller's, via `EventCtx::release_focus`).
 334     fn close_all(&mut self) {
 335         self.focused = false;
 336         self.context_dropdown_open = false;
 337         self.context_hovered_item = None;
 338         self.hovered_dropdown_item = None;
 339         self.menus.inner_mut().set_selected(None);
 340     }
 341 
 342     /// The conditional focus claim of the legacy `focus()`: hold the global focus only while
 343     /// something is open.
 344     fn sync_focus(&mut self, ectx: &mut EventCtx) {
 345         if self.context_dropdown_open || self.menus.selected.is_some() {
 346             self.focused = true;
 347             ectx.request_focus();
 348         } else {
 349             self.focused = false;
 350             ectx.release_focus();
 351         }
 352     }
 353 }
 354 
 355 impl Adapted<MenuBar> {
 356     pub fn with_color(mut self, color: [f32; 4]) -> Self {
 357         self.color = Some(color);
 358         self
 359     }
 360 
 361     /// Drop the bar's own background and carve it into the window root plate instead, so
 362     /// the plate reads as recessed under the menu — a relief cut into the surface rather
 363     /// than a slab sitting on it. Shading follows the DE-wide `light_source_position` /
 364     /// `bevel_depth` config, inverted so the light-facing edges are the shadowed ones.
 365     pub fn with_recess(mut self, recessed: bool) -> Self {
 366         self.recessed = Some(recessed);
 367         self
 368     }
 369 
 370     pub fn with_blur(mut self, blur: bool) -> Self {
 371         self.blur = blur;
 372         self
 373     }
 374 
 375     pub fn with_right_aligned_title(mut self, right: bool) -> Self {
 376         self.right_align_title = right;
 377         self
 378     }
 379 
 380     pub fn on_context_change<F: Fn(usize) + Send + Sync + 'static>(mut self, cb: F) -> Self {
 381         self.on_context_change_cb = Some(Box::new(cb));
 382         self
 383     }
 384 
 385     pub fn on_menu_click<F: Fn(usize, usize) + Send + Sync + 'static>(mut self, cb: F) -> Self {
 386         self.on_menu_click_cb = Some(Box::new(cb));
 387         self
 388     }
 389 
 390     pub fn with_context_options(mut self, options: Vec<String>, selected: usize) -> Self {
 391         self.context_options = options;
 392         self.context_selected = selected;
 393         self
 394     }
 395 
 396     pub fn with_center_items(mut self, center: bool) -> Self {
 397         self.center_items = center;
 398         self
 399     }
 400 
 401     pub fn with_title(mut self, title: &str) -> Self {
 402         self.title = title.to_string();
 403         self
 404     }
 405 
 406     pub fn with_item(mut self, label: &str, items: &[&str]) -> Self {
 407         self.menu_items.push(label.to_string());
 408         self.vertical_items.push(label.to_string());
 409         self.menu_dropdowns.push(items.iter().map(|s| s.to_string()).collect());
 410         self.menu_dropdown_checked.push(vec![None; items.len()]);
 411         self.menus.add_button(label);
 412         self
 413     }
 414 
 415     pub fn with_item_vh(mut self, horizontal_label: &str, vertical_label: &str, items: &[&str]) -> Self {
 416         self.menu_items.push(horizontal_label.to_string());
 417         self.vertical_items.push(vertical_label.to_string());
 418         self.menu_dropdowns.push(items.iter().map(|s| s.to_string()).collect());
 419         self.menu_dropdown_checked.push(vec![None; items.len()]);
 420         let label = if self.vertical { vertical_label } else { horizontal_label };
 421         self.menus.add_button(label);
 422         self
 423     }
 424 
 425     pub fn with_vertical(mut self, vertical: bool) -> Self {
 426         self.vertical = vertical;
 427         self.menus.vertical = vertical;
 428         self.update_menu_labels();
 429         self
 430     }
 431 
 432     pub fn with_z_index(mut self, z: i32) -> Self {
 433         self.z_level = z;
 434         self
 435     }
 436 }
 437 
 438 impl Layout for MenuBar {
 439 
 440     fn z_order(&self) -> i32 {
 441         self.z_level
 442     }
 443 
 444     fn arrange_children(&mut self, rect: Rect) {
 445         self.layout_strip(rect);
 446     }
 447 }
 448 
 449 impl Paint for MenuBar {
 450     fn color(&self) -> [f32; 4] {
 451         self.bg_color()
 452     }
 453 
 454     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
 455         // Corners never round (the root plate-adjacency source is gone). The radius was the
 456         // parent's, read through a stored pointer — but nothing ever set_parent's a MenuBar,
 457         // so 0.0 is what production always read (6bd: the dead pointer field is gone).
 458         Some((0.0, (false, false, false, false)))
 459     }
 460 
 461     fn widget_font(&self) -> Option<String> {
 462         Some(crate::layout::menubar_font())
 463     }
 464 
 465     fn sync_label(&mut self, label: &str) {
 466         self.label = Some(label.to_string());
 467         self.layout_dirty = true;
 468     }
 469 
 470     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
 471         if self.recessed() {
 472             // No background of our own: carve the root plate instead. The recess shading is
 473             // a light/shadow overlay, so whatever the plate painted here (fill, rim
 474             // gradient, blur) shows through modulated.
 475             // `depth` is the roll-off width in px (the shading amplitude is separate: the
 476             // renderer applies `bevel_depth` itself), capped so a deep DE-wide setting can
 477             // never swallow a short bar — the two walls would meet in the middle and the
 478             // flat floor would vanish.
 479             if rect.x <= 0.5 && rect.y <= 0.5 {
 480                 // Flush with the plate's top-left: the bar is a plateau one step down, not a
 481                 // trough, so its only wall is the one facing the content. The other three
 482                 // sides are the plate's outer edge, where the plate's own roll already lives
 483                 // — carving there too would cut a second lip into the same pixels. One wall
 484                 // straddling the boundary intrudes only half its width, so the cap is looser
 485                 // than the trough's.
 486                 // The wall stays inside the bar (`layout::carve_inside`).
 487                 let depth = crate::layout::bar_wall_width().min(rect.height * 0.6);
 488                 let bar = Rect { height: rect.height - depth * 0.5, ..rect };
 489                 ctx.recess_edges(bar, (0.0, 0.0, 0.0, 0.0), depth, (false, false, true, false));
 490             } else {
 491                 // Inset from the plate edge: a real trough, walled all round, its corners
 492                 // rounded by the roll itself.
 493                 let depth = crate::layout::bar_wall_width().min(rect.height * 0.4);
 494                 let (well, radii) = crate::layout::carve_inside(rect, (depth, depth, depth, depth), depth);
 495                 ctx.recess(well, radii, depth);
 496             }
 497         } else {
 498             // Background: always the plain quad — the rounded-against-parent variant required a
 499             // root plate parent, which no longer exists.
 500             ctx.quad(rect, self.bg_color());
 501         }
 502 
 503         // Dropdown-trigger chrome shared by the context title and the menu
 504         // buttons on horizontal bars: the DE-wide closed-dropdown look — a
 505         // flush plate with a field run's edge and a transparent face
 506         // (Dropdown::paint_background's raised path) on a band-inset rect, with
 507         // the state fill rounded to sit inside it. The vertical and curved
 508         // modes keep their plain quads — their geometry is exotic and gets no
 509         // trough.
 510         let flat_modes = self.vertical || self.curved_circle.is_some();
 511         let trough_chrome = |ctx: &mut PaintCtx, r: (f32, f32, f32, f32), fill: Option<[f32; 4]>| {
 512             let trough_h = DROPDOWN_ITEM_H.min((r.3 - 6.0).max(8.0));
 513             let trough = Rect { x: r.0, y: r.1 + (r.3 - trough_h) / 2.0, width: r.2, height: trough_h };
 514             let radius = crate::layout::dropdown_corner_radius();
 515             let depth = crate::layout::bevel_width().min(trough_h * 0.2);
 516             let (trough, radii) = crate::layout::carve_inside(trough, (radius, radius, radius, radius), depth);
 517             ctx.inset_plate(trough, radii, None, depth);
 518             if let Some(c) = fill {
 519                 ctx.rounded_rect(trough, radius, (true, true, true, true), c);
 520             }
 521         };
 522 
 523         // Context-dropdown trigger (the bar's folder/pane selector).
 524         if !self.context_options.is_empty() {
 525             let tr = self.title_rect(rect);
 526             if flat_modes {
 527                 if self.context_dropdown_open {
 528                     ctx.quad(Rect { x: tr.0, y: tr.1, width: tr.2, height: tr.3 }, colors::highlight_primary_color());
 529                 } else if self.context_title_hovered {
 530                     ctx.quad(Rect { x: tr.0, y: tr.1, width: tr.2, height: tr.3 }, colors::HIGHLIGHT_SECONDARY);
 531                 }
 532             } else {
 533                 let fill = if self.context_dropdown_open {
 534                     Some(colors::highlight_primary_color())
 535                 } else if self.context_title_hovered {
 536                     Some(colors::HIGHLIGHT_SECONDARY)
 537                 } else {
 538                     None
 539                 };
 540                 trough_chrome(ctx, tr, fill);
 541             }
 542         }
 543 
 544         // The embedded strip's geometry (it is not a tree child; its pixels are
 545         // ours). Horizontal bars restyle the menu buttons as dropdown triggers
 546         // too: each gets its own trough (side-inset so adjacent troughs keep
 547         // separate groove rings), and the strip's full-height square state
 548         // quads are replaced by the trigger fills — open matches the context
 549         // dropdown's open tint rather than the strip's legacy palette.
 550         let strip = crate::widget::shown_prims(&self.menus);
 551         if flat_modes {
 552             for prim in &strip {
 553                 if let crate::scene::paint::Prim::Quad { rect, color } = prim {
 554                     ctx.quad(*rect, *color);
 555                 }
 556             }
 557         } else {
 558             for i in 0..self.menus.buttons.len() {
 559                 let r = self.menus.item_rect(i);
 560                 let r = (r.0 + 3.0, r.1, (r.2 - 6.0).max(8.0), r.3);
 561                 let fill = if Some(i) == self.menus.selected {
 562                     Some(colors::highlight_primary_color())
 563                 } else if Some(i) == self.menus.pressed_idx {
 564                     Some(colors::BUTTON_PRESS)
 565                 } else if Some(i) == self.menus.hovered_idx {
 566                     Some(colors::HIGHLIGHT_SECONDARY)
 567                 } else {
 568                     None
 569                 };
 570                 trough_chrome(ctx, r, fill);
 571             }
 572         }
 573         for prim in strip {
 574             match prim {
 575                 crate::scene::paint::Prim::Arc { cx, cy, radius, thickness, start, end, color } => {
 576                     ctx.arc(cx, cy, radius, thickness, start, end, color);
 577                 }
 578                 crate::scene::paint::Prim::Circle { cx, cy, radius, color } => ctx.circle(cx, cy, radius, color),
 579                 _ => {}
 580             }
 581         }
 582 
 583         // Text: the sidebar label (vertical), the title (curved / vertical / horizontal), and
 584         // the strip's button labels — the legacy `text_labels` body.
 585         let label_color = self.text_color();
 586         let srgb = crate::colors::to_srgb(label_color);
 587         let text_color = [
 588             (srgb[0] * 255.0) as u8,
 589             (srgb[1] * 255.0) as u8,
 590             (srgb[2] * 255.0) as u8,
 591         ];
 592         // The title's context arrow: the `chevron-down` glyph in the title's
 593         // colour, standing where the " ▼" it replaced stood (the title's
 594         // width is still measured with it, so nothing moves). A label at
 595         // (x, y) sized `fs` puts it there.
 596         let arrow_tint = [srgb[0], srgb[1], srgb[2], 1.0];
 597         let arrow_at = |ctx: &mut PaintCtx, x: f32, y: f32, fs: f32| {
 598             let side = (fs * 0.6).round();
 599             ctx.icon("chevron-down", Rect { x, y: y + 0.5 * (fs - side), width: side, height: side }, arrow_tint);
 600         };
 601         let padding_x = crate::layout::paginator_tab_padding_x();
 602 
 603         if let Some(ref label) = self.label {
 604             if self.vertical {
 605                 let font_size = 12.0;
 606                 let line_height = font_size * 1.2;
 607                 let start_y = rect.y + 16.0;
 608                 for (i, c) in label.chars().enumerate() {
 609                     let char_str = c.to_string();
 610                     let char_w = crate::widget::display::measure_text(&char_str, font_size);
 611                     let x_pos = rect.x + (rect.width - char_w) / 2.0;
 612                     let y_pos = start_y + i as f32 * line_height;
 613                     ctx.text_with(char_str, x_pos, y_pos, font_size, [0x83, 0x83, 0x8a], None,
 614                         Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
 615                 }
 616             }
 617         }
 618 
 619         let font_setting = crate::layout::menubar_font();
 620         let (font_fam, font_size_opt) = crate::layout::parse_font_string(&font_setting);
 621         let font_size = font_size_opt.unwrap_or(12.0);
 622         let char_w = 7.5 * (font_size / 12.0);
 623 
 624         let mut display_title = self.title.clone();
 625         if !self.context_options.is_empty() {
 626             display_title.push_str(" ▼");
 627         }
 628 
 629         if let Some((ccx, ccy, ccr)) = self.curved_circle {
 630             let r_mid = ccr - rect.height / 2.0;
 631             let mut total_width = 8.0;
 632             if !self.title.is_empty() {
 633                 total_width += display_title.len() as f32 * char_w + 24.0;
 634             }
 635             for btn_label in &self.menus.buttons {
 636                 total_width += btn_label.len() as f32 * char_w + 2.0 * padding_x;
 637             }
 638             let total_angular_width = total_width / r_mid;
 639             let start_angle = 1.5 * std::f32::consts::PI - total_angular_width / 2.0;
 640             let current_angle = start_angle;
 641 
 642             if !self.title.is_empty() {
 643                 let title_w = display_title.len() as f32 * char_w + 24.0;
 644                 let dtheta_title = title_w / r_mid;
 645                 for l in TextLabel::curved_layout(
 646                     &display_title,
 647                     ccx, ccy, r_mid,
 648                     current_angle, current_angle + dtheta_title,
 649                     font_size,
 650                     text_color,
 651                 ) {
 652                     if l.text == "▼" {
 653                         arrow_at(ctx, l.x, l.y, l.font_size);
 654                         continue;
 655                     }
 656                     ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None,
 657                         Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
 658                 }
 659             }
 660         } else if self.vertical {
 661             if !self.title.is_empty() {
 662                 let mut start_y = rect.y + 16.0;
 663                 if let Some(ref label) = self.label {
 664                     let font_size = 12.0;
 665                     let line_height = font_size * 1.2;
 666                     let label_h = label.chars().count() as f32 * line_height;
 667                     start_y += label_h + 20.0;
 668                 }
 669                 let line_height = font_size * 1.2;
 670                 let char_w = crate::widget::display::measure_text("o", font_size);
 671                 let x_pos = rect.x + (rect.width - char_w) / 2.0;
 672                 for (i, c) in self.display_title().chars().enumerate() {
 673                     let char_str = c.to_string();
 674                     let y_pos = start_y + i as f32 * line_height;
 675                     if c == '▼' {
 676                         arrow_at(ctx, x_pos, y_pos, font_size);
 677                         continue;
 678                     }
 679                     ctx.text_with(char_str, x_pos, y_pos, font_size, text_color, None,
 680                         Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
 681                 }
 682             }
 683         } else if !self.title.is_empty() {
 684             let mut start_x = 8.0;
 685             if self.center_items {
 686                 let mut total_width = 8.0;
 687                 if !self.right_align_title {
 688                     total_width += display_title.len() as f32 * char_w + 24.0;
 689                 }
 690                 for btn_label in &self.menus.buttons {
 691                     total_width += btn_label.len() as f32 * char_w + 2.0 * padding_x;
 692                 }
 693                 if rect.width > total_width {
 694                     start_x = (rect.width - total_width) / 2.0;
 695                 }
 696             }
 697             let text_y = crate::layout::align_text_y(rect.y, rect.height, font_size, 0.0);
 698             let x_pos = if self.right_align_title {
 699                 let title_w = crate::widget::display::measure_text_width(&display_title, &font_fam, font_size) + 24.0;
 700                 rect.x + rect.width - title_w - 20.0
 701             } else {
 702                 rect.x + start_x
 703             };
 704             ctx.text_with(self.title.clone(), x_pos, text_y, font_size, text_color, None,
 705                 Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
 706             if !self.context_options.is_empty() {
 707                 let lead = format!("{} ", self.title);
 708                 let ax = x_pos + crate::widget::display::measure_text_width(&lead, &font_fam, font_size);
 709                 arrow_at(ctx, ax, text_y, font_size);
 710             }
 711         }
 712 
 713         // Every word this widget draws is bounded by the widget. A menu's
 714         // POPOVER is a separate pass with its own rect, so bounding the bar
 715         // here does not clip an open menu.
 716         let bar = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
 717         for l in self.menus.own_labels() {
 718             ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, bar);
 719         }
 720     }
 721 
 722     fn popover(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
 723         self.context_popover_rect(rect).or_else(|| self.menu_dropdown_rect())
 724     }
 725 
 726     fn draw_popover(&self, rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
 727         let label_color = self.text_color();
 728         let srgb = crate::colors::to_srgb(label_color);
 729         let color_f32 = [srgb[0], srgb[1], srgb[2], 1.0];
 730         let font = Paint::widget_font(self);
 731 
 732         // Both dropdown flavors paint in the Dropdown widget's popover idiom
 733         // (layered soft shadows, surface border/bg, accent hover, blue
 734         // selected) so menubar menus read as the DE's normal dropdowns.
 735         let draw_panel = |pc: &mut dyn crate::layout::RenderTarget, dx: f32, dy: f32, dw: f32, dh: f32, hovered: Option<usize>| {
 736             let theme = colors::active_theme();
 737             pc.rect([0.02, 0.02, 0.05, 0.15], dx + 1.0, dy + 1.0, dw, dh);
 738             pc.rect([0.02, 0.02, 0.05, 0.08], dx + 3.0, dy + 3.0, dw, dh);
 739             pc.rect([0.02, 0.02, 0.05, 0.04], dx + 5.0, dy + 5.0, dw, dh);
 740             pc.rect(theme.surface_border, dx, dy, dw, dh);
 741             // Frosted, like the Dropdown popover and the context menu: the
 742             // popover material (`Material::popover`), encoded for the
 743             // colour-typed flat path — menus show what is beneath them
 744             // blurred and tinted, not covered.
 745             let bg = crate::scene::Material::popover(theme.surface_bg).fill(crate::scene::PlateRole::Nested);
 746             pc.rect(bg, dx + 1.0, dy + 1.0, dw - 2.0, dh - 2.0);
 747             if let Some(di) = hovered {
 748                 let iy = dy + di as f32 * DROPDOWN_ITEM_H;
 749                 pc.rect(theme.primary_accent, dx + 2.0, iy + 2.0, dw - 4.0, DROPDOWN_ITEM_H - 4.0);
 750             }
 751         };
 752         let item_color = |hovered: bool, selected: bool| -> [f32; 4] {
 753             let c: [u8; 3] = if hovered {
 754                 [0xff, 0xff, 0xff]
 755             } else if selected {
 756                 [0x3a, 0x9a, 0xff]
 757             } else {
 758                 [0xcc, 0xcc, 0xd4]
 759             };
 760             [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0]
 761         };
 762         let _ = color_f32;
 763 
 764         if self.context_dropdown_open {
 765             if let Some((dx, dy, dw, dh)) = self.context_popover_rect(rect) {
 766                 draw_panel(pc, dx, dy, dw, dh, self.context_hovered_item);
 767                 let bounds = Some([dx, dy, dx + dw, dy + dh]);
 768                 for (i, option) in self.context_options.iter().enumerate() {
 769                     let color = item_color(self.context_hovered_item == Some(i), self.context_selected == i);
 770                     let iy = crate::layout::align_text_y(dy + i as f32 * DROPDOWN_ITEM_H, DROPDOWN_ITEM_H, 12.0, 0.0);
 771                     if let Some(ref f) = font {
 772                         pc.text_with_font_and_bounds(option, dx + 8.0, iy, 12.0, color, f, bounds);
 773                     } else {
 774                         pc.text_with_bounds(option, dx + 8.0, iy, 12.0, color, bounds);
 775                     }
 776                 }
 777             }
 778         } else if let Some((dx, dy, dw, dh)) = self.menu_dropdown_rect() {
 779             draw_panel(pc, dx, dy, dw, dh, self.hovered_dropdown_item);
 780             let bounds = Some([dx, dy, dx + dw, dy + dh]);
 781             if let Some(menu_idx) = self.menus.selected {
 782                 if let Some(items) = self.menu_dropdowns.get(menu_idx) {
 783                     for (i, option) in items.iter().enumerate() {
 784                         let checked = self.menu_dropdown_checked.get(menu_idx)
 785                             .and_then(|menu| menu.get(i))
 786                             .and_then(|&v| v);
 787                         // A checkable item keeps a mark's room before its
 788                         // text; a checked one wears the `check` glyph in it.
 789                         const MARK: f32 = 9.0;
 790                         let indent = if checked.is_some() { MARK + 6.0 } else { 0.0 };
 791                         let color = item_color(self.hovered_dropdown_item == Some(i), checked == Some(true));
 792                         let iy = crate::layout::align_text_y(dy + i as f32 * DROPDOWN_ITEM_H, DROPDOWN_ITEM_H, 12.0, 0.0);
 793                         if checked == Some(true) {
 794                             let my = dy + i as f32 * DROPDOWN_ITEM_H + 0.5 * (DROPDOWN_ITEM_H - MARK);
 795                             pc.icon("check", Rect { x: dx + 8.0, y: my, width: MARK, height: MARK }, color);
 796                         }
 797                         if let Some(ref f) = font {
 798                             pc.text_with_font_and_bounds(option, dx + 8.0 + indent, iy, 12.0, color, f, bounds);
 799                         } else {
 800                             pc.text_with_bounds(option, dx + 8.0 + indent, iy, 12.0, color, bounds);
 801                         }
 802                     }
 803                 }
 804             }
 805         }
 806     }
 807 }
 808 
 809 impl Input for MenuBar {
 810     fn blocks_root_plate_drag(&self) -> bool {
 811         false
 812     }
 813 
 814     /// Legacy `mouse_input` saw every press: any press closes an open context dropdown, even
 815     /// outside the bar.
 816     fn gates_presses(&self) -> bool {
 817         false
 818     }
 819 
 820     fn hit(&self, rect: Rect, px: f32, py: f32) -> bool {
 821         if !self.visible {
 822             return false;
 823         }
 824         if let Some((dx, dy, dw, dh)) = self.context_popover_rect(rect) {
 825             if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
 826                 return true;
 827             }
 828         }
 829         if let Some((dx, dy, dw, dh)) = self.menu_dropdown_rect() {
 830             if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
 831                 return true;
 832             }
 833         }
 834         if px >= rect.x && px <= rect.x + rect.width && py >= rect.y && py <= rect.y + rect.height {
 835             return true;
 836         }
 837         // The strip may extend past the assigned rect (clamped layouts).
 838         let (sx, sy, sw, sh) = self.menus.rect();
 839         px >= sx && px <= sx + sw && py >= sy && py <= sy + sh
 840     }
 841 
 842     fn set_modifiers(&mut self, ctrl: bool, shift: bool, alt: bool) {
 843         self.menus.set_modifiers(ctrl, shift, alt);
 844     }
 845 
 846     fn visibility_changed(&mut self, visible: bool) {
 847         self.visible = visible;
 848         self.menus.set_visible(visible);
 849         self.layout_dirty = true;
 850     }
 851 
 852     fn is_focused(&self, _base_focused: bool) -> bool {
 853         self.focused || self.context_dropdown_open || self.menus.selected.is_some()
 854     }
 855 
 856     fn set_selected(&mut self, selected: bool) {
 857         self.focused = selected;
 858         if !selected {
 859             self.menus.inner_mut().set_selected(None);
 860         }
 861     }
 862 
 863     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
 864         match event {
 865             Event::PointerMove { x: px, y: py, .. } => {
 866                 if !self.visible {
 867                     return false;
 868                 }
 869                 let (px, py) = (*px, *py);
 870                 let rect = ectx.rect;
 871                 self.layout_strip(rect);
 872 
 873                 let mut changed = false;
 874 
 875                 let old_title_hovered = self.context_title_hovered;
 876                 self.context_title_hovered = false;
 877                 if !self.context_options.is_empty() {
 878                     let tr = self.title_rect(rect);
 879                     if px >= tr.0 && px <= tr.0 + tr.2 && py >= tr.1 && py <= tr.1 + tr.3 {
 880                         self.context_title_hovered = true;
 881                     }
 882                 }
 883                 if old_title_hovered != self.context_title_hovered {
 884                     changed = true;
 885                 }
 886 
 887                 let old_hovered_item = self.context_hovered_item;
 888                 self.context_hovered_item = None;
 889                 if let Some((dx, dy, dw, dh)) = self.context_popover_rect(rect) {
 890                     if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
 891                         let di = ((py - dy) / DROPDOWN_ITEM_H) as usize;
 892                         if di < self.context_options.len() {
 893                             self.context_hovered_item = Some(di);
 894                         }
 895                     }
 896                 }
 897                 if old_hovered_item != self.context_hovered_item {
 898                     changed = true;
 899                 }
 900 
 901                 let old_hovered_dropdown = self.hovered_dropdown_item;
 902                 self.hovered_dropdown_item = None;
 903                 if let Some((dx, dy, dw, dh)) = self.menu_dropdown_rect() {
 904                     if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
 905                         let di = ((py - dy) / DROPDOWN_ITEM_H) as usize;
 906                         if let Some(menu_idx) = self.menus.selected {
 907                             if let Some(items) = self.menu_dropdowns.get(menu_idx) {
 908                                 if di < items.len() {
 909                                     self.hovered_dropdown_item = Some(di);
 910                                 }
 911                             }
 912                         }
 913                     }
 914                 }
 915                 if old_hovered_dropdown != self.hovered_dropdown_item {
 916                     changed = true;
 917                 }
 918 
 919                 if let Some(ui) = ectx.ui.as_deref_mut() {
 920                     if self.menus.cursor_moved(px, py, ui) {
 921                         changed = true;
 922                     }
 923                 }
 924                 changed
 925             }
 926             Event::MouseButton { button, state, x: px, y: py, .. } => {
 927                 if !self.visible {
 928                     return false;
 929                 }
 930                 if *button != MouseButton::Left {
 931                     return false;
 932                 }
 933                 let (px, py, state) = (*px, *py, *state);
 934                 let rect = ectx.rect;
 935                 self.layout_strip(rect);
 936 
 937                 let mut changed = false;
 938 
 939                 if let Some((dx, dy, dw, dh)) = self.menu_dropdown_rect() {
 940                     if px >= dx && px < dx + dw && py >= dy && py < dy + dh {
 941                         if state == ElementState::Pressed {
 942                             let di = ((py - dy) / DROPDOWN_ITEM_H) as usize;
 943                             if let Some(menu_idx) = self.menus.selected {
 944                                 if let Some(items) = self.menu_dropdowns.get(menu_idx) {
 945                                     if di < items.len() {
 946                                         self.clicked_dropdown_item = Some((menu_idx, di));
 947                                         self.close_all();
 948                                         ectx.release_focus();
 949                                         return true;
 950                                     }
 951                                 }
 952                             }
 953                         }
 954                         return true;
 955                     }
 956                 }
 957 
 958                 if let Some((dx, dy, dw, dh)) = self.context_popover_rect(rect) {
 959                     if px >= dx && px < dx + dw && py >= dy && py < dy + dh
 960                         && state == ElementState::Pressed {
 961                             let di = ((py - dy) / DROPDOWN_ITEM_H) as usize;
 962                             if di < self.context_options.len() {
 963                                 self.context_selected = di;
 964                                 self.context_just_changed = true;
 965                                 self.close_all();
 966                                 ectx.release_focus();
 967                                 if let Some(ref cb) = self.on_context_change_cb {
 968                                     cb(di);
 969                                 }
 970                                 return true;
 971                             }
 972                         }
 973                 }
 974 
 975                 if !self.context_options.is_empty() {
 976                     let tr = self.title_rect(rect);
 977                     if px >= tr.0 && px <= tr.0 + tr.2 && py >= tr.1 && py <= tr.1 + tr.3 {
 978                         if state == ElementState::Pressed {
 979                             if self.context_dropdown_open {
 980                                 self.close_all();
 981                                 ectx.release_focus();
 982                             } else {
 983                                 self.menus.unfocus();
 984                                 self.context_dropdown_open = true;
 985                                 self.sync_focus(ectx);
 986                             }
 987                         }
 988                         return true;
 989                     }
 990                 }
 991 
 992                 if self.context_dropdown_open && state == ElementState::Pressed {
 993                     self.close_all();
 994                     ectx.release_focus();
 995                     changed = true;
 996                 }
 997 
 998                 let old_menu_selected = self.menus.selected;
 999                 if let Some(ui) = ectx.ui.as_deref_mut() {
1000                     if self.menus.mouse_input(MouseButton::Left, state, px, py, ui) {
1001                         changed = true;
1002                         if self.menus.selected.is_some() && old_menu_selected != self.menus.selected {
1003                             self.sync_focus(ectx);
1004                         }
1005                     }
1006                 }
1007                 changed
1008             }
1009             Event::KeyInput(key_event) => {
1010                 if key_event.state != ElementState::Pressed {
1011                     return false;
1012                 }
1013                 if self.context_dropdown_open {
1014                     match key_event.logical_key {
1015                         Key::Named(NamedKey::ArrowDown) => {
1016                             let current = self.context_hovered_item.unwrap_or(self.context_selected);
1017                             if current + 1 < self.context_options.len() {
1018                                 self.context_hovered_item = Some(current + 1);
1019                             } else {
1020                                 self.context_hovered_item = Some(0);
1021                             }
1022                             return true;
1023                         }
1024                         Key::Named(NamedKey::ArrowUp) => {
1025                             let current = self.context_hovered_item.unwrap_or(self.context_selected);
1026                             if current > 0 {
1027                                 self.context_hovered_item = Some(current - 1);
1028                             } else {
1029                                 self.context_hovered_item = Some(self.context_options.len() - 1);
1030                             }
1031                             return true;
1032                         }
1033                         Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
1034                             if let Some(idx) = self.context_hovered_item {
1035                                 self.context_selected = idx;
1036                                 self.context_just_changed = true;
1037                             }
1038                             self.close_all();
1039                             ectx.release_focus();
1040                             return true;
1041                         }
1042                         Key::Named(NamedKey::Escape) => {
1043                             self.close_all();
1044                             ectx.release_focus();
1045                             return true;
1046                         }
1047                         _ => {}
1048                     }
1049                 }
1050                 match ectx.ui.as_deref_mut() {
1051                     Some(ui) => self.menus.keyboard_input(key_event, ui),
1052                     None => false,
1053                 }
1054             }
1055             // Hosts call `focus()`/`unfocus()` directly; legacy semantics: focus is claimed
1056             // conditionally (only while something is open), unfocus closes everything.
1057             Event::FocusIn => {
1058                 self.sync_focus(ectx);
1059                 true
1060             }
1061             Event::FocusOut => {
1062                 self.close_all();
1063                 ectx.release_focus();
1064                 true
1065             }
1066             _ => false,
1067         }
1068     }
1069 
1070 }
1071 
1072 impl MenuController for MenuBar {
1073     fn menu_click(&mut self) -> Option<(usize, usize)> {
1074         let _ = self.menus.take_click();
1075 
1076         if let Some((menu_idx, item_idx)) = self.clicked_dropdown_item.take() {
1077             if let Some(ref cb) = self.on_menu_click_cb {
1078                 cb(menu_idx, item_idx);
1079             }
1080             return Some((menu_idx, item_idx));
1081         }
1082         None
1083     }
1084 
1085     fn trigger_menu_click(&mut self, menu_idx: usize, item_idx: usize) {
1086         self.clicked_dropdown_item = Some((menu_idx, item_idx));
1087     }
1088 
1089     fn set_item_checked(&mut self, menu_idx: usize, item_idx: usize, checked: bool) {
1090         if let Some(menu) = self.menu_dropdown_checked.get_mut(menu_idx) {
1091             if item_idx < menu.len() {
1092                 menu[item_idx] = Some(checked);
1093             }
1094         }
1095     }
1096 
1097     fn set_menu_items(&mut self, menu_idx: usize, items: &[String]) {
1098         if menu_idx < self.menu_dropdowns.len() {
1099             self.menu_dropdowns[menu_idx] = items.to_vec();
1100             self.menu_dropdown_checked[menu_idx] = vec![Some(false); items.len()];
1101             self.layout_dirty = true;
1102         }
1103     }
1104 
1105     fn is_menu_bar(&self) -> bool {
1106         self.visible
1107     }
1108 
1109     fn is_menu_open(&self) -> bool {
1110         self.context_dropdown_open || self.menus.selected.is_some()
1111     }
1112 
1113     fn menu_items(&self) -> Vec<String> {
1114         self.menu_items.clone()
1115     }
1116 
1117     fn menu_item_checked(&self) -> Vec<Option<bool>> {
1118         self.menu_dropdown_checked.iter().flatten().copied().collect()
1119     }
1120 
1121     fn is_vertical(&self) -> bool {
1122         self.vertical
1123     }
1124 
1125     fn menu_names(&self) -> Vec<String> {
1126         self.menus.buttons.clone()
1127     }
1128 
1129     fn menu_items_list(&self) -> Vec<Vec<String>> {
1130         self.menu_dropdowns.clone()
1131     }
1132 
1133     fn menu_checked_list(&self) -> Vec<Vec<Option<bool>>> {
1134         self.menu_dropdown_checked.clone()
1135     }
1136 
1137     fn take_context_change(&mut self) -> Option<usize> {
1138         self.take_context_change()
1139     }
1140 
1141     fn set_context_selected(&mut self, selected: usize) {
1142         self.set_context_selected(selected);
1143     }
1144 
1145     fn set_center_items(&mut self, center: bool) {
1146         self.center_items = center;
1147     }
1148 
1149     fn get_menu_items_at(&self, px: f32, py: f32) -> Option<(usize, String, Vec<String>, f32, f32, f32, f32)> {
1150         if !self.visible {
1151             return None;
1152         }
1153         for i in 0..self.menus.buttons.len() {
1154             let r = self.menus.item_rect(i);
1155             if px >= r.0 && px < r.0 + r.2 && py >= r.1 && py < r.1 + r.3 {
1156                 let title = self.menus.buttons[i].clone();
1157                 let mut formatted_items = Vec::new();
1158                 if let Some(items) = self.menu_dropdowns.get(i) {
1159                     for (item_idx, item) in items.iter().enumerate() {
1160                         let checked = self.menu_dropdown_checked.get(i)
1161                             .and_then(|menu| menu.get(item_idx))
1162                             .and_then(|&v| v);
1163                         // `context_menu`'s mark convention: a checked item
1164                         // is drawn with the `check` glyph.
1165                         let prefix = match checked {
1166                             Some(true) => crate::widget::context_menu::MARK_CHECK,
1167                             Some(false) => "  ",
1168                             None => "",
1169                         };
1170                         formatted_items.push(format!("{}{}", prefix, item));
1171                     }
1172                 }
1173                 return Some((i, title, formatted_items, r.0, r.1, r.2, r.3));
1174             }
1175         }
1176         None
1177     }
1178 }
1179 
1180 impl PageSelector for MenuBar {
1181     fn selected_page(&self) -> usize {
1182         self.menus.selected.unwrap_or(0)
1183     }
1184 
1185     fn set_selected_page(&mut self, page: usize) {
1186         self.menus.inner_mut().set_selected(Some(page));
1187     }
1188 
1189     fn sidebar_w(&self) -> f32 {
1190         let padding_x = crate::layout::paginator_tab_padding_x();
1191         let margin_x = 5.0;
1192         if self.vertical {
1193             (12.0 + 2.0 * padding_x).max(24.0) + 2.0 * margin_x
1194         } else {
1195             let items = &self.menu_items;
1196             let max_req_w = items.iter()
1197                 .map(|p| p.len() as f32 * 7.5 + 2.0 * padding_x)
1198                 .max_by(|a, b| a.partial_cmp(b).unwrap())
1199                 .unwrap_or(0.0);
1200             max_req_w.max(24.0) + 2.0 * margin_x
1201         }
1202     }
1203 }
1204 
1205 unsafe impl Send for MenuBar {}
1206 unsafe impl Sync for MenuBar {}
1207 
1208 #[cfg(test)]
1209 mod tests {
1210     use super::*;
1211     use crate::context::UiContext;
1212 
1213     fn bar() -> Adapted<MenuBar> {
1214         MenuBar::new(0.0, 0.0, 400.0, 24.0)
1215             .with_title("Test")
1216             .with_item("File", &["New", "Save"])
1217             .with_item("Edit", &["Undo"])
1218     }
1219 
1220     #[test]
1221     fn menu_open_click_and_controller_roundtrip() {
1222         let mut ctx = UiContext::new();
1223         let mb = ctx.insert(bar());
1224         WidgetHost::set_rect(&mut ctx[mb], 0.0, 0.0, 400.0, 24.0);
1225 
1226         // Click the "File" strip button (the strip commits selection on release): the dropdown
1227         // opens, the bar reports focused (conditional focus), and a popover rect exists.
1228         let (bx, by, bw, bh) = ctx[mb].menus.item_rect(0);
1229         assert!(bw > 0.0, "strip laid out");
1230         assert!(ctx.lend_h(mb, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, bx + bw / 2.0, by + bh / 2.0, ctx)).unwrap());
1231         assert!(ctx.lend_h(mb, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Released, bx + bw / 2.0, by + bh / 2.0, ctx)).unwrap());
1232         assert!(MenuController::is_menu_open(&*ctx[mb]), "dropdown open");
1233         assert!(WidgetHost::focused(&ctx[mb], &ctx), "bar holds focus while open");
1234         let (dx, dy, _, _) = WidgetHost::popover_rect(&ctx[mb]).expect("dropdown popover");
1235 
1236         // Click the second item ("Save"): menu_click reports (0, 1) and everything closes.
1237         assert!(ctx.lend_h(mb, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, dx + 10.0, dy + DROPDOWN_ITEM_H * 1.5, ctx)).unwrap());
1238         assert_eq!(MenuController::menu_click(&mut *ctx[mb]), Some((0, 1)));
1239         assert!(!MenuController::is_menu_open(&*ctx[mb]));
1240         assert!(!WidgetHost::focused(&ctx[mb], &ctx), "focus released after the click");
1241 
1242         // The PageSelector capability is reached through the concrete adapter too.
1243         assert!(PageSelector::sidebar_w(&*ctx[mb]) > 0.0);
1244     }
1245 
1246     #[test]
1247     fn hidden_menubar_reports_no_menu_and_rejects_hits() {
1248         let mut ctx = UiContext::new();
1249         let mb = ctx.insert(bar());
1250         WidgetHost::set_rect(&mut ctx[mb], 0.0, 0.0, 400.0, 24.0);
1251 
1252         WidgetHost::set_visible(&mut ctx[mb], false);
1253         assert!(!MenuController::is_menu_bar(&*ctx[mb]), "hidden bar is not a menu bar");
1254         assert!(!WidgetHost::hit_test(&ctx[mb], 10.0, 10.0, &ctx));
1255         assert!(MenuController::get_menu_items_at(&*ctx[mb], 10.0, 10.0).is_none());
1256     }
1257 }