nirc-rs/src/tui/menubar.rs

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 },
]),
]
}