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 }