//! 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, } impl MenuBarEntry { pub fn new(label: &str, items: Vec) -> Self { Self { label: label.to_owned(), items } } } /// State machine for the menu bar overlay. #[derive(Debug)] pub struct MenuBarState { pub entries: Vec, /// Index into `entries` of the currently open dropdown, or `None` if closed. pub open_dropdown: Option, /// 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, } 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 = 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 = 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 { 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 { 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 }, ]), ] }