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 }