use retroglyph_core::color::Style;
use retroglyph_core::event::{Event, KeyCode};
use retroglyph_core::grid::{Pos, Rect, Size};
use super::strip::Strip;
use super::{Menu, MenuRow};
use crate::Surface;
use crate::align::Align;
use crate::draw::fill_rect;
use crate::interact::Sense;
use crate::layout::{Side, anchored_rect};
use crate::state::MenuBarState;
use crate::text::draw_clipped;
use crate::theme::Theme;
use crate::ui::Ui;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MenuBarIds<Id> {
pub label: Id,
pub popup_content: Id,
pub popup_backdrop: Id,
}
#[derive(Clone, Copy, Debug)]
pub struct MenuBar<'a> {
titles: &'a [&'a str],
rows: &'a [&'a [MenuRow<'a>]],
style: Style,
open_style: Style,
column_spacing: u16,
theme: Theme,
}
impl<'a> MenuBar<'a> {
#[must_use]
pub fn new(titles: &'a [&'a str], rows: &'a [&'a [MenuRow<'a>]]) -> Self {
Self {
titles,
rows,
style: Style::new(),
open_style: Style::new(),
column_spacing: 1,
theme: Theme::DARK,
}
.theme(Theme::DARK)
}
#[must_use]
pub const fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub const fn open_style(mut self, style: Style) -> Self {
self.open_style = style;
self
}
#[must_use]
pub const fn column_spacing(mut self, spacing: u16) -> Self {
self.column_spacing = spacing;
self
}
#[must_use]
pub fn theme(mut self, theme: Theme) -> Self {
self.style = Style::new().fg(theme.dim).bg(theme.panel_bg);
self.open_style = Style::new().fg(theme.bg).bg(theme.accent);
self.theme = theme;
self
}
}
impl MenuBar<'_> {
pub fn draw(&self, surface: &mut Surface<'_>, open: Option<usize>) {
let area = surface.area();
if area.width() == 0 || area.height() == 0 {
return;
}
let strip = Strip::new(self.titles, self.column_spacing);
for (index, abs_x, text_width) in strip.columns(area) {
let x = abs_x - area.left();
let title = self.titles[index];
let style = if Some(index) == open {
self.open_style
} else {
self.style
};
if Some(index) == open && text_width > 0 {
fill_rect(
surface,
Rect::new(x, 0, text_width, 1),
' ',
Style::new().bg(style.background()),
);
}
let _ = draw_clipped(surface, (x, 0), text_width, title, Align::Left, style);
}
}
fn label_at(&self, area: Rect, pos: Pos) -> Option<usize> {
Strip::new(self.titles, self.column_spacing).index_at(area, pos)
}
fn label_rect(&self, area: Rect, label: usize) -> Rect {
Strip::new(self.titles, self.column_spacing)
.columns(area)
.find(|&(index, ..)| index == label)
.map_or(area, |(_, x, text_width)| {
Rect::new(x, area.top(), text_width.max(1), area.height())
})
}
fn rows_of(&self, label: usize) -> (usize, impl Fn(usize) -> bool + '_) {
let rows = self.rows[label];
(rows.len(), move |i: usize| rows[i].is_selectable())
}
#[must_use]
pub fn show<Id: Copy + PartialEq>(
&self,
ui: &mut Ui<'_, '_, Id>,
screen: Rect,
bar_area: Rect,
popup_size: Size,
ids: MenuBarIds<Id>,
state: &mut MenuBarState,
) -> Option<(usize, usize)> {
let mut activated = None;
if let Some(open) = state.open_label() {
let anchor = self.label_rect(bar_area, open);
let popup_area = anchored_rect(anchor, popup_size, Side::Below, screen);
if let Some(row) = Menu::new(self.rows[open]).theme(self.theme).show(
ui,
screen,
popup_area,
ids.popup_content,
ids.popup_backdrop,
state.menu_mut(),
) {
activated = Some((open, row));
}
}
let (response, mut surface) = ui.region(bar_area, ids.label, Sense::click() | Sense::HOVER);
self.draw(&mut surface, state.open_label());
let hovered_label = response
.pointer_pos()
.and_then(|pos| self.label_at(bar_area, pos));
if response.clicked()
&& let Some(label) = hovered_label
{
let (len, selectable) = self.rows_of(label);
state.toggle(label, len, selectable);
}
match hovered_label {
Some(label) => {
let (len, selectable) = self.rows_of(label);
state.hover(Some(label), len, selectable);
}
None => state.hover(None, 0, |_| false),
}
activated
}
pub fn handle_event(&self, event: &Event, state: &mut MenuBarState) -> Option<(usize, usize)> {
let open = state.open_label()?;
if let Event::Key(key) = event
&& key.is_down()
{
let delta = match key.code {
KeyCode::Left => -1,
KeyCode::Right => 1,
_ => 0,
};
if delta != 0 {
self.step_open_label(state, open, delta);
return None;
}
}
let (len, selectable) = self.rows_of(open);
let row = state.menu_mut().handle_event(event, len, selectable)?;
Some((open, row))
}
fn step_open_label(&self, state: &mut MenuBarState, current: usize, delta: i32) {
let Ok(len) = i32::try_from(self.titles.len()) else {
return; };
if len == 0 {
return;
}
let Ok(current_i32) = i32::try_from(current) else {
return;
};
let next = (current_i32 + delta).clamp(0, len - 1);
#[allow(clippy::cast_sign_loss)]
let next = next as usize;
if next != current {
let (len, selectable) = self.rows_of(next);
state.open(next, len, selectable);
}
}
}
#[cfg(test)]
mod tests {
use retroglyph_core::event::{
Event, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use retroglyph_core::grid::Grid;
use super::*;
use crate::interact::Interaction;
use crate::widget::Marker;
const FILE_ROWS: [MenuRow<'static>; 2] = [
MenuRow::Item {
label: "New",
marker: Marker::None,
enabled: true,
},
MenuRow::Item {
label: "Open",
marker: Marker::None,
enabled: true,
},
];
const EDIT_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
label: "Cut",
marker: Marker::None,
enabled: true,
}];
const VIEW_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
label: "Zoom",
marker: Marker::None,
enabled: true,
}];
const TITLES: [&str; 3] = ["File", "Edit", "View"];
fn rows() -> [&'static [MenuRow<'static>]; 3] {
[&FILE_ROWS, &EDIT_ROWS, &VIEW_ROWS]
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Id {
Bar,
PopupContent,
PopupBackdrop,
}
fn show(
bar: &MenuBar<'_>,
interaction: &mut Interaction<Id>,
grid: &mut Grid,
screen: Rect,
state: &mut MenuBarState,
) -> Option<(usize, usize)> {
interaction.frame(&mut Surface::new(grid, screen, 0), |ui| {
bar.show(
ui,
screen,
Rect::new(0, 0, 20, 1),
Size::new(10, 4),
MenuBarIds {
label: Id::Bar,
popup_content: Id::PopupContent,
popup_backdrop: Id::PopupBackdrop,
},
state,
)
})
}
fn click(interaction: &mut Interaction<Id>, pos: Pos) {
let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
pos,
KeyModifiers::NONE,
)));
let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Up(MouseButton::Left),
pos,
KeyModifiers::NONE,
)));
}
fn hover(interaction: &mut Interaction<Id>, pos: Pos) {
let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
MouseEventKind::Moved,
pos,
KeyModifiers::NONE,
)));
}
#[test]
fn draws_every_title_left_to_right() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let area = Rect::new(0, 0, 20, 1);
let mut grid = Grid::new(20, 1);
bar.draw(&mut Surface::new(&mut grid, area, 0), None);
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'F'); assert_eq!(grid[Pos::new(5, 0)].glyph(), 'E');
}
#[test]
fn cold_hover_does_not_open_anything() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut interaction = Interaction::<Id>::new();
let screen = Rect::new(0, 0, 20, 10);
let mut state = MenuBarState::new();
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
hover(&mut interaction, Pos::new(0, 0));
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
assert_eq!(state.open_label(), None);
}
#[test]
fn click_opens_the_clicked_label() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut interaction = Interaction::<Id>::new();
let screen = Rect::new(0, 0, 20, 10);
let mut state = MenuBarState::new();
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
click(&mut interaction, Pos::new(0, 0));
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
assert_eq!(state.open_label(), Some(0));
}
#[test]
fn click_past_a_wide_glyph_label_opens_the_next_label() {
const WIDE_TITLES: [&str; 2] = ["è¨å®š", "次"];
const NEXT_ROWS: [MenuRow<'static>; 1] = [MenuRow::Item {
label: "Item",
marker: Marker::None,
enabled: true,
}];
let wide_rows: [&[MenuRow<'static>]; 2] = [&NEXT_ROWS, &NEXT_ROWS];
let bar = MenuBar::new(&WIDE_TITLES, &wide_rows);
let mut interaction = Interaction::<Id>::new();
let screen = Rect::new(0, 0, 20, 10);
let mut state = MenuBarState::new();
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
click(&mut interaction, Pos::new(5, 0));
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
assert_eq!(state.open_label(), Some(1));
}
#[test]
fn click_again_closes_the_same_label() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut interaction = Interaction::<Id>::new();
let screen = Rect::new(0, 0, 20, 10);
let mut state = MenuBarState::new();
state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
click(&mut interaction, Pos::new(0, 0));
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
assert_eq!(state.open_label(), None);
}
#[test]
fn hovering_another_label_while_one_is_open_switches_to_it() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut interaction = Interaction::<Id>::new();
let screen = Rect::new(0, 0, 20, 10);
let mut state = MenuBarState::new();
state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
hover(&mut interaction, Pos::new(5, 0));
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
assert_eq!(state.open_label(), Some(1));
}
#[test]
fn clicking_a_row_activates_it_and_closes_the_bar() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut interaction = Interaction::<Id>::new();
let screen = Rect::new(0, 0, 20, 10);
let mut state = MenuBarState::new();
state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
click(&mut interaction, Pos::new(1, 2));
let mut grid = Grid::new(20, 10);
let activated = show(&bar, &mut interaction, &mut grid, screen, &mut state);
assert_eq!(activated, Some((0, 1)));
assert_eq!(state.open_label(), None);
}
#[test]
fn clicking_outside_the_bar_and_popup_closes_without_activating() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut interaction = Interaction::<Id>::new();
let screen = Rect::new(0, 0, 20, 10);
let mut state = MenuBarState::new();
state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
click(&mut interaction, Pos::new(19, 9));
let mut grid = Grid::new(20, 10);
let activated = show(&bar, &mut interaction, &mut grid, screen, &mut state);
assert_eq!(activated, None);
assert_eq!(state.open_label(), None);
}
#[test]
fn right_arrow_moves_to_the_next_label() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut state = MenuBarState::new();
state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
let activated = bar.handle_event(&right, &mut state);
assert_eq!(activated, None);
assert_eq!(state.open_label(), Some(1));
}
#[test]
fn left_right_clamp_at_the_ends_without_wrapping() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut state = MenuBarState::new();
state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
let left = Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
bar.handle_event(&left, &mut state);
assert_eq!(state.open_label(), Some(0)); }
#[test]
fn left_right_are_a_no_op_while_nothing_is_open() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut state = MenuBarState::new();
let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
bar.handle_event(&right, &mut state);
assert_eq!(state.open_label(), None); }
#[test]
fn arrow_down_then_enter_navigates_and_activates_the_open_popup() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut state = MenuBarState::new();
state.open(0, FILE_ROWS.len(), |i| FILE_ROWS[i].is_selectable());
let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
bar.handle_event(&down, &mut state);
assert_eq!(state.menu().selected(), Some(1));
let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let activated = bar.handle_event(&enter, &mut state);
assert_eq!(activated, Some((0, 1)));
assert_eq!(state.open_label(), None);
}
#[test]
fn a_stationary_pointer_does_not_fight_a_keyboard_driven_switch() {
let rows = rows();
let bar = MenuBar::new(&TITLES, &rows);
let mut interaction = Interaction::<Id>::new();
let screen = Rect::new(0, 0, 20, 10);
let mut state = MenuBarState::new();
hover(&mut interaction, Pos::new(0, 0));
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
click(&mut interaction, Pos::new(0, 0));
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
assert_eq!(state.open_label(), Some(0));
let right = Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
bar.handle_event(&right, &mut state);
assert_eq!(state.open_label(), Some(1));
let mut grid = Grid::new(20, 10);
show(&bar, &mut interaction, &mut grid, screen, &mut state);
assert_eq!(state.open_label(), Some(1));
}
}