use rat_theme4::WidgetStyle;
use rat_theme4::theme::SalsaTheme;
use rat_widget::event::MenuOutcome;
use rat_widget::menu::menubar;
use rat_widget::menu::{MenuStyle, Menubar, MenubarLine, MenubarPopup, MenubarState, StaticMenu};
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use ratatui_core::widgets::StatefulWidget;
use ratatui_crossterm::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::chrome::Chrome;
use crate::vocabulary::{Action, Reply};
static MENU: StaticMenu = StaticMenu {
menu: &[
(
"_File",
&[
"_New|Ctrl+N",
"_Open|Ctrl+O",
"_Save|Ctrl+S",
"_Export as Text",
"Open _Demo",
"_Quit|Ctrl+Q",
],
),
("_Edit", &["_Undo|Ctrl+Z", "_Redo|Ctrl+Shift+Z"]),
("_Help", &["_About"]),
],
};
const FILE: usize = 0;
const NEW: usize = 0;
const OPEN: usize = 1;
const SAVE: usize = 2;
const EXPORT_TEXT: usize = 3;
const OPEN_DEMO: usize = 4;
const QUIT: usize = 5;
const EDIT: usize = 1;
const UNDO: usize = 0;
const REDO: usize = 1;
const HELP: usize = 2;
const ABOUT: usize = 0;
pub(crate) fn handle(raw: &Event, chrome: &mut Chrome) -> Reply {
let bar = chrome.is_bar();
if !bar && asks_for_the_bar(raw) {
chrome.move_to_bar();
return Reply::Repaint;
}
if bar && steps_back(raw, chrome.menu()) {
chrome.move_to_region();
return Reply::Repaint;
}
let translated;
let (raw, arriving) = match alt_navigation(raw) {
Some(character) => {
translated = Event::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
let arrived = !chrome.is_bar();
chrome.move_to_bar();
(&translated, arrived)
}
None => (raw, false),
};
let before_open = chrome.popup_active();
let before_focus = chrome.leaf();
let focused = chrome.is_bar();
let answer = match menubar::handle_popup_events(chrome.menu_mut(), focused, raw) {
MenuOutcome::MenuActivated(label, entry) => {
chrome.close_popup();
meaning(label, entry)
}
MenuOutcome::Changed
| MenuOutcome::Hide
| MenuOutcome::Selected(_)
| MenuOutcome::Activated(_)
| MenuOutcome::MenuSelected(_, _) => Reply::Repaint,
MenuOutcome::Unchanged => Reply::Consumed,
MenuOutcome::Continue => Reply::Ignored,
};
let state_changed = chrome.popup_active() != before_open || chrome.leaf() != before_focus;
let answer = if state_changed && answer != Reply::Ignored {
answer.and_repaint()
} else {
answer
};
if arriving {
answer.and_repaint()
} else {
answer
}
}
fn alt_navigation(raw: &Event) -> Option<char> {
let Event::Key(key) = raw else {
return None;
};
if key.kind != KeyEventKind::Press || key.modifiers != KeyModifiers::ALT {
return None;
}
let KeyCode::Char(character) = key.code else {
return None;
};
let character = character.to_ascii_lowercase();
MENU.menu
.iter()
.any(|(label, _)| navigation(label) == Some(character))
.then_some(character)
}
fn asks_for_the_bar(event: &Event) -> bool {
matches!(
event,
Event::Key(KeyEvent {
code: KeyCode::F(10),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
})
)
}
fn steps_back(event: &Event, state: &MenubarState) -> bool {
!state.popup_active() && is_escape(event)
}
fn is_escape(event: &Event) -> bool {
matches!(
event,
Event::Key(KeyEvent {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
})
)
}
fn navigation(label: &str) -> Option<char> {
label
.strip_prefix('_')
.and_then(|rest| rest.chars().next())
.map(|character| character.to_ascii_lowercase())
}
fn meaning(label: usize, entry: usize) -> Reply {
match (label, entry) {
(FILE, NEW) => Reply::Act(Action::New),
(FILE, OPEN) => Reply::Act(Action::Open),
(FILE, SAVE) => Reply::Act(Action::Save),
(FILE, EXPORT_TEXT) => Reply::Act(Action::ExportText),
(FILE, OPEN_DEMO) => Reply::Act(Action::OpenDemo),
(FILE, QUIT) => Reply::quit(),
(EDIT, UNDO) => Reply::Act(Action::Undo),
(EDIT, REDO) => Reply::Act(Action::Redo),
(HELP, ABOUT) => Reply::Act(Action::About),
_ => Reply::Repaint,
}
}
pub(crate) fn draw_bar(
area: Rect,
buf: &mut Buffer,
state: &mut MenubarState,
theme: &SalsaTheme,
focused: bool,
) {
let (bar, _) = widgets(theme);
state.bar.focus.set(focused);
bar.render(area, buf, state);
}
pub(crate) fn draw_drop_down(
area: Rect,
buf: &mut Buffer,
state: &mut MenubarState,
theme: &SalsaTheme,
) {
let (_, drop_down) = widgets(theme);
drop_down.render(area, buf, state);
}
fn widgets(theme: &SalsaTheme) -> (MenubarLine<'static>, MenubarPopup<'static>) {
let mut style: MenuStyle = theme.style(WidgetStyle::MENU);
style.focus = Some(Style::new().reversed());
style.popup_focus = Some(Style::new().reversed());
Menubar::new(&MENU).styles(style).into_widgets()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chrome::{Chrome, Focus};
use crate::config::Config;
use ratatui_crossterm::crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
const WIDTH: u16 = 40;
const HEIGHT: u16 = 10;
fn theme() -> SalsaTheme {
Config::default().theme
}
fn draw(chrome: &mut Chrome) -> Buffer {
draw_with(chrome, true)
}
fn draw_with(chrome: &mut Chrome, focused: bool) -> Buffer {
let area = Rect::new(0, 0, WIDTH, HEIGHT);
let mut buffer = Buffer::empty(area);
draw_bar(
Rect::new(0, 0, WIDTH, 1),
&mut buffer,
chrome.menu_mut(),
&theme(),
focused,
);
draw_drop_down(area, &mut buffer, chrome.menu_mut(), &theme());
buffer
}
fn drawn() -> Chrome {
let mut chrome = Chrome::new();
draw(&mut chrome);
chrome
}
fn picture(buffer: &Buffer) -> String {
(buffer.area.top()..buffer.area.bottom())
.map(|line| row_of(buffer, line))
.collect::<Vec<String>>()
.join("\n")
}
fn row_of(buffer: &Buffer, line: u16) -> String {
(buffer.area.left()..buffer.area.right())
.map(|column| buffer[(column, line)].symbol().to_string())
.collect()
}
fn cell_of(buffer: &Buffer, text: &str) -> (u16, u16) {
crate::frame::cell_of(buffer, text)
}
fn reversed(buffer: &Buffer, column: u16, line: u16) -> bool {
buffer[(column, line)]
.style()
.add_modifier
.contains(ratatui_core::style::Modifier::REVERSED)
}
fn on_the_bar(event: &Event, chrome: &mut Chrome) -> Reply {
chrome.move_to_bar();
handle(event, chrome)
}
fn open_with(chrome: &mut Chrome, navigation: char) {
let _ = on_the_bar(&key(KeyCode::Char(navigation)), chrome);
draw(chrome);
}
fn open(chrome: &mut Chrome) {
open_with(chrome, 'f');
}
fn take(chrome: &mut Chrome, text: &str) -> Reply {
let buffer = draw(chrome);
let (column, row) = cell_of(&buffer, text);
let _ = on_the_bar(&moved(column, row), chrome);
on_the_bar(&click(column, row), chrome)
}
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
Event::Key(KeyEvent::new(code, modifiers))
}
fn click(column: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
})
}
fn moved(column: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column,
row,
modifiers: KeyModifiers::NONE,
})
}
#[test]
fn a_control_the_keyboard_is_on_is_drawn_in_reverse_video() {
let mut without = Chrome::new();
let unfocused = draw_with(&mut without, false);
let (column, row) = cell_of(&unfocused, "File");
let plain = unfocused[(column, row)].style();
let mut with = drawn();
let focused = draw(&mut with);
let lit = focused[(column, row)].style();
assert!(
!reversed(&unfocused, column, row),
"a label is reversed while the bar does not hold the keyboard"
);
assert!(
reversed(&focused, column, row),
"the label the keyboard is on is not reversed"
);
assert_eq!(lit.fg, plain.fg, "the focus changed the label's foreground");
assert_eq!(
lit.bg, plain.bg,
"the theme's focus background is used, not reverse video"
);
let (column, row) = cell_of(&focused, "Edit");
assert!(
!reversed(&focused, column, row),
"another label is reversed with the one the keyboard is on"
);
}
#[test]
fn the_navigation_character_opens_the_drop_down_and_escape_closes_it() {
let mut chrome = drawn();
let outcome = on_the_bar(&key(KeyCode::Char('f')), &mut chrome);
assert!(
matches!(outcome, Reply::Repaint),
"the navigation character did not report a screen change: {outcome:?}"
);
assert!(chrome.popup_active(), "the drop-down did not open");
let outcome = on_the_bar(&key(KeyCode::Esc), &mut chrome);
assert!(
matches!(outcome, Reply::Repaint),
"escape did not report a screen change: {outcome:?}"
);
assert!(!chrome.popup_active(), "escape did not close the drop-down");
}
#[test]
fn an_escape_press_is_the_bars_way_out() {
let mut chrome = drawn();
assert!(
steps_back(&key(KeyCode::Esc), chrome.menu()),
"the escape press was not read"
);
for kind in [KeyEventKind::Release, KeyEventKind::Repeat] {
let event = Event::Key(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
kind,
));
assert!(
!steps_back(&event, chrome.menu()),
"{kind:?} was read as it"
);
}
assert!(
!steps_back(
&key_with(KeyCode::Esc, KeyModifiers::CONTROL),
chrome.menu()
),
"a modified escape was read as it"
);
assert!(
!steps_back(&key(KeyCode::Enter), chrome.menu()),
"another key was read as it"
);
open(&mut chrome);
assert!(
!steps_back(&key(KeyCode::Esc), chrome.menu()),
"the escape that closes the drop-down was read as the bar stepping back"
);
}
#[test]
fn a_key_that_asks_for_the_bar_is_a_press_and_nothing_else() {
assert!(
asks_for_the_bar(&key(KeyCode::F(10))),
"F10 did not ask for the bar"
);
assert!(
!asks_for_the_bar(&key_with(KeyCode::F(10), KeyModifiers::SHIFT)),
"a shifted F10 asked for the bar"
);
assert!(
!asks_for_the_bar(&key(KeyCode::F(9))),
"another function key asked for the bar"
);
assert!(
!asks_for_the_bar(&key(KeyCode::Char('f'))),
"a navigation character asked for the bar on its own"
);
for kind in [KeyEventKind::Release, KeyEventKind::Repeat] {
let event = Event::Key(KeyEvent::new_with_kind(
KeyCode::F(10),
KeyModifiers::NONE,
kind,
));
assert!(!asks_for_the_bar(&event), "{kind:?} asked for the bar");
}
}
#[test]
fn f10_moves_the_keyboard_to_the_bar_and_owes_the_frame() {
let mut chrome = drawn();
let outcome = handle(&key(KeyCode::F(10)), &mut chrome);
assert_eq!(
chrome.leaf(),
Focus::Bar,
"F10 left the keyboard where it was"
);
assert!(
matches!(outcome, Reply::Repaint),
"F10 answered {outcome:?}"
);
assert!(!chrome.popup_active(), "F10 opened a drop-down");
}
#[test]
fn the_escape_that_closes_nothing_gives_the_keyboard_back() {
let mut chrome = drawn();
chrome.move_to_bar();
let outcome = handle(&key(KeyCode::Esc), &mut chrome);
assert_eq!(
chrome.leaf(),
Focus::Region,
"the escape left the keyboard on the bar"
);
assert!(
matches!(outcome, Reply::Repaint),
"the escape answered {outcome:?}"
);
}
#[test]
fn a_press_the_drop_down_traps_is_consumed_and_not_ignored() {
let mut chrome = drawn();
open_with(&mut chrome, 'e');
let popup = chrome.menu().popup.area;
let mut trapped = None;
for row in popup.top()..popup.bottom() {
for column in popup.left()..popup.right() {
if chrome.menu().popup.item_at((column, row)).is_none() {
trapped = Some((column, row));
break;
}
}
if trapped.is_some() {
break;
}
}
let (column, row) = trapped.expect("the drop-down has no cell that is not an entry");
let outcome = on_the_bar(&click(column, row), &mut chrome);
assert_eq!(
outcome,
Reply::Consumed,
"a press the drop-down trapped answered {outcome:?}"
);
assert!(chrome.popup_active(), "the press closed the drop-down");
}
#[test]
fn taking_the_new_entry_becomes_the_action_of_new() {
let mut chrome = drawn();
open(&mut chrome);
let outcome = take(&mut chrome, "New");
assert!(
matches!(outcome, Reply::Act(Action::New)),
"the entry did not become the action of new: {outcome:?}"
);
assert!(
!chrome.popup_active(),
"the drop-down stayed open behind it"
);
}
#[test]
fn taking_the_open_entry_becomes_the_action_of_the_open_dialog() {
let mut chrome = drawn();
open(&mut chrome);
let outcome = take(&mut chrome, "Open");
assert!(
matches!(outcome, Reply::Act(Action::Open)),
"the entry did not become the action of the open dialog: {outcome:?}"
);
assert!(
!chrome.popup_active(),
"the drop-down stayed open behind it"
);
}
#[test]
fn taking_the_save_entry_becomes_the_action_of_the_save_dialog() {
let mut chrome = drawn();
open(&mut chrome);
let outcome = take(&mut chrome, "Save");
assert!(
matches!(outcome, Reply::Act(Action::Save)),
"the entry did not become the action of the save dialog: {outcome:?}"
);
assert!(
!chrome.popup_active(),
"the drop-down stayed open behind it"
);
}
#[test]
fn taking_the_open_demo_entry_becomes_the_action_of_open_demo() {
let mut chrome = drawn();
open(&mut chrome);
let outcome = take(&mut chrome, "Open Demo");
assert!(
matches!(outcome, Reply::Act(Action::OpenDemo)),
"the entry did not become the action of open demo: {outcome:?}"
);
assert!(
!chrome.popup_active(),
"the drop-down stayed open behind it"
);
}
#[test]
fn taking_the_quit_entry_becomes_the_answer_of_the_way_out() {
let mut chrome = drawn();
open(&mut chrome);
let outcome = take(&mut chrome, "Quit");
assert_eq!(
outcome,
Reply::quit(),
"the entry did not become the answer of the way out"
);
assert!(
!chrome.popup_active(),
"the drop-down stayed open behind it"
);
}
#[test]
fn taking_the_undo_entry_becomes_the_action_of_the_key() {
let mut chrome = drawn();
open_with(&mut chrome, 'e');
let outcome = take(&mut chrome, "Undo");
assert!(
matches!(outcome, Reply::Act(Action::Undo)),
"the entry did not become the action of the undo key: {outcome:?}"
);
assert!(
!chrome.popup_active(),
"the drop-down stayed open behind it"
);
}
#[test]
fn the_help_menu_holds_about_and_draws_no_shortcut() {
let mut chrome = drawn();
open_with(&mut chrome, 'h');
let buffer = draw(&mut chrome);
let frame = picture(&buffer);
assert!(frame.contains("Help"), "the bar has no Help: {frame:?}");
assert!(
frame.contains("About"),
"the Help menu offers no About: {frame:?}"
);
let (_, row) = cell_of(&buffer, "About");
let line = row_of(&buffer, row);
assert!(
!line.contains("Ctrl") && !line.contains("Del"),
"the About entry draws a shortcut: {line:?}"
);
}
#[test]
fn taking_the_about_entry_mints_the_about_action() {
let mut chrome = drawn();
open_with(&mut chrome, 'h');
let outcome = take(&mut chrome, "About");
assert!(
matches!(outcome, Reply::Act(Action::About)),
"the entry did not become the action of the About dialog: {outcome:?}"
);
assert!(
!chrome.popup_active(),
"the drop-down stayed open behind it"
);
}
#[test]
fn taking_the_redo_entry_becomes_the_action_of_the_key() {
let mut chrome = drawn();
open_with(&mut chrome, 'e');
let outcome = take(&mut chrome, "Redo");
assert!(
matches!(outcome, Reply::Act(Action::Redo)),
"the entry did not become the action of the redo key: {outcome:?}"
);
assert!(
!chrome.popup_active(),
"the drop-down stayed open behind it"
);
}
#[test]
fn the_edit_menu_shows_undo_and_redo_with_their_shortcuts() {
let mut chrome = drawn();
open_with(&mut chrome, 'e');
let frame = picture(&draw(&mut chrome));
assert!(
frame.contains("Undo"),
"the Edit menu offers no undo: {frame:?}"
);
assert!(
frame.contains("Redo"),
"the Edit menu offers no redo: {frame:?}"
);
assert!(
frame.contains("Ctrl+Z"),
"the undo entry shows no shortcut: {frame:?}"
);
assert!(
frame.contains("Ctrl+Shift+Z"),
"the redo entry shows no shortcut: {frame:?}"
);
let undo = frame.find("Undo").expect("the undo entry is on the frame");
let redo = frame.find("Redo").expect("the redo entry is on the frame");
assert!(undo < redo, "the entries are not Undo, Redo: {frame:?}");
}
#[test]
fn the_edit_menu_shows_undo_and_redo_and_nothing_else() {
let mut chrome = drawn();
open_with(&mut chrome, 'e');
let frame = picture(&draw(&mut chrome));
assert!(
frame.contains("Edit"),
"the bar has no Edit menu: {frame:?}"
);
assert!(
!frame.contains("Delete"),
"the Edit menu still offers delete: {frame:?}"
);
assert!(
!frame.contains("Del"),
"the Edit menu still shows Del: {frame:?}"
);
}
#[test]
fn the_shortcuts_are_not_the_bars() {
let mut chrome = drawn();
chrome.move_to_bar();
let outcome = handle(
&key_with(KeyCode::Char('q'), KeyModifiers::CONTROL),
&mut chrome,
);
assert_eq!(
outcome,
Reply::Ignored,
"the bar answered a shortcut: {outcome:?}"
);
assert_eq!(chrome.leaf(), Focus::Bar, "the bar gave the keyboard away");
assert!(!chrome.popup_active(), "a shortcut opened a drop-down");
}
#[test]
fn a_click_on_the_entry_becomes_the_same_answer() {
let mut chrome = drawn();
open(&mut chrome);
let outcome = take(&mut chrome, "Quit");
assert_eq!(
outcome,
Reply::quit(),
"the click did not become the answer of the way out"
);
}
#[test]
fn a_click_on_the_label_opens_its_drop_down() {
let mut chrome = drawn();
let buffer = draw(&mut chrome);
let (column, row) = cell_of(&buffer, "File");
let outcome = on_the_bar(&click(column, row), &mut chrome);
assert!(
matches!(outcome, Reply::Repaint),
"the click did not report a screen change: {outcome:?}"
);
assert!(
chrome.popup_active(),
"the click did not open the drop-down"
);
}
#[test]
fn a_key_the_bar_does_not_use_is_nothing() {
let mut chrome = drawn();
let outcome = on_the_bar(&key(KeyCode::Char('z')), &mut chrome);
assert_eq!(outcome, Reply::Ignored, "{outcome:?}");
assert!(!chrome.popup_active(), "a key opened a drop-down");
}
#[test]
fn the_drop_down_shows_the_shortcut_the_entry_answers_to() {
let mut chrome = drawn();
open(&mut chrome);
let buffer = draw(&mut chrome);
let frame = picture(&buffer);
assert!(
frame.contains("Ctrl+Q"),
"the drop-down shows no shortcut: {frame:?}"
);
}
#[test]
fn taking_the_export_as_text_entry_becomes_the_action_of_the_export_dialog() {
let mut chrome = drawn();
open(&mut chrome);
let outcome = take(&mut chrome, "Export as Text");
assert!(
matches!(outcome, Reply::Act(Action::ExportText)),
"the entry did not become the action of the export dialog: {outcome:?}"
);
assert!(
!chrome.popup_active(),
"the drop-down stayed open behind it"
);
}
}