1use densa_core::{Color, Point, Rect, Size};
6use densa_draw::Canvas;
7use densa_ui::{
8 AccessAction, AccessNode, AccessRequest, ActionKind, Anchor, Constraints, Cursor, Event,
9 EventCx, Handled, Key, LayoutCx, NamedKey, PaintCx, PointerButton, PointerKind, Role, Style,
10 Widget, WidgetId,
11};
12
13use crate::paint::outline;
14use crate::text::Text;
15
16const MENU_PADDING: f32 = 4.0;
18const ROW_PADDING_Y: f32 = 4.0;
20const GUTTER: f32 = 22.0;
22const SHORTCUT_GAP: f32 = 28.0;
24const ARROW: f32 = 18.0;
26const SEPARATOR: f32 = 9.0;
28const TITLE_PADDING_X: f32 = 10.0;
30const TITLE_PADDING_Y: f32 = 5.0;
31
32#[derive(Debug, Clone, PartialEq)]
34pub struct MenuItem {
35 kind: ItemKind,
36}
37
38#[derive(Debug, Clone, PartialEq)]
39enum ItemKind {
40 Command {
41 label: String,
42 command: u32,
43 shortcut: Option<String>,
44 enabled: bool,
45 checked: Option<bool>,
46 },
47 Submenu {
48 label: String,
49 items: Vec<MenuItem>,
50 enabled: bool,
51 },
52 Separator,
53}
54
55impl MenuItem {
56 pub fn new(label: impl Into<String>, command: u32) -> Self {
58 Self {
59 kind: ItemKind::Command {
60 label: label.into(),
61 command,
62 shortcut: None,
63 enabled: true,
64 checked: None,
65 },
66 }
67 }
68
69 pub fn submenu(label: impl Into<String>, items: impl IntoIterator<Item = MenuItem>) -> Self {
71 Self {
72 kind: ItemKind::Submenu {
73 label: label.into(),
74 items: items.into_iter().collect(),
75 enabled: true,
76 },
77 }
78 }
79
80 pub fn separator() -> Self {
82 Self {
83 kind: ItemKind::Separator,
84 }
85 }
86
87 #[must_use]
90 pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
91 if let ItemKind::Command { shortcut: s, .. } = &mut self.kind {
92 *s = Some(shortcut.into());
93 }
94 self
95 }
96
97 #[must_use]
100 pub fn enabled(mut self, enabled: bool) -> Self {
101 match &mut self.kind {
102 ItemKind::Command { enabled: e, .. } | ItemKind::Submenu { enabled: e, .. } => {
103 *e = enabled;
104 }
105 ItemKind::Separator => {}
106 }
107 self
108 }
109
110 #[must_use]
113 pub fn checked(mut self, checked: bool) -> Self {
114 if let ItemKind::Command { checked: c, .. } = &mut self.kind {
115 *c = Some(checked);
116 }
117 self
118 }
119
120 fn label(&self) -> &str {
121 match &self.kind {
122 ItemKind::Command { label, .. } | ItemKind::Submenu { label, .. } => label,
123 ItemKind::Separator => "",
124 }
125 }
126
127 fn is_enabled(&self) -> bool {
128 match &self.kind {
129 ItemKind::Command { enabled, .. } | ItemKind::Submenu { enabled, .. } => *enabled,
130 ItemKind::Separator => false,
131 }
132 }
133}
134
135#[derive(Debug)]
137struct Row {
138 item: MenuItem,
139 label: Text,
140 shortcut: Option<Text>,
141 top: f32,
143 bottom: f32,
144}
145
146#[derive(Debug)]
157pub struct Menu {
158 rows: Vec<Row>,
159 check: Text,
160 arrow: Text,
161 highlighted: Option<usize>,
162 open: Option<(usize, WidgetId)>,
164 from_keyboard: bool,
166 nested: bool,
168 entered: bool,
170 last_pointer: Option<Point>,
172}
173
174impl Menu {
175 pub fn new(items: impl IntoIterator<Item = MenuItem>) -> Self {
177 let rows = items
178 .into_iter()
179 .map(|item| Row {
180 label: Text::new(item.label()),
181 shortcut: match &item.kind {
182 ItemKind::Command {
183 shortcut: Some(s), ..
184 } => Some(Text::new(s.as_str())),
185 _ => None,
186 },
187 item,
188 top: 0.0,
189 bottom: 0.0,
190 })
191 .collect();
192 Self {
193 rows,
194 check: Text::new("\u{2713}"),
195 arrow: Text::new("\u{203A}"),
196 highlighted: None,
197 open: None,
198 from_keyboard: false,
199 nested: false,
200 entered: false,
201 last_pointer: None,
202 }
203 }
204
205 pub fn style() -> Style {
207 Style::column()
208 }
209
210 #[must_use]
212 pub fn highlighting_first(mut self) -> Self {
213 self.from_keyboard = true;
214 self.highlighted = self.next_enabled(None, true);
215 self
216 }
217
218 pub fn highlighted(&self) -> Option<usize> {
220 self.highlighted
221 }
222
223 fn next_enabled(&self, from: Option<usize>, forward: bool) -> Option<usize> {
226 let count = self.rows.len();
227 if count == 0 {
228 return None;
229 }
230 let start = match (from, forward) {
231 (Some(at), true) => at + 1,
232 (Some(at), false) => at + count - 1,
233 (None, true) => 0,
234 (None, false) => count - 1,
235 };
236 (0..count)
237 .map(|step| {
238 if forward {
239 (start + step) % count
240 } else {
241 (start + count - step % count) % count
242 }
243 })
244 .find(|&index| self.rows[index].item.is_enabled())
245 }
246
247 fn row_at(&self, rect: Rect, at: Point) -> Option<usize> {
248 if !rect.contains(at) {
249 return None;
250 }
251 let y = at.y - rect.top();
252 self.rows
253 .iter()
254 .position(|row| y >= row.top && y < row.bottom)
255 }
256
257 fn row_rect(&self, rect: Rect, index: usize) -> Rect {
258 let row = &self.rows[index];
259 Rect::from_edges(
260 rect.left(),
261 rect.top() + row.top,
262 rect.right(),
263 rect.top() + row.bottom,
264 )
265 }
266
267 fn highlight(&mut self, cx: &mut EventCx<'_>, index: Option<usize>, open_submenu: bool) {
270 if self.highlighted != index {
271 self.highlighted = index;
272 cx.request_paint();
273 }
274 if let Some((row, id)) = self.open {
275 if Some(row) != index || !cx.is_popup_open(id) {
276 cx.close_popup(id);
277 self.open = None;
278 }
279 }
280 if open_submenu {
281 if let Some(index) = index {
282 self.open_submenu(cx, index, false);
283 }
284 }
285 }
286
287 fn open_submenu(&mut self, cx: &mut EventCx<'_>, index: usize, from_keyboard: bool) -> bool {
289 if self
290 .open
291 .is_some_and(|(row, id)| row == index && cx.is_popup_open(id))
292 {
293 return true;
294 }
295 let ItemKind::Submenu { items, enabled, .. } = &self.rows[index].item.kind else {
296 return false;
297 };
298 if !*enabled {
299 return false;
300 }
301 let mut menu = Menu::new(items.iter().cloned());
302 menu.nested = true;
303 if from_keyboard {
304 menu = menu.highlighting_first();
305 }
306 let anchor = Anchor::beside(self.row_rect(cx.rect(), index));
307 match cx.open_popup(menu, anchor, Menu::style()) {
308 Ok(id) => {
309 self.open = Some((index, id));
310 true
311 }
312 Err(_) => false,
313 }
314 }
315
316 fn choose(&mut self, cx: &mut EventCx<'_>, index: usize, from_keyboard: bool) {
318 match &self.rows[index].item.kind {
319 ItemKind::Command {
320 command, enabled, ..
321 } if *enabled => {
322 cx.emit(ActionKind::Command(*command));
323 cx.close_popups();
324 }
325 ItemKind::Submenu { .. } => {
326 self.open_submenu(cx, index, from_keyboard);
327 }
328 _ => {}
329 }
330 }
331
332 fn key(&mut self, cx: &mut EventCx<'_>, key: NamedKey) -> Handled {
333 match key {
334 NamedKey::ArrowDown | NamedKey::ArrowUp => {
335 let next = self.next_enabled(self.highlighted, key == NamedKey::ArrowDown);
336 self.highlight(cx, next, false);
337 Handled::Yes
338 }
339 NamedKey::Home | NamedKey::End => {
340 let next = self.next_enabled(None, key == NamedKey::Home);
341 self.highlight(cx, next, false);
342 Handled::Yes
343 }
344 NamedKey::Enter | NamedKey::Space => {
345 if let Some(index) = self.highlighted {
346 self.choose(cx, index, true);
347 }
348 Handled::Yes
349 }
350 NamedKey::ArrowRight => {
351 let open = self.open.is_some_and(|(_, id)| cx.is_popup_open(id));
353 match self.highlighted {
354 Some(index) if !open => self.open_submenu(cx, index, true).into(),
355 _ => Handled::No,
356 }
357 }
358 NamedKey::ArrowLeft if self.nested => {
359 cx.close_popup(cx.id());
360 Handled::Yes
361 }
362 NamedKey::Escape => {
363 cx.close_popup(cx.id());
364 Handled::Yes
365 }
366 NamedKey::ArrowLeft => Handled::No,
368 _ => Handled::Yes,
370 }
371 }
372
373 fn heading_to_submenu(&self, cx: &EventCx<'_>, at: Point) -> bool {
376 let (Some((_, id)), Some(last)) = (self.open, self.last_pointer) else {
377 return false;
378 };
379 let Some(submenu) = cx.rect_of(id).filter(|_| cx.is_popup_open(id)) else {
380 return false;
381 };
382 let toward = if submenu.left() >= cx.rect().right() - 1.0 {
383 at.x > last.x
384 } else {
385 at.x < last.x
386 };
387 toward && at.y >= submenu.top() && at.y < submenu.bottom()
388 }
389}
390
391fn starts_with_key(label: &str, typed: &str) -> bool {
393 match (label.chars().next(), typed.chars().next()) {
394 (Some(first), Some(key)) => first.to_lowercase().eq(key.to_lowercase()),
395 _ => false,
396 }
397}
398
399fn shaped(text: &mut Text, cx: &mut LayoutCx<'_>, color: Color) -> Size {
401 text.fit(cx, f32::INFINITY, color);
402 text.size()
403}
404
405impl Widget for Menu {
406 fn measure(&mut self, cx: &mut LayoutCx<'_>, _: Constraints) -> Size {
407 let theme = cx.theme().clone();
408 let check = shaped(&mut self.check, cx, theme.text);
409 let arrow = shaped(&mut self.arrow, cx, theme.text_dim);
410 let (mut label_width, mut shortcut_width, mut submenus) = (0.0_f32, 0.0_f32, false);
411 let mut y = cx.px(MENU_PADDING);
412 let line = check.height.max(arrow.height);
413 for row in &mut self.rows {
414 let color = if row.item.is_enabled() {
415 theme.text
416 } else {
417 theme.text_dim
418 };
419 row.top = y;
420 if matches!(row.item.kind, ItemKind::Separator) {
421 y += cx.px(SEPARATOR).round();
422 row.bottom = y;
423 continue;
424 }
425 let label = shaped(&mut row.label, cx, color);
426 label_width = label_width.max(label.width);
427 if let Some(shortcut) = &mut row.shortcut {
428 shortcut_width = shortcut_width.max(shaped(shortcut, cx, theme.text_dim).width);
429 }
430 submenus |= matches!(row.item.kind, ItemKind::Submenu { .. });
431 y += (label.height.max(line) + 2.0 * cx.px(ROW_PADDING_Y)).round();
432 row.bottom = y;
433 }
434 let shortcuts = if shortcut_width > 0.0 {
435 cx.px(SHORTCUT_GAP) + shortcut_width
436 } else {
437 0.0
438 };
439 let arrows = if submenus { cx.px(ARROW) } else { 0.0 };
440 let width = 2.0 * cx.px(MENU_PADDING)
441 + cx.px(GUTTER)
442 + label_width
443 + shortcuts
444 + arrows
445 + cx.px(GUTTER) / 2.0;
446 Size::new(width.ceil(), (y + cx.px(MENU_PADDING)).ceil())
447 }
448
449 fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
450 let theme = cx.theme();
451 let rect = cx.rect();
452 for (offset, alpha) in [(1.0, 0.22), (2.0, 0.14), (3.0, 0.07)] {
454 let d = cx.px(offset).round();
455 let shadow = Rect::from_edges(
456 rect.left() + d,
457 rect.top() + d,
458 rect.right() + d,
459 rect.bottom() + d,
460 );
461 canvas.fill_rect(shadow, Color::BLACK.with_alpha(alpha));
462 }
463 canvas.fill_rect(rect, theme.raised);
464 outline(canvas, rect, cx.px(1.0).round(), theme.border);
465 let padding = cx.px(MENU_PADDING);
466 let (left, right) = (rect.left() + padding, rect.right() - padding);
467 for (index, row) in self.rows.iter().enumerate() {
468 let (top, bottom) = (rect.top() + row.top, rect.top() + row.bottom);
469 if matches!(row.item.kind, ItemKind::Separator) {
470 let middle = ((top + bottom) / 2.0).round();
471 let line = cx.px(1.0).round();
472 canvas.fill_rect(
473 Rect::from_edges(left, middle, right, middle + line),
474 theme.border,
475 );
476 continue;
477 }
478 let open = self
479 .open
480 .is_some_and(|(row, id)| row == index && cx.is_popup_open(id));
481 if self.highlighted == Some(index) || open {
482 canvas.fill_rect(Rect::from_edges(left, top, right, bottom), theme.hover);
483 }
484 let text_top = |height: f32| (top + (bottom - top - height) / 2.0).round();
485 if let ItemKind::Command {
486 checked: Some(true),
487 ..
488 } = row.item.kind
489 {
490 let size = self.check.size();
491 let x = (left + (cx.px(GUTTER) - size.width) / 2.0).round();
492 self.check
493 .paint(canvas, Point::new(x, text_top(size.height)));
494 }
495 let label = row.label.size();
496 let x = (left + cx.px(GUTTER)).round();
497 row.label
498 .paint(canvas, Point::new(x, text_top(label.height)));
499 let mut end = right - cx.px(GUTTER) / 2.0;
500 if matches!(row.item.kind, ItemKind::Submenu { .. }) {
501 let size = self.arrow.size();
502 let at = Point::new((end - size.width).round(), text_top(size.height));
503 self.arrow.paint(canvas, at);
504 end -= cx.px(ARROW);
505 }
506 if let Some(shortcut) = &row.shortcut {
507 let size = shortcut.size();
508 let at = Point::new((end - size.width).round(), text_top(size.height));
509 shortcut.paint(canvas, at);
510 }
511 }
512 }
513
514 fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
515 if let Some((_, id)) = self.open {
516 if !cx.is_popup_open(id) {
517 self.open = None;
518 }
519 }
520 match event {
521 Event::Pointer(pointer) => match pointer.kind {
522 PointerKind::Enter | PointerKind::Move => {
523 cx.set_cursor(Some(Cursor::Default));
524 if self.nested && !self.entered {
525 self.entered = true;
528 cx.request_focus();
529 }
530 let at = pointer.position;
531 let heading = self.heading_to_submenu(cx, at);
532 self.last_pointer = Some(at);
533 let row = self
534 .row_at(cx.rect(), at)
535 .filter(|&index| self.rows[index].item.is_enabled());
536 if !heading && (row.is_some() || self.open.is_none()) {
539 self.highlight(cx, row, true);
540 }
541 Handled::Yes
542 }
543 PointerKind::Leave => {
544 self.last_pointer = None;
545 if self.open.is_none() {
546 self.highlight(cx, None, false);
547 }
548 Handled::Yes
549 }
550 PointerKind::Up(PointerButton::Primary) => {
551 if let Some(index) = self.row_at(cx.rect(), pointer.position) {
552 self.choose(cx, index, false);
553 }
554 Handled::Yes
555 }
556 PointerKind::Down(_) | PointerKind::Up(_) | PointerKind::Scroll(_) => Handled::Yes,
557 _ => Handled::No,
558 },
559 Event::Key(key) if key.pressed => match key.key {
560 Key::Named(named) => self.key(cx, named),
561 Key::Character(_) if key.modifiers.control() || key.modifiers.alt() => Handled::Yes,
563 Key::Character(typed) => {
564 let count = self.rows.len();
566 let start = self.highlighted.map_or(0, |at| at + 1);
567 let found = (0..count)
568 .map(|step| (start + step) % count)
569 .find(|&index| {
570 let row = &self.rows[index];
571 row.item.is_enabled() && starts_with_key(row.item.label(), typed)
572 });
573 if found.is_some() {
574 self.highlight(cx, found, false);
575 }
576 Handled::Yes
577 }
578 _ => Handled::Yes,
579 },
580 Event::FocusGained | Event::FocusLost => {
581 cx.request_paint();
582 Handled::Yes
583 }
584 _ => Handled::No,
585 }
586 }
587
588 fn accepts_focus(&self) -> bool {
591 !self.nested || self.from_keyboard || self.entered
592 }
593
594 fn accessibility(&self, cx: &PaintCx<'_>, node: &mut AccessNode) {
595 node.set_role(Role::Menu);
596 let rect = cx.rect();
597 for (index, row) in self.rows.iter().enumerate() {
598 let role = match row.item.kind {
599 ItemKind::Separator => continue,
600 ItemKind::Command {
601 checked: Some(_), ..
602 } => Role::MenuItemCheckBox,
603 _ => Role::MenuItem,
604 };
605 let mut item = AccessNode::new(role);
606 item.set_label(row.item.label());
607 item.set_bounds(self.row_rect(rect, index));
608 item.set_disabled(!row.item.is_enabled());
609 match &row.item.kind {
610 ItemKind::Command {
611 checked: Some(checked),
612 ..
613 } => item.set_checked(*checked),
614 ItemKind::Submenu { .. } => {
615 let open = self
616 .open
617 .is_some_and(|(at, id)| at == index && cx.is_popup_open(id));
618 item.set_expanded(open);
619 }
620 _ => {}
621 }
622 if row.item.is_enabled() {
623 item.add_action(AccessAction::Click);
624 }
625 node.push_part(index as u32, item);
627 }
628 if let Some(index) = self.highlighted {
629 node.set_focused_part(index as u32);
631 }
632 }
633
634 fn access_action(&mut self, cx: &mut EventCx<'_>, request: &AccessRequest<'_>) -> Handled {
635 let index = request.part.map(|part| part as usize);
636 match (request.action, index) {
637 (AccessAction::Click, Some(index)) if index < self.rows.len() => {
638 self.highlight(cx, Some(index), false);
639 self.choose(cx, index, true);
640 Handled::Yes
641 }
642 _ => Handled::No,
643 }
644 }
645}
646
647#[derive(Debug)]
649struct Title {
650 text: Text,
651 items: Vec<MenuItem>,
652 left: f32,
654 right: f32,
655}
656
657#[derive(Debug, Default)]
662pub struct MenuBar {
663 titles: Vec<Title>,
664 highlighted: Option<usize>,
665 open: Option<(usize, WidgetId)>,
667}
668
669impl MenuBar {
670 pub fn new() -> Self {
672 Self::default()
673 }
674
675 #[must_use]
677 pub fn with_menu(
678 mut self,
679 title: impl Into<String>,
680 items: impl IntoIterator<Item = MenuItem>,
681 ) -> Self {
682 self.titles.push(Title {
683 text: Text::new(title),
684 items: items.into_iter().collect(),
685 left: 0.0,
686 right: 0.0,
687 });
688 self
689 }
690
691 pub fn style() -> Style {
693 Style::row().shrink(0.0)
694 }
695
696 pub fn activate(&mut self, cx: &mut EventCx<'_>) {
699 if !self.titles.is_empty() {
700 self.highlighted = Some(0);
701 }
702 cx.request_focus();
703 cx.request_paint();
704 }
705
706 pub fn set_items(&mut self, index: usize, items: impl IntoIterator<Item = MenuItem>) -> bool {
709 match self.titles.get_mut(index) {
710 Some(title) => {
711 title.items = items.into_iter().collect();
712 true
713 }
714 None => false,
715 }
716 }
717
718 pub fn open_menu(&self) -> Option<usize> {
720 self.open.map(|(index, _)| index)
721 }
722
723 fn title_at(&self, rect: Rect, at: Point) -> Option<usize> {
724 if !rect.contains(at) {
725 return None;
726 }
727 let x = at.x - rect.left();
728 self.titles.iter().position(|t| x >= t.left && x < t.right)
729 }
730
731 fn title_rect(&self, rect: Rect, index: usize) -> Rect {
732 let title = &self.titles[index];
733 Rect::from_edges(
734 rect.left() + title.left,
735 rect.top(),
736 rect.left() + title.right,
737 rect.bottom(),
738 )
739 }
740
741 fn open(&mut self, cx: &mut EventCx<'_>, index: usize, from_keyboard: bool) {
743 if let Some((_, id)) = self.open.take() {
744 cx.close_popup(id);
745 }
746 let mut menu = Menu::new(self.titles[index].items.iter().cloned());
747 if from_keyboard {
748 menu = menu.highlighting_first();
749 }
750 let anchor = Anchor::below(self.title_rect(cx.rect(), index));
751 if let Ok(id) = cx.open_popup(menu, anchor, Menu::style()) {
752 self.open = Some((index, id));
753 }
754 self.highlighted = Some(index);
755 cx.request_paint();
756 }
757
758 fn close(&mut self, cx: &mut EventCx<'_>) {
759 if let Some((_, id)) = self.open.take() {
760 cx.close_popup(id);
761 }
762 cx.request_paint();
763 }
764
765 fn neighbour(&self, from: usize, step: isize) -> usize {
767 let count = self.titles.len() as isize;
769 (from as isize + step).rem_euclid(count) as usize
770 }
771}
772
773impl Widget for MenuBar {
774 fn measure(&mut self, cx: &mut LayoutCx<'_>, _: Constraints) -> Size {
775 let color = cx.theme().text;
776 let padding = cx.px(TITLE_PADDING_X).round();
777 let (mut x, mut height) = (0.0_f32, 0.0_f32);
778 for title in &mut self.titles {
779 let size = shaped(&mut title.text, cx, color);
780 title.left = x;
781 x += (size.width + 2.0 * padding).round();
782 title.right = x;
783 height = height.max(size.height);
784 }
785 Size::new(x.ceil(), (height + 2.0 * cx.px(TITLE_PADDING_Y)).ceil())
786 }
787
788 fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
789 let theme = cx.theme();
790 let rect = cx.rect();
791 canvas.fill_rect(rect, theme.surface);
792 let line = cx.px(1.0).round();
793 canvas.fill_rect(
794 Rect::from_edges(
795 rect.left(),
796 rect.bottom() - line,
797 rect.right(),
798 rect.bottom(),
799 ),
800 theme.border,
801 );
802 let open = self
803 .open
804 .filter(|(_, id)| cx.is_popup_open(*id))
805 .map(|(index, _)| index);
806 for (index, title) in self.titles.iter().enumerate() {
807 let area = self.title_rect(rect, index);
808 if open == Some(index) {
809 canvas.fill_rect(area, theme.pressed);
810 } else if self.highlighted == Some(index) && (cx.is_hovered() || cx.is_focused()) {
811 canvas.fill_rect(area, theme.hover);
812 }
813 let size = title.text.size();
814 let at = crate::paint::centered(area, size.width, size.height);
815 title.text.paint(canvas, at);
816 }
817 }
818
819 fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
820 if let Some((_, id)) = self.open {
821 if !cx.is_popup_open(id) {
822 self.open = None;
823 }
824 }
825 match event {
826 Event::Pointer(pointer) => match pointer.kind {
827 PointerKind::Enter | PointerKind::Move => {
828 let at = self.title_at(cx.rect(), pointer.position);
829 match (at, self.open) {
830 (Some(index), Some((open, _))) if index != open => {
831 self.open(cx, index, false);
832 }
833 (None, Some(_)) => {}
834 _ => {
835 if self.highlighted != at {
836 self.highlighted = at;
837 cx.request_paint();
838 }
839 }
840 }
841 Handled::Yes
842 }
843 PointerKind::Leave => {
844 if self.open.is_none() && !cx.is_focused() {
845 self.highlighted = None;
846 cx.request_paint();
847 }
848 Handled::Yes
849 }
850 PointerKind::Down(PointerButton::Primary) => {
851 match (self.title_at(cx.rect(), pointer.position), self.open) {
852 (Some(index), Some((open, _))) if index == open => self.close(cx),
853 (Some(index), _) => self.open(cx, index, false),
854 (None, _) => self.close(cx),
855 }
856 Handled::Yes
857 }
858 PointerKind::Down(_) | PointerKind::Up(_) => Handled::Yes,
859 _ => Handled::No,
860 },
861 Event::Key(key) if key.pressed => {
862 let Key::Named(named) = key.key else {
863 return Handled::No;
864 };
865 let Some(at) = self.open.map(|(index, _)| index).or(self.highlighted) else {
866 return Handled::No;
867 };
868 match named {
869 NamedKey::ArrowLeft | NamedKey::ArrowRight => {
870 let step = if named == NamedKey::ArrowLeft { -1 } else { 1 };
871 let next = self.neighbour(at, step);
872 if self.open.is_some() {
873 self.open(cx, next, true);
874 } else {
875 self.highlighted = Some(next);
876 cx.request_paint();
877 }
878 Handled::Yes
879 }
880 NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space => {
881 self.open(cx, at, true);
882 Handled::Yes
883 }
884 NamedKey::Escape => {
885 self.highlighted = None;
886 cx.release_focus();
887 cx.request_paint();
888 Handled::Yes
889 }
890 _ => Handled::No,
891 }
892 }
893 Event::FocusLost => {
894 if self.open.is_none() {
895 self.highlighted = None;
896 }
897 cx.request_paint();
898 Handled::Yes
899 }
900 _ => Handled::No,
901 }
902 }
903
904 fn accessibility(&self, cx: &PaintCx<'_>, node: &mut AccessNode) {
905 node.set_role(Role::MenuBar);
906 let rect = cx.rect();
907 let open = self
908 .open
909 .filter(|(_, id)| cx.is_popup_open(*id))
910 .map(|(index, _)| index);
911 for (index, title) in self.titles.iter().enumerate() {
912 let mut item = AccessNode::new(Role::MenuItem);
913 item.set_label(title.text.as_str());
914 item.set_bounds(self.title_rect(rect, index));
915 item.set_expanded(open == Some(index));
916 item.add_action(AccessAction::Click);
917 node.push_part(index as u32, item);
919 }
920 if let Some(index) = self.highlighted.filter(|_| cx.is_focused()) {
921 node.set_focused_part(index as u32);
923 }
924 }
925
926 fn access_action(&mut self, cx: &mut EventCx<'_>, request: &AccessRequest<'_>) -> Handled {
927 let index = request.part.map(|part| part as usize);
928 match (request.action, index) {
929 (AccessAction::Click, Some(index)) if index < self.titles.len() => {
930 let open = self
931 .open
932 .is_some_and(|(at, id)| at == index && cx.is_popup_open(id));
933 if open {
934 self.close(cx);
935 } else {
936 self.open(cx, index, true);
937 }
938 Handled::Yes
939 }
940 _ => Handled::No,
941 }
942 }
943}