1use retroglyph_core::color::Style;
3use retroglyph_core::event::{Event, KeyCode};
4use retroglyph_core::grid::{Pos, Rect, Size};
5
6use super::strip::Strip;
7use super::{Menu, MenuRow};
8use crate::Surface;
9use crate::align::Align;
10use crate::draw::fill_rect;
11use crate::interact::Sense;
12use crate::layout::{Side, anchored_rect};
13use crate::state::MenuBarState;
14use crate::text::draw_clipped;
15use crate::theme::Theme;
16use crate::ui::Ui;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct MenuBarIds<Id> {
26 pub label: Id,
28 pub popup_content: Id,
30 pub popup_backdrop: Id,
32}
33
34#[derive(Clone, Copy, Debug)]
89pub struct MenuBar<'a> {
90 titles: &'a [&'a str],
91 rows: &'a [&'a [MenuRow<'a>]],
92 style: Style,
93 open_style: Style,
94 column_spacing: u16,
95 theme: Theme,
96}
97
98impl<'a> MenuBar<'a> {
99 #[must_use]
102 pub fn new(titles: &'a [&'a str], rows: &'a [&'a [MenuRow<'a>]]) -> Self {
103 Self {
104 titles,
105 rows,
106 style: Style::new(),
107 open_style: Style::new(),
108 column_spacing: 1,
109 theme: Theme::DARK,
110 }
111 .theme(Theme::DARK)
112 }
113
114 #[must_use]
116 pub const fn style(mut self, style: Style) -> Self {
117 self.style = style;
118 self
119 }
120
121 #[must_use]
123 pub const fn open_style(mut self, style: Style) -> Self {
124 self.open_style = style;
125 self
126 }
127
128 #[must_use]
130 pub const fn column_spacing(mut self, spacing: u16) -> Self {
131 self.column_spacing = spacing;
132 self
133 }
134
135 #[must_use]
143 pub fn theme(mut self, theme: Theme) -> Self {
144 self.style = Style::new().fg(theme.dim).bg(theme.panel_bg);
145 self.open_style = Style::new().fg(theme.bg).bg(theme.accent);
146 self.theme = theme;
147 self
148 }
149}
150
151impl MenuBar<'_> {
152 pub fn draw(&self, surface: &mut Surface<'_>, open: Option<usize>) {
155 let area = surface.area();
156 if area.width() == 0 || area.height() == 0 {
157 return;
158 }
159
160 let strip = Strip::new(self.titles, self.column_spacing);
165 for (index, abs_x, text_width) in strip.columns(area) {
166 let x = abs_x - area.left();
167 let title = self.titles[index];
168 let style = if Some(index) == open {
169 self.open_style
170 } else {
171 self.style
172 };
173 if Some(index) == open && text_width > 0 {
174 fill_rect(
175 surface,
176 Rect::new(x, 0, text_width, 1),
177 ' ',
178 Style::new().bg(style.background()),
179 );
180 }
181 let _ = draw_clipped(surface, (x, 0), text_width, title, Align::Left, style);
182 }
183 }
184
185 fn label_at(&self, area: Rect, pos: Pos) -> Option<usize> {
188 Strip::new(self.titles, self.column_spacing).index_at(area, pos)
189 }
190
191 fn label_rect(&self, area: Rect, label: usize) -> Rect {
196 Strip::new(self.titles, self.column_spacing)
197 .columns(area)
198 .find(|&(index, ..)| index == label)
199 .map_or(area, |(_, x, text_width)| {
200 Rect::new(x, area.top(), text_width.max(1), area.height())
201 })
202 }
203
204 fn rows_of(&self, label: usize) -> (usize, impl Fn(usize) -> bool + '_) {
207 let rows = self.rows[label];
208 (rows.len(), move |i: usize| rows[i].is_selectable())
209 }
210
211 #[must_use]
241 pub fn show<Id: Copy + PartialEq>(
242 &self,
243 ui: &mut Ui<'_, '_, Id>,
244 screen: Rect,
245 bar_area: Rect,
246 popup_size: Size,
247 ids: MenuBarIds<Id>,
248 state: &mut MenuBarState,
249 ) -> Option<(usize, usize)> {
250 let mut activated = None;
251
252 if let Some(open) = state.open_label() {
253 let anchor = self.label_rect(bar_area, open);
254 let popup_area = anchored_rect(anchor, popup_size, Side::Below, screen);
255 if let Some(row) = Menu::new(self.rows[open]).theme(self.theme).show(
256 ui,
257 screen,
258 popup_area,
259 ids.popup_content,
260 ids.popup_backdrop,
261 state.menu_mut(),
262 ) {
263 activated = Some((open, row));
264 }
265 }
266
267 let (response, mut surface) = ui.region(bar_area, ids.label, Sense::click() | Sense::HOVER);
268 self.draw(&mut surface, state.open_label());
269
270 let hovered_label = response
271 .pointer_pos()
272 .and_then(|pos| self.label_at(bar_area, pos));
273
274 if response.clicked()
275 && let Some(label) = hovered_label
276 {
277 let (len, selectable) = self.rows_of(label);
278 state.toggle(label, len, selectable);
279 }
280
281 match hovered_label {
282 Some(label) => {
283 let (len, selectable) = self.rows_of(label);
284 state.hover(Some(label), len, selectable);
285 }
286 None => state.hover(None, 0, |_| false),
287 }
288
289 activated
290 }
291
292 pub fn handle_event(&self, event: &Event, state: &mut MenuBarState) -> Option<(usize, usize)> {
301 let open = state.open_label()?;
302
303 if let Event::Key(key) = event
304 && key.is_down()
305 {
306 let delta = match key.code {
307 KeyCode::Left => -1,
308 KeyCode::Right => 1,
309 _ => 0,
310 };
311 if delta != 0 {
312 self.step_open_label(state, open, delta);
313 return None;
314 }
315 }
316
317 let (len, selectable) = self.rows_of(open);
318 let row = state.menu_mut().handle_event(event, len, selectable)?;
319 Some((open, row))
320 }
321
322 fn step_open_label(&self, state: &mut MenuBarState, current: usize, delta: i32) {
326 let Ok(len) = i32::try_from(self.titles.len()) else {
327 return; };
329 if len == 0 {
330 return;
331 }
332 let Ok(current_i32) = i32::try_from(current) else {
333 return;
334 };
335 let next = (current_i32 + delta).clamp(0, len - 1);
336 #[allow(clippy::cast_sign_loss)]
338 let next = next as usize;
339 if next != current {
340 let (len, selectable) = self.rows_of(next);
341 state.open(next, len, selectable);
342 }
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use retroglyph_core::event::{
349 Event, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
350 };
351 use retroglyph_core::grid::Grid;
352
353 use super::*;
354 use crate::interact::Interaction;
355 use crate::widget::Marker;
356
357 const FILE_ROWS: [MenuRow<'static>; 2] = [
358 MenuRow::Item {
359 label: "New",
360 marker: Marker::None,
361 enabled: true,
362 },
363 MenuRow::Item {
364 label: "Open",
365 marker: Marker::None,
366 enabled: true,
367 },
368 ];
369 const EDIT_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
370 label: "Cut",
371 marker: Marker::None,
372 enabled: true,
373 }];
374 const VIEW_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
375 label: "Zoom",
376 marker: Marker::None,
377 enabled: true,
378 }];
379
380 const TITLES: [&str; 3] = ["File", "Edit", "View"];
381
382 fn rows() -> [&'static [MenuRow<'static>]; 3] {
383 [&FILE_ROWS, &EDIT_ROWS, &VIEW_ROWS]
384 }
385
386 #[derive(Clone, Copy, PartialEq, Eq)]
387 enum Id {
388 Bar,
389 PopupContent,
390 PopupBackdrop,
391 }
392
393 fn show(
394 bar: &MenuBar<'_>,
395 interaction: &mut Interaction<Id>,
396 grid: &mut Grid,
397 screen: Rect,
398 state: &mut MenuBarState,
399 ) -> Option<(usize, usize)> {
400 interaction.frame(&mut Surface::new(grid, screen, 0), |ui| {
401 bar.show(
402 ui,
403 screen,
404 Rect::new(0, 0, 20, 1),
405 Size::new(10, 4),
406 MenuBarIds {
407 label: Id::Bar,
408 popup_content: Id::PopupContent,
409 popup_backdrop: Id::PopupBackdrop,
410 },
411 state,
412 )
413 })
414 }
415
416 fn click(interaction: &mut Interaction<Id>, pos: Pos) {
417 let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
418 MouseEventKind::Down(MouseButton::Left),
419 pos,
420 KeyModifiers::NONE,
421 )));
422 let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
423 MouseEventKind::Up(MouseButton::Left),
424 pos,
425 KeyModifiers::NONE,
426 )));
427 }
428
429 fn hover(interaction: &mut Interaction<Id>, pos: Pos) {
430 let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
431 MouseEventKind::Moved,
432 pos,
433 KeyModifiers::NONE,
434 )));
435 }
436
437 #[test]
438 fn draws_every_title_left_to_right() {
439 let rows = rows();
440 let bar = MenuBar::new(&TITLES, &rows);
441 let area = Rect::new(0, 0, 20, 1);
442 let mut grid = Grid::new(20, 1);
443 bar.draw(&mut Surface::new(&mut grid, area, 0), None);
444
445 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'F'); assert_eq!(grid[Pos::new(5, 0)].glyph(), 'E');
448 }
449
450 #[test]
451 fn cold_hover_does_not_open_anything() {
452 let rows = rows();
453 let bar = MenuBar::new(&TITLES, &rows);
454 let mut interaction = Interaction::<Id>::new();
455 let screen = Rect::new(0, 0, 20, 10);
456 let mut state = MenuBarState::new();
457
458 let mut grid = Grid::new(20, 10);
459 show(&bar, &mut interaction, &mut grid, screen, &mut state);
460
461 hover(&mut interaction, Pos::new(0, 0)); let mut grid = Grid::new(20, 10);
464 show(&bar, &mut interaction, &mut grid, screen, &mut state);
465
466 assert_eq!(state.open_label(), None);
467 }
468
469 #[test]
470 fn click_opens_the_clicked_label() {
471 let rows = rows();
472 let bar = MenuBar::new(&TITLES, &rows);
473 let mut interaction = Interaction::<Id>::new();
474 let screen = Rect::new(0, 0, 20, 10);
475 let mut state = MenuBarState::new();
476
477 let mut grid = Grid::new(20, 10);
478 show(&bar, &mut interaction, &mut grid, screen, &mut state);
479
480 click(&mut interaction, Pos::new(0, 0)); let mut grid = Grid::new(20, 10);
483 show(&bar, &mut interaction, &mut grid, screen, &mut state);
484
485 assert_eq!(state.open_label(), Some(0));
486 }
487
488 #[test]
491 fn click_past_a_wide_glyph_label_opens_the_next_label() {
492 const WIDE_TITLES: [&str; 2] = ["設定", "次"];
493 const NEXT_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
494 label: "Item",
495 marker: Marker::None,
496 enabled: true,
497 }];
498 let wide_rows: [&[MenuRow<'static>]; 2] = [&NEXT_ROWS, &NEXT_ROWS];
499 let bar = MenuBar::new(&WIDE_TITLES, &wide_rows);
500 let mut interaction = Interaction::<Id>::new();
501 let screen = Rect::new(0, 0, 20, 10);
502 let mut state = MenuBarState::new();
503
504 let mut grid = Grid::new(20, 10);
505 show(&bar, &mut interaction, &mut grid, screen, &mut state);
506
507 click(&mut interaction, Pos::new(5, 0));
509
510 let mut grid = Grid::new(20, 10);
511 show(&bar, &mut interaction, &mut grid, screen, &mut state);
512
513 assert_eq!(state.open_label(), Some(1));
514 }
515
516 #[test]
517 fn click_again_closes_the_same_label() {
518 let rows = rows();
519 let bar = MenuBar::new(&TITLES, &rows);
520 let mut interaction = Interaction::<Id>::new();
521 let screen = Rect::new(0, 0, 20, 10);
522 let mut state = MenuBarState::new();
523 state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
524
525 let mut grid = Grid::new(20, 10);
526 show(&bar, &mut interaction, &mut grid, screen, &mut state);
527
528 click(&mut interaction, Pos::new(0, 0)); let mut grid = Grid::new(20, 10);
531 show(&bar, &mut interaction, &mut grid, screen, &mut state);
532
533 assert_eq!(state.open_label(), None);
534 }
535
536 #[test]
537 fn hovering_another_label_while_one_is_open_switches_to_it() {
538 let rows = rows();
539 let bar = MenuBar::new(&TITLES, &rows);
540 let mut interaction = Interaction::<Id>::new();
541 let screen = Rect::new(0, 0, 20, 10);
542 let mut state = MenuBarState::new();
543 state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
544
545 let mut grid = Grid::new(20, 10);
546 show(&bar, &mut interaction, &mut grid, screen, &mut state);
547
548 hover(&mut interaction, Pos::new(5, 0)); let mut grid = Grid::new(20, 10);
551 show(&bar, &mut interaction, &mut grid, screen, &mut state);
552
553 assert_eq!(state.open_label(), Some(1));
554 }
555
556 #[test]
557 fn clicking_a_row_activates_it_and_closes_the_bar() {
558 let rows = rows();
559 let bar = MenuBar::new(&TITLES, &rows);
560 let mut interaction = Interaction::<Id>::new();
561 let screen = Rect::new(0, 0, 20, 10);
562 let mut state = MenuBarState::new();
563 state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
564
565 let mut grid = Grid::new(20, 10);
567 show(&bar, &mut interaction, &mut grid, screen, &mut state);
568
569 click(&mut interaction, Pos::new(1, 2));
571
572 let mut grid = Grid::new(20, 10);
573 let activated = show(&bar, &mut interaction, &mut grid, screen, &mut state);
574
575 assert_eq!(activated, Some((0, 1)));
576 assert_eq!(state.open_label(), None);
577 }
578
579 #[test]
580 fn clicking_outside_the_bar_and_popup_closes_without_activating() {
581 let rows = rows();
582 let bar = MenuBar::new(&TITLES, &rows);
583 let mut interaction = Interaction::<Id>::new();
584 let screen = Rect::new(0, 0, 20, 10);
585 let mut state = MenuBarState::new();
586 state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
587
588 let mut grid = Grid::new(20, 10);
589 show(&bar, &mut interaction, &mut grid, screen, &mut state);
590
591 click(&mut interaction, Pos::new(19, 9)); let mut grid = Grid::new(20, 10);
594 let activated = show(&bar, &mut interaction, &mut grid, screen, &mut state);
595
596 assert_eq!(activated, None);
597 assert_eq!(state.open_label(), None);
598 }
599
600 #[test]
601 fn right_arrow_moves_to_the_next_label() {
602 let rows = rows();
603 let bar = MenuBar::new(&TITLES, &rows);
604 let mut state = MenuBarState::new();
605 state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
606
607 let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
608 let activated = bar.handle_event(&right, &mut state);
609
610 assert_eq!(activated, None);
611 assert_eq!(state.open_label(), Some(1));
612 }
613
614 #[test]
615 fn left_right_clamp_at_the_ends_without_wrapping() {
616 let rows = rows();
617 let bar = MenuBar::new(&TITLES, &rows);
618 let mut state = MenuBarState::new();
619 state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
620
621 let left = Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
622 bar.handle_event(&left, &mut state);
623 assert_eq!(state.open_label(), Some(0)); }
625
626 #[test]
627 fn left_right_are_a_no_op_while_nothing_is_open() {
628 let rows = rows();
629 let bar = MenuBar::new(&TITLES, &rows);
630 let mut state = MenuBarState::new();
631
632 let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
633 bar.handle_event(&right, &mut state);
634 assert_eq!(state.open_label(), None); }
636
637 #[test]
638 fn arrow_down_then_enter_navigates_and_activates_the_open_popup() {
639 let rows = rows();
640 let bar = MenuBar::new(&TITLES, &rows);
641 let mut state = MenuBarState::new();
642 state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
643
644 let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
645 bar.handle_event(&down, &mut state);
646 assert_eq!(state.menu().selected(), Some(1)); let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
649 let activated = bar.handle_event(&enter, &mut state);
650 assert_eq!(activated, Some((0, 1)));
651 assert_eq!(state.open_label(), None);
652 }
653
654 #[test]
661 fn a_stationary_pointer_does_not_fight_a_keyboard_driven_switch() {
662 let rows = rows();
663 let bar = MenuBar::new(&TITLES, &rows);
664 let mut interaction = Interaction::<Id>::new();
665 let screen = Rect::new(0, 0, 20, 10);
666 let mut state = MenuBarState::new();
667
668 hover(&mut interaction, Pos::new(0, 0));
670 let mut grid = Grid::new(20, 10);
671 show(&bar, &mut interaction, &mut grid, screen, &mut state);
672
673 click(&mut interaction, Pos::new(0, 0));
676 let mut grid = Grid::new(20, 10);
677 show(&bar, &mut interaction, &mut grid, screen, &mut state);
678 assert_eq!(state.open_label(), Some(0));
679
680 let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
682 bar.handle_event(&right, &mut state);
683 assert_eq!(state.open_label(), Some(1));
684
685 let mut grid = Grid::new(20, 10);
688 show(&bar, &mut interaction, &mut grid, screen, &mut state);
689 assert_eq!(state.open_label(), Some(1));
690 }
691}