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

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

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