361 lines
16 KiB
Rust
Executable File
361 lines
16 KiB
Rust
Executable File
//! Top-level dropdown menu bar (F1 toggle).
|
|
//!
|
|
//! Inspired by QBasic 4.5's menu system and aptitude's TUI menus.
|
|
//! Activated with F1. Navigate with arrow keys, Enter to select, Esc/F1 to close.
|
|
//! Menu items dispatch to slash-commands or internal actions.
|
|
|
|
use crate::core::app::App;
|
|
use crate::tui::foundation::{NaimPalette, NaimStyle};
|
|
use ratatui::prelude::*;
|
|
use std::collections::VecDeque;
|
|
|
|
/// A single item inside a dropdown menu.
|
|
#[derive(Debug, Clone)]
|
|
pub struct MenuItem {
|
|
pub label: String,
|
|
/// Slash-command to dispatch, or an internal action tag.
|
|
pub action: String,
|
|
/// True if this item is a separator line (visual only).
|
|
pub separator: bool,
|
|
}
|
|
|
|
/// A top-level menu heading that opens a dropdown.
|
|
#[derive(Debug, Clone)]
|
|
pub struct MenuBarEntry {
|
|
pub label: String,
|
|
pub items: Vec<MenuItem>,
|
|
}
|
|
|
|
impl MenuBarEntry {
|
|
pub fn new(label: &str, items: Vec<MenuItem>) -> Self {
|
|
Self { label: label.to_owned(), items }
|
|
}
|
|
}
|
|
|
|
/// State machine for the menu bar overlay.
|
|
#[derive(Debug)]
|
|
pub struct MenuBarState {
|
|
pub entries: Vec<MenuBarEntry>,
|
|
/// Index into `entries` of the currently open dropdown, or `None` if closed.
|
|
pub open_dropdown: Option<usize>,
|
|
/// Highlighted item index within the open dropdown.
|
|
pub highlight_idx: usize,
|
|
/// True when the menu bar is active (F1 toggles this).
|
|
pub active: bool,
|
|
/// Queue of actions selected by the user (consumed by main loop).
|
|
pub pending_actions: VecDeque<String>,
|
|
}
|
|
|
|
impl MenuBarState {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
entries: default_menus(),
|
|
open_dropdown: None,
|
|
highlight_idx: 0,
|
|
active: false,
|
|
pending_actions: VecDeque::new(),
|
|
}
|
|
}
|
|
|
|
/// Toggle menu bar on/off.
|
|
pub fn toggle(&mut self) {
|
|
if self.active {
|
|
self.close();
|
|
} else {
|
|
self.active = true;
|
|
self.open_dropdown = Some(0);
|
|
self.highlight_idx = 0;
|
|
}
|
|
}
|
|
|
|
/// Close the menu bar entirely.
|
|
pub fn close(&mut self) {
|
|
self.active = false;
|
|
self.open_dropdown = None;
|
|
}
|
|
|
|
/// Open a specific dropdown by index.
|
|
pub fn open(&mut self, idx: usize) {
|
|
self.active = true;
|
|
self.open_dropdown = Some(idx);
|
|
self.highlight_idx = 0;
|
|
}
|
|
|
|
/// Move highlight right to the next menu heading.
|
|
pub fn move_right(&mut self) {
|
|
if let Some(cur) = self.open_dropdown {
|
|
let next = (cur + 1) % self.entries.len();
|
|
self.open_dropdown = Some(next);
|
|
self.highlight_idx = 0;
|
|
}
|
|
}
|
|
|
|
/// Move highlight left to the previous menu heading.
|
|
pub fn move_left(&mut self) {
|
|
if let Some(cur) = self.open_dropdown {
|
|
let prev = if cur == 0 { self.entries.len() - 1 } else { cur - 1 };
|
|
self.open_dropdown = Some(prev);
|
|
self.highlight_idx = 0;
|
|
}
|
|
}
|
|
|
|
/// Move highlight down within the current dropdown.
|
|
pub fn move_down(&mut self) {
|
|
if let Some(di) = self.open_dropdown {
|
|
let items = &self.entries[di].items;
|
|
let non_sep: Vec<usize> = items.iter().enumerate()
|
|
.filter(|(_, it)| !it.separator)
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
if non_sep.is_empty() { return; }
|
|
let cur_pos = non_sep.iter().position(|&i| i == self.highlight_idx)
|
|
.unwrap_or(0);
|
|
let next_pos = (cur_pos + 1) % non_sep.len();
|
|
self.highlight_idx = non_sep[next_pos];
|
|
}
|
|
}
|
|
|
|
/// Move highlight up within the current dropdown.
|
|
pub fn move_up(&mut self) {
|
|
if let Some(di) = self.open_dropdown {
|
|
let items = &self.entries[di].items;
|
|
let non_sep: Vec<usize> = items.iter().enumerate()
|
|
.filter(|(_, it)| !it.separator)
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
if non_sep.is_empty() { return; }
|
|
let cur_pos = non_sep.iter().position(|&i| i == self.highlight_idx)
|
|
.unwrap_or(0);
|
|
let prev_pos = if cur_pos == 0 { non_sep.len() - 1 } else { cur_pos - 1 };
|
|
self.highlight_idx = non_sep[prev_pos];
|
|
}
|
|
}
|
|
|
|
/// Select the currently highlighted item.
|
|
pub fn select(&mut self) {
|
|
if let Some(di) = self.open_dropdown {
|
|
if let Some(item) = self.entries[di].items.get(self.highlight_idx) {
|
|
if !item.separator {
|
|
self.pending_actions.push_back(item.action.clone());
|
|
self.close();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pop the next pending action (if any).
|
|
pub fn pop_action(&mut self) -> Option<String> {
|
|
self.pending_actions.pop_front()
|
|
}
|
|
}
|
|
|
|
/// Render the menu bar. When active, draws the top row with headings and
|
|
/// the currently-open dropdown beneath it.
|
|
pub fn render_menubar(area: Rect, buf: &mut Buffer, state: &MenuBarState, palette: &NaimPalette, _app: &App) {
|
|
if area.width == 0 || area.height == 0 {
|
|
return;
|
|
}
|
|
|
|
let menu_bg = palette.statusbar_bg;
|
|
let heading_fg = palette.self_fg;
|
|
let heading_active_fg = palette.buddy_waiting_fg;
|
|
let item_fg = palette.event_fg;
|
|
let item_hl_fg = palette.self_fg;
|
|
let item_hl_bg = palette.input_bg;
|
|
let sep_fg = palette.buddy_idle_fg;
|
|
let border_color = palette.buddy_idle_fg;
|
|
|
|
// ── Menu bar row (always drawn when active) ──
|
|
let bar_area = Rect::new(area.x, area.y, area.width, 1);
|
|
let bar_bg_style = NaimStyle::pair(heading_fg, menu_bg);
|
|
for x in bar_area.x..bar_area.x + bar_area.width {
|
|
buf.set_string(x, bar_area.y, " ", bar_bg_style);
|
|
}
|
|
|
|
let mut x = bar_area.x;
|
|
for (i, entry) in state.entries.iter().enumerate() {
|
|
let is_open = state.open_dropdown == Some(i);
|
|
let style = if is_open {
|
|
NaimStyle::bold_pair(heading_active_fg, item_hl_bg)
|
|
} else {
|
|
NaimStyle::pair(heading_fg, menu_bg)
|
|
};
|
|
// Pad heading with a space on each side.
|
|
let label = format!(" {} ", entry.label);
|
|
if x + label.chars().count() as u16 <= bar_area.x + bar_area.width {
|
|
buf.set_string(x, bar_area.y, &label, style);
|
|
x += label.chars().count() as u16;
|
|
}
|
|
}
|
|
|
|
// Right-align help hint.
|
|
let help = " Esc=Close \u{2190}\u{2192}=Menus \u{2191}\u{2193}=Items Enter=Select ";
|
|
let hw = help.chars().count() as u16;
|
|
if bar_area.width >= hw + 4 {
|
|
buf.set_string(bar_area.x + bar_area.width - hw - 2, bar_area.y, help,
|
|
NaimStyle::pair(sep_fg, menu_bg));
|
|
}
|
|
|
|
// ── Dropdown panel ──
|
|
if let Some(di) = state.open_dropdown {
|
|
let items = &state.entries[di].items;
|
|
if items.is_empty() { return; }
|
|
|
|
// Dropdown width: max item length + 4 padding, or heading width + 8.
|
|
let heading_x = heading_x_offset(&state.entries, di, bar_area.x);
|
|
let max_item_w = items.iter().map(|it| it.label.chars().count()).max().unwrap_or(10);
|
|
let heading_offset = (heading_x - bar_area.x as usize) as u16;
|
|
let dd_width = (max_item_w + 4).max(state.entries[di].label.chars().count() + 8)
|
|
.min((area.width as usize).saturating_sub(heading_offset as usize)) as u16;
|
|
let dd_height = (items.len() as u16 + 2).min(area.height.saturating_sub(2));
|
|
|
|
let dd_x = (heading_x as u16).min(area.x + area.width - dd_width);
|
|
let dd_y = bar_area.y + 1;
|
|
let dd_area = Rect::new(dd_x, dd_y, dd_width, dd_height);
|
|
|
|
// Background.
|
|
for y in dd_area.y..dd_area.y + dd_area.height {
|
|
for x in dd_area.x..dd_area.x + dd_area.width {
|
|
buf.set_string(x, y, " ", NaimStyle::pair(item_fg, item_hl_bg));
|
|
}
|
|
}
|
|
|
|
// Border.
|
|
let border = NaimStyle::pair(border_color, item_hl_bg);
|
|
// Top-left, top-right corners.
|
|
if dd_area.width >= 2 && dd_area.height >= 2 {
|
|
buf.set_string(dd_area.x, dd_area.y, "\u{250C}", border);
|
|
buf.set_string(dd_area.x + dd_area.width - 1, dd_area.y, "\u{2510}", border);
|
|
buf.set_string(dd_area.x, dd_area.y + dd_area.height - 1, "\u{2514}", border);
|
|
buf.set_string(dd_area.x + dd_area.width - 1, dd_area.y + dd_area.height - 1, "\u{2518}", border);
|
|
}
|
|
// Horizontal borders.
|
|
for bx in (dd_area.x + 1)..(dd_area.x + dd_area.width - 1) {
|
|
buf.set_string(bx, dd_area.y, "\u{2500}", border);
|
|
buf.set_string(bx, dd_area.y + dd_area.height - 1, "\u{2500}", border);
|
|
}
|
|
// Vertical borders.
|
|
for by in (dd_area.y + 1)..(dd_area.y + dd_area.height - 1) {
|
|
buf.set_string(dd_area.x, by, "\u{2502}", border);
|
|
buf.set_string(dd_area.x + dd_area.width - 1, by, "\u{2502}", border);
|
|
}
|
|
|
|
// Items.
|
|
let inner_x = dd_area.x + 1;
|
|
let inner_w = dd_area.width.saturating_sub(2);
|
|
for (idx, item) in items.iter().enumerate() {
|
|
let row = dd_area.y + 1 + idx as u16;
|
|
if row >= dd_area.y + dd_area.height - 1 { break; }
|
|
|
|
if item.separator {
|
|
// Separator line.
|
|
for sx in (inner_x + 1)..(inner_x + inner_w - 1) {
|
|
buf.set_string(sx, row, "\u{2500}", NaimStyle::pair(sep_fg, item_hl_bg));
|
|
}
|
|
} else {
|
|
let is_hl = idx == state.highlight_idx;
|
|
let style = if is_hl {
|
|
NaimStyle::bold_pair(item_hl_fg, palette.event_alt_fg)
|
|
} else {
|
|
NaimStyle::pair(item_fg, item_hl_bg)
|
|
};
|
|
// Clear the row.
|
|
for sx in inner_x..(inner_x + inner_w) {
|
|
buf.set_string(sx, row, " ", style);
|
|
}
|
|
// Truncate label to fit.
|
|
let max_chars = inner_w as usize;
|
|
let display: String = item.label.chars().take(max_chars).collect();
|
|
buf.set_string(inner_x, row, &display, style);
|
|
// Right-align the shortcut hint if present.
|
|
if let Some((_lbl, _shortcut)) = item.label.split_once('\t') {
|
|
if let Some(sc) = item.label.split('\t').nth(1) {
|
|
let sc_display: String = sc.chars().take(max_chars).collect();
|
|
let sc_w = sc_display.chars().count() as u16;
|
|
if sc_w < inner_w {
|
|
buf.set_string(inner_x + inner_w - sc_w, row, &sc_display,
|
|
NaimStyle::pair(sep_fg, if is_hl { palette.event_alt_fg } else { item_hl_bg }));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compute the x-offset (in chars) for a given dropdown heading.
|
|
fn heading_x_offset(entries: &[MenuBarEntry], target: usize, base_x: u16) -> usize {
|
|
let mut x = base_x as usize;
|
|
for (i, e) in entries.iter().enumerate() {
|
|
if i == target { return x; }
|
|
x += e.label.chars().count() + 2; // " label " → 1 space + label + 1 space
|
|
}
|
|
x
|
|
}
|
|
|
|
/// Build the default menu structure.
|
|
///
|
|
/// ## Action conventions
|
|
///
|
|
/// - **Direct execution** (safe with no args): action is the exact `/command`.
|
|
/// e.g. `"/clear"`, `"/disconnect"`.
|
|
/// - **Prompt mode** (needs user input): action is `"__prompt:/cmd "`.
|
|
/// The `__prompt:` prefix causes the input bar to be pre-filled with the
|
|
/// command prefix so the user can type the required arguments and press Enter.
|
|
/// e.g. `"__prompt:/join "` puts `/join ` in the input bar.
|
|
/// - **Internal actions**: `"__server_list"` etc. are handled directly in
|
|
/// the main event loop's menu dispatch block.
|
|
fn default_menus() -> Vec<MenuBarEntry> {
|
|
use MenuItem as MI;
|
|
vec![
|
|
// ── File ────────────────────────────────────────────────────
|
|
MenuBarEntry::new("File", vec![
|
|
MI { label: "Connect…\t__prompt:/connect ".into(), action: "__prompt:/connect ".into(), separator: false },
|
|
MI { label: "Disconnect\t/disconnect".into(), action: "/disconnect".into(), separator: false },
|
|
MI { label: String::new(), action: String::new(), separator: true },
|
|
MI { label: "Save Config\t/save".into(), action: "/save".into(), separator: false },
|
|
MI { label: "Source File…\t__prompt:/source ".into(), action: "__prompt:/source ".into(), separator: false },
|
|
MI { label: String::new(), action: String::new(), separator: true },
|
|
MI { label: "Quit\t/quit".into(), action: "/quit".into(), separator: false },
|
|
]),
|
|
// ── Edit ────────────────────────────────────────────────────
|
|
MenuBarEntry::new("Edit", vec![
|
|
MI { label: "Clear Window\t/clear".into(), action: "/clear".into(), separator: false },
|
|
MI { label: "Clear All Windows\t/clearall".into(), action: "/clearall".into(), separator: false },
|
|
MI { label: String::new(), action: String::new(), separator: true },
|
|
MI { label: "Set Variable…\t__prompt:/set ".into(), action: "__prompt:/set ".into(), separator: false },
|
|
MI { label: "Get Variable…\t__prompt:/get ".into(), action: "__prompt:/get ".into(), separator: false },
|
|
MI { label: "Evaluate…\t__prompt:/eval ".into(), action: "__prompt:/eval ".into(), separator: false },
|
|
]),
|
|
// ── View ────────────────────────────────────────────────────
|
|
MenuBarEntry::new("View", vec![
|
|
MI { label: "Toggle Winlist\t/winlist".into(), action: "/winlist".into(), separator: false },
|
|
MI { label: String::new(), action: String::new(), separator: true },
|
|
MI { label: "Jump to Window…\t__prompt:/jump ".into(), action: "__prompt:/jump ".into(), separator: false },
|
|
MI { label: "Jump Back\t/jumpback".into(), action: "/jumpback".into(), separator: false },
|
|
MI { label: "Next Unread\tCtrl-N".into(), action: "__internal:ctrl_n".into(), separator: false },
|
|
MI { label: String::new(), action: String::new(), separator: true },
|
|
MI { label: "Redraw Screen\tCtrl-L".into(), action: "__internal:ctrl_l".into(), separator: false },
|
|
]),
|
|
// ── Connect ─────────────────────────────────────────────────
|
|
// NOTE: BitChat menu entry removed in 0.10.2. See NOTICES.md.
|
|
MenuBarEntry::new("Connect", vec![
|
|
MI { label: "IRC…\t__prompt:/connect irc ".into(), action: "__prompt:/connect irc ".into(), separator: false },
|
|
MI { label: "Matrix…\t__prompt:/connect matrix ".into(), action: "__prompt:/connect matrix ".into(), separator: false },
|
|
MI { label: "ADC/DC++…\t__prompt:/connect adc ".into(), action: "__prompt:/connect adc ".into(), separator: false },
|
|
MI { label: "Discord…\t__prompt:/connect discord ".into(), action: "__prompt:/connect discord ".into(), separator: false },
|
|
MI { label: String::new(), action: String::new(), separator: true },
|
|
MI { label: "Server List\t__server_list".into(), action: "__server_list".into(), separator: false },
|
|
MI { label: "Disconnect All\t/disconnect".into(), action: "/disconnect".into(), separator: false },
|
|
]),
|
|
// ── Help ────────────────────────────────────────────────────
|
|
MenuBarEntry::new("Help", vec![
|
|
MI { label: "Help\t/help".into(), action: "/help".into(), separator: false },
|
|
MI { label: "Version\t/version".into(), action: "/version".into(), separator: false },
|
|
MI { label: "Client Info\t/info".into(), action: "/info".into(), separator: false },
|
|
MI { label: String::new(), action: String::new(), separator: true },
|
|
MI { label: "Transfers\t/transfers".into(), action: "/transfers".into(), separator: false },
|
|
MI { label: "Commands\t/help".into(), action: "/help".into(), separator: false },
|
|
]),
|
|
]
|
|
} |