diff --git a/local/recipes/tui/tlc/source/src/viewer/menubar.rs b/local/recipes/tui/tlc/source/src/viewer/menubar.rs new file mode 100644 index 0000000000..17c5914caf --- /dev/null +++ b/local/recipes/tui/tlc/source/src/viewer/menubar.rs @@ -0,0 +1,472 @@ +//! Menu bar for the viewer (F9). +//! +//! Mirrors `filemanager::menubar::MenuBar` and +//! `editor::menubar::EditorMenuBar` — top-level menus with arrow-key +//! navigation, dropdown rendering, and item dispatch. +//! +//! Surface the existing viewer toggles (hex mode, magic mode, line +//! wrap, growing buffer, bookmarks) plus search/goto. Closing the +//! viewer maps to Esc/F10. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, Paragraph}; +use ratatui::Frame; + +use crate::key::Key; +use crate::terminal::color::Theme; +use crate::terminal::mc_skin; +use crate::terminal::popup::render_popup; + +/// A viewer command surfaced by the menu bar. +/// +/// Mirrors Midnight Commander's viewer menu items. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ViewerCmd { + /// No-op (used for separators). + Nop, + /// Toggle hex view. + ToggleHex, + /// Toggle magic (binary detection) on open. + ToggleMagic, + /// Toggle line wrap. + ToggleWrap, + /// Toggle growing buffer (tail -f style). + ToggleGrowing, + /// Search forward. + Search, + /// Search next. + SearchNext, + /// Search previous. + SearchPrev, + /// Goto line. + Goto, + /// Toggle bookmark on current line. + BookmarkToggle, + /// Jump to next bookmark. + BookmarkNext, + /// Jump to previous bookmark. + BookmarkPrev, + /// Open the next file in the directory. + NextFile, + /// Open the previous file in the directory. + PrevFile, + /// Close the viewer. + Close, +} + +/// A single item in a menu — either a labelled command or a separator. +#[derive(Debug, Clone)] +pub enum MenuItem { + /// A selectable command item. + Item { + /// Display label. + label: String, + /// Hotkey character (highlighted in the label). + hotkey: char, + /// Command to dispatch when selected. + cmd: ViewerCmd, + }, + /// Visual separator (a blank line in the dropdown). + Separator, +} + +impl MenuItem { + /// Build a labelled item with a hotkey. + #[must_use] + pub fn item(label: &str, hotkey: char, cmd: ViewerCmd) -> Self { + Self::Item { + label: label.to_string(), + hotkey, + cmd, + } + } + /// Build a separator. + #[must_use] + pub fn separator() -> Self { + Self::Separator + } +} + +/// A single top-level menu: a title and a list of items. +#[derive(Debug, Clone)] +pub struct Menu { + /// Title shown in the top bar. + pub title: String, + /// Dropdown items, in render order. + pub items: Vec, +} + +impl Menu { + /// Construct a menu with a title and items. + #[must_use] + pub fn new(title: &str, items: Vec) -> Self { + Self { + title: title.to_string(), + items, + } + } +} + +/// The viewer menu bar state. +#[derive(Debug, Clone)] +pub struct ViewerMenuBar { + /// All top-level menus. + menus: Vec, + /// Index of the currently highlighted menu (0 = first). + active_menu: usize, + /// Index of the highlighted item within the active menu's + /// dropdown. + selected_item: usize, + /// Whether the dropdown is currently open. When false, only the + /// top bar is visible. + dropdown_open: bool, +} + +impl Default for ViewerMenuBar { + fn default() -> Self { + Self::new() + } +} + +impl ViewerMenuBar { + /// Construct the default viewer menu set: File, View, Search, + /// Bookmark, Goto. + #[must_use] + pub fn new() -> Self { + let menus = vec![ + Menu::new( + "File", + vec![ + MenuItem::item("Next file", 'n', ViewerCmd::NextFile), + MenuItem::item("Prev file", 'p', ViewerCmd::PrevFile), + MenuItem::separator(), + MenuItem::item("Close", 'c', ViewerCmd::Close), + ], + ), + Menu::new( + "View", + vec![ + MenuItem::item("Hex mode", 'h', ViewerCmd::ToggleHex), + MenuItem::item("Magic", 'm', ViewerCmd::ToggleMagic), + MenuItem::item("Wrap", 'w', ViewerCmd::ToggleWrap), + MenuItem::item("Growing buffer", 'g', ViewerCmd::ToggleGrowing), + ], + ), + Menu::new( + "Search", + vec![ + MenuItem::item("Find...", 'f', ViewerCmd::Search), + MenuItem::item("Find next", 'n', ViewerCmd::SearchNext), + MenuItem::item("Find prev", 'p', ViewerCmd::SearchPrev), + ], + ), + Menu::new( + "Bookmark", + vec![ + MenuItem::item("Toggle", 't', ViewerCmd::BookmarkToggle), + MenuItem::item("Next", 'n', ViewerCmd::BookmarkNext), + MenuItem::item("Prev", 'p', ViewerCmd::BookmarkPrev), + ], + ), + Menu::new( + "Goto", + vec![MenuItem::item("Line...", 'l', ViewerCmd::Goto)], + ), + ]; + Self { + menus, + active_menu: 0, + selected_item: 0, + dropdown_open: false, + } + } + + /// Open the menu bar (show the top row with the first menu + /// highlighted). + pub fn open(&mut self) { + self.dropdown_open = true; + self.active_menu = 0; + self.selected_item = 0; + } + + /// Close the menu bar without dispatching. + pub fn close(&mut self) { + self.dropdown_open = false; + } + + /// `true` if the menu bar is currently visible (either as a + /// collapsed top row or with the dropdown open). + #[must_use] + pub fn is_open(&self) -> bool { + self.dropdown_open + } + + /// Currently-highlighted item in the open dropdown, or `None` + /// if the dropdown is not open or the active menu has no + /// selectable items. + #[must_use] + pub fn selected_cmd(&self) -> Option { + if !self.dropdown_open { + return None; + } + let menu = self.menus.get(self.active_menu)?; + match menu.items.get(self.selected_item)? { + MenuItem::Item { cmd, .. } => Some(*cmd), + MenuItem::Separator => None, + } + } + + /// Handle a key while the menu bar is open. Returns `true` if + /// the key was consumed (including dispatching a command and + /// closing the bar). + pub fn handle_key(&mut self, key: Key) -> bool { + match key { + Key::ESCAPE => { + if self.dropdown_open { + self.close(); + } else { + self.close(); + } + true + } + Key::ENTER => { + let cmd = self.selected_cmd(); + if let Some(c) = cmd { + if c == ViewerCmd::Nop { + return true; + } + self.close(); + // Re-open after dispatch so the next Enter + // can re-trigger the same command. + // Callers should close the menubar after + // handling the returned command. + } + true + } + Key::LEFT => { + if self.dropdown_open { + let n = self.menus.len().max(1); + self.active_menu = (self.active_menu + n - 1) % n; + self.selected_item = 0; + } + true + } + Key::RIGHT => { + if self.dropdown_open { + let n = self.menus.len().max(1); + self.active_menu = (self.active_menu + 1) % n; + self.selected_item = 0; + } + true + } + Key::UP => { + if self.dropdown_open { + self.move_selection(-1); + } + true + } + Key::DOWN => { + if self.dropdown_open { + self.move_selection(1); + } + true + } + _ => true, + } + } + + fn move_selection(&mut self, dy: i32) { + let n = match self.menus.get(self.active_menu) { + Some(m) => m.items.len(), + None => return, + }; + if n == 0 { + return; + } + let mut idx = self.selected_item as i32 + dy; + let len = n as i32; + while idx < 0 { + idx += len; + } + while idx >= len { + idx -= len; + } + // Skip separators. + let mut attempts = 0; + let menu = &self.menus[self.active_menu]; + while matches!(menu.items[idx as usize], MenuItem::Separator) { + idx += dy.signum(); + if idx < 0 { + idx += len; + } + if idx >= len { + idx -= len; + } + attempts += 1; + if attempts > len { + return; + } + } + self.selected_item = idx as usize; + } + + /// Render the menu bar into a frame. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let menubar_pair = mc_skin::color_pair(theme.name, "core", "menubar"); + let menubar_fg = menubar_pair.map(|p| p.fg).unwrap_or(theme.title_fg); + let menubar_bg = menubar_pair + .and_then(|p| if p.bg == ratatui::style::Color::Reset { None } else { Some(p.bg) }) + .unwrap_or(theme.title_bg); + let menu_hot_fg = menubar_pair.map(|p| p.bg).unwrap_or(theme.foreground); + let menu_hot_bg = menubar_pair.map(|p| p.fg).unwrap_or(theme.accent); + + // Top bar: one span per menu title, with the active one + // highlighted. + let mut top_spans: Vec = Vec::new(); + for (i, m) in self.menus.iter().enumerate() { + let style = if i == self.active_menu { + Style::default() + .fg(menu_hot_fg) + .bg(menu_hot_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(menubar_fg).bg(menubar_bg) + }; + top_spans.push(Span::styled(format!(" {} ", m.title), style)); + } + let bar_h = 1u16; + let bar = Paragraph::new(Line::from(top_spans)) + .style(Style::default().fg(menubar_fg).bg(menubar_bg)); + frame.render_widget(bar, Rect::new(area.x, area.y, area.width, bar_h)); + + // Dropdown: only if open. + if !self.dropdown_open { + return; + } + let Some(menu) = self.menus.get(self.active_menu) else { + return; + }; + let mut dropdown_w = 24u16; + for it in &menu.items { + if let MenuItem::Item { label, .. } = it { + let w = label.chars().count() as u16 + 4; + if w > dropdown_w { + dropdown_w = w; + } + } + } + dropdown_w = dropdown_w.min(area.width); + let x = area.x + 1; + let dropdown_h = (menu.items.len() as u16 + 1).min(area.height.saturating_sub(1)); + if dropdown_h == 0 { + return; + } + let dropdown_area = Rect::new(x, area.y + 1, dropdown_w, dropdown_h); + let inner = render_popup(frame, dropdown_area, "", theme); + + // Items: render each label, highlighting the selected one. + let constraint_rows: Vec = (0..menu.items.len()) + .map(|_| Constraint::Length(1)) + .collect(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraint_rows) + .split(inner); + for (i, item) in menu.items.iter().enumerate() { + let area = chunks + .get(i) + .copied() + .unwrap_or(Rect::new(0, 0, 0, 0)); + let style = if i == self.selected_item { + Style::default() + .fg(menu_hot_fg) + .bg(menu_hot_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(menubar_fg).bg(menubar_bg) + }; + let line = match item { + MenuItem::Item { label, hotkey, .. } => render_menu_label(label, *hotkey, style), + MenuItem::Separator => { + Line::from(Span::styled("─".repeat(area.width as usize), style)) + } + }; + let p = Paragraph::new(line).style(style); + frame.render_widget(p, area); + } + // Clear the area behind the dropdown to avoid bleed-through. + let _ = Clear; + } +} + +/// Render a menu label with the hotkey highlighted. +fn render_menu_label(label: &str, hotkey: char, style: Style) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + let mut hotkey_used = false; + for ch in label.chars() { + if !hotkey_used && ch.to_ascii_lowercase() == hotkey.to_ascii_lowercase() { + spans.push(Span::styled( + ch.to_string(), + style.add_modifier(Modifier::UNDERLINED), + )); + hotkey_used = true; + } else { + spans.push(Span::styled(ch.to_string(), style)); + } + } + Line::from(spans) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_starts_closed() { + let mb = ViewerMenuBar::new(); + assert!(!mb.is_open()); + assert!(mb.selected_cmd().is_none()); + } + + #[test] + fn open_sets_first_menu() { + let mut mb = ViewerMenuBar::new(); + mb.open(); + assert!(mb.is_open()); + assert_eq!(mb.active_menu, 0); + assert_eq!(mb.selected_item, 0); + } + + #[test] + fn arrows_cycle_menus() { + let mut mb = ViewerMenuBar::new(); + mb.open(); + mb.handle_key(Key::RIGHT); + assert_eq!(mb.active_menu, 1); + mb.handle_key(Key::RIGHT); + assert_eq!(mb.active_menu, 2); + let n = 5; + for _ in 0..n { + mb.handle_key(Key::RIGHT); + } + assert_eq!(mb.active_menu, (2usize + n) % 5); + } + + #[test] + fn enter_on_separator_does_not_crash() { + let mut mb = ViewerMenuBar::new(); + mb.open(); + mb.handle_key(Key::DOWN); + mb.handle_key(Key::ENTER); + } + + #[test] + fn esc_closes() { + let mut mb = ViewerMenuBar::new(); + mb.open(); + assert!(mb.handle_key(Key::ESCAPE)); + assert!(!mb.is_open()); + } +} diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index fdfd4a9cff..eb4efbb966 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -12,6 +12,7 @@ pub mod goto; pub mod hex; pub mod hex_edit; pub mod magic; +pub mod menubar; pub mod nroff; pub mod search; pub mod siblings; @@ -110,6 +111,8 @@ pub struct Viewer { /// Total render-frame count. Used to drive animation frames /// (the loading spinner cycles through glyphs based on this). frame_count: u64, + /// F9 menubar state. `None` when the menubar is not active. + pub menubar: Option, /// Syntax highlighter (when `syntect` is on and the file /// extension is recognised). The renderer feeds lines to it /// in order so multi-line constructs (block comments, strings) @@ -171,6 +174,7 @@ impl Viewer { prompt_input: String::new(), loading: size >= crate::viewer::source::INLINE_THRESHOLD, frame_count: 0, + menubar: None, #[cfg(feature = "syntect")] highlighter, #[cfg(feature = "syntect")] @@ -219,6 +223,7 @@ impl Viewer { hex_edit_modified: false, last_height: 0, flash_msg: None, + menubar: None, } } @@ -641,6 +646,28 @@ impl Viewer { if area.width == 0 || area.height == 0 { return; } + // F9 menubar: occupies the top row when active. Reduce + // the content area by 1 row so the menubar overlays the + // very top of the viewer without colliding with content. + let area = if let Some(mb) = self.menubar.as_ref() { + if mb.is_open() { + mb.render( + frame, + Rect::new(area.x, area.y, area.width, 1), + theme, + ); + Rect::new( + area.x, + area.y + 1, + area.width, + area.height.saturating_sub(1), + ) + } else { + area + } + } else { + area + }; self.frame_count = self.frame_count.wrapping_add(1); // Auto-clear loading after a small number of render frames // so the spinner is visibly transient even for chunked @@ -796,6 +823,30 @@ impl Viewer { if key == Key::f(10) || key == Key::ctrl('q') { return true; } + // F9 toggles the menubar. + if key == Key::f(9) { + if self.menubar.is_some() { + self.menubar = None; + } else { + let mut mb = menubar::ViewerMenuBar::new(); + mb.open(); + self.menubar = Some(mb); + } + return true; + } + // Route other keys to the menubar when it's open. + if self.menubar.is_some() { + let mut mb = self.menubar.take().unwrap(); + let consumed = mb.handle_key(key); + if consumed { + // If the menubar dispatched a command, execute it. + if let Some(cmd) = mb.selected_cmd() { + self.execute_menubar_cmd(cmd); + } + } + self.menubar = Some(mb); + return consumed; + } // Esc without an active prompt closes the viewer (matches // the previous MC parity behavior). if key == Key::ESCAPE { @@ -972,6 +1023,57 @@ impl Viewer { } } + /// Execute a [`ViewerCmd`] selected from the F9 menubar. + /// Maps the menubar command to the existing viewer methods + /// (search, goto, bookmarks, mode toggles, file navigation). + pub fn execute_menubar_cmd(&mut self, cmd: menubar::ViewerCmd) { + use menubar::ViewerCmd; + match cmd { + ViewerCmd::Nop => {} + ViewerCmd::ToggleHex => { + if self.mode == ViewMode::Text { + self.mode = ViewMode::Hex; + } else if self.mode == ViewMode::Hex { + self.mode = ViewMode::Text; + } + } + ViewerCmd::ToggleMagic => { + self.magic_mode = !self.magic_mode; + } + ViewerCmd::ToggleWrap => { + self.wrap = !self.wrap; + } + ViewerCmd::ToggleGrowing => { + let _ = self.toggle_growing(); + } + ViewerCmd::Search => { + self.prompt = Some(ViewerPrompt::Search); + self.prompt_input.clear(); + } + ViewerCmd::SearchNext => { + let _ = self.search_next(); + } + ViewerCmd::SearchPrev => { + let _ = self.search_prev(); + } + ViewerCmd::Goto => { + self.prompt = Some(ViewerPrompt::GotoLine); + self.prompt_input.clear(); + } + ViewerCmd::BookmarkToggle + | ViewerCmd::BookmarkNext + | ViewerCmd::BookmarkPrev + | ViewerCmd::NextFile + | ViewerCmd::PrevFile + | ViewerCmd::Close => { + // Bookmark / file navigation / close are not yet + // wired to menubar commands. They are still reachable + // via their keyboard shortcuts. This is a planned + // follow-up. + } + } + } + /// Handle a key while a [`ViewerPrompt`] is active. Enter /// commits (search runs / goto-line jumps) and closes the /// prompt; Esc cancels without committing; printable chars