diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index 9a43cb1628..a419f85e7e 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -325,6 +325,17 @@ impl App { self.net = crate::network::NetInfo::read(); } + // Storage device traffic (read_bytes / write_bytes from + // /sys/block//stat) accumulates monotonically. Refresh + // every 11th tick (5.5 sec at POLL_MS=500). The 11-tick modulus + // is coprime to all other moduli (3, 4, 5, 7), so storage + // reads never synchronize with any other data source. 5.5 sec + // is sufficient because disk I/O is bursty and a finer + // cadence just adds noise. + if self.refresh_counter % 11 == 0 { + self.storage = crate::storage::StorageInfo::read(); + } + for row in &mut self.cpus { if let Some(status) = read_thermal_status(row.id) { row.temp_c = if status & THERM_STATUS_READOUT_VALID != 0 { diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index 3b9cbbd1a2..f0ffa92072 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -55,7 +55,7 @@ use crate::app::{App, POLL_MS, TabId}; use crate::render::{ render_battery_panel, render_controls, render_cpu_table, render_header, render_help, render_info_panel, render_motherboard_panel, render_network_panel, render_once, - render_prochot_alert, render_sensor_panel, render_system_panel, + render_prochot_alert, render_sensor_panel, render_storage_panel, render_system_panel, render_tab_bar, snapshot, }; @@ -336,6 +336,12 @@ fn main() -> io::Result<()> { body_area, ); } + TabId::Storage => { + f.render_widget( + render_storage_panel(&app, focused_panel == 1), + body_area, + ); + } } f.render_widget(render_controls(&app, focused_panel == 2), controls_area); if let Some(alert) = render_prochot_alert(&app, f) { @@ -395,6 +401,7 @@ fn main() -> io::Result<()> { Key::Char('5') => app.current_tab = app::TabId::Battery, Key::Char('6') => app.current_tab = app::TabId::Sensors, Key::Char('7') => app.current_tab = app::TabId::Network, + Key::Char('8') => app.current_tab = app::TabId::Storage, Key::Char('T') => app.current_tab = app.current_tab.next(), Key::Char('?') => show_help = !show_help, Key::Char('g') => app.cycle_governor(), diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index 1d82631ecf..f794e54e5e 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -756,6 +756,82 @@ pub fn render_network_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { .wrap(Wrap { trim: true }) } +pub fn render_storage_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { + let storage = &app.storage; + if storage.is_empty() { + return Paragraph::new(Line::from( + "(no storage devices detected — /sys/block/ not readable)".set_style(theme::VALUE_WARM), + )) + .block(panel_border(focused, " Storage ")) + .wrap(Wrap { trim: true }); + } + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(format!( + "Detected {} disk(s):", + storage.count() + ).set_style(theme::LABEL_BOLD))); + lines.push(Line::from("")); + for disk in &storage.disks { + lines.push(Line::from(format!( + "▸ {} ({})", + disk.name, + disk.kind_label() + ).set_style(theme::LABEL_BOLD))); + if let Some(model) = &disk.model { + lines.push(Line::from(vec![ + " Model: ".set_style(theme::LABEL), + model.clone().set_style(theme::VALUE), + ])); + } + if let Some(vendor) = &disk.vendor { + let trimmed = vendor.trim(); + if !trimmed.is_empty() { + lines.push(Line::from(vec![ + " Vendor: ".set_style(theme::LABEL), + trimmed.set_style(theme::VALUE), + ])); + } + } + lines.push(Line::from(vec![ + " Size: ".set_style(theme::LABEL), + crate::storage::DiskInfo::format_size(disk.size_bytes).set_style(theme::VALUE), + ])); + if let Some(sched) = &disk.scheduler { + let sched_trimmed: String = sched.chars().take(60).collect(); + lines.push(Line::from(vec![ + " Scheduler:".set_style(theme::LABEL), + format!(" {}", sched_trimmed).set_style(theme::VALUE), + ])); + } + if let Some(qd) = disk.queue_depth { + lines.push(Line::from(vec![ + " Queue: ".set_style(theme::LABEL), + format!("{} requests", qd).set_style(theme::VALUE), + ])); + } + lines.push(Line::from(vec![ + " Read: ".set_style(theme::LABEL), + crate::storage::DiskInfo::format_size(disk.stats.read_bytes).set_style(theme::VALUE), + format!(" ({} I/Os)", disk.stats.reads_completed).set_style(theme::VALUE_OFF), + ])); + lines.push(Line::from(vec![ + " Written: ".set_style(theme::LABEL), + crate::storage::DiskInfo::format_size(disk.stats.write_bytes).set_style(theme::VALUE), + format!(" ({} I/Os)", disk.stats.writes_completed).set_style(theme::VALUE_OFF), + ])); + if !disk.partitions.is_empty() { + lines.push(Line::from(vec![ + " Parts: ".set_style(theme::LABEL), + disk.partitions.join(", ").set_style(theme::VALUE), + ])); + } + lines.push(Line::from("")); + } + Paragraph::new(lines) + .block(panel_border(focused, " Storage ")) + .wrap(Wrap { trim: true }) +} + pub fn render_cpu_table<'a>( cpus: &'a [CpuRow], expanded_cpu: Option, @@ -1160,5 +1236,15 @@ pub fn render_once(app: &App) -> io::Result<()> { }) .expect("draw"); print!("{}", buffer_to_string(terminal.backend().buffer())); + eprintln!("--- Storage panel (verifies v1.12 sysfs) ---"); + let sto_para = render_storage_panel(app, false); + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|f| { + f.render_widget(sto_para, f.area()); + }) + .expect("draw"); + print!("{}", buffer_to_string(terminal.backend().buffer())); Ok(()) } \ No newline at end of file diff --git a/local/recipes/system/redbear-power/source/src/storage.rs b/local/recipes/system/redbear-power/source/src/storage.rs index ce9bfa4f93..81d9931bce 100644 --- a/local/recipes/system/redbear-power/source/src/storage.rs +++ b/local/recipes/system/redbear-power/source/src/storage.rs @@ -172,4 +172,90 @@ impl StorageInfo { pub fn count(&self) -> usize { self.disks.len() } -} \ No newline at end of file +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_size_below_1kib() { + assert_eq!(DiskInfo::format_size(500), "500.0 B"); + } + + #[test] + fn format_size_1kib() { + assert_eq!(DiskInfo::format_size(1024), "1.0 KiB"); + } + + #[test] + fn format_size_1gib() { + assert_eq!(DiskInfo::format_size(1024 * 1024 * 1024), "1.0 GiB"); + } + + #[test] + fn format_size_1tib() { + assert_eq!( + DiskInfo::format_size(1024u64.pow(4)), + "1.0 TiB" + ); + } + + #[test] + fn disk_stats_parse_real_line() { + let line = "269817834 0 1079271336 12345 152004989 0 27200423456 23456 0 0 0 0 0 0 12345"; + let s = DiskStats::parse(line); + assert_eq!(s.reads_completed, 269817834); + assert_eq!(s.read_bytes, 1079271336); + assert_eq!(s.writes_completed, 152004989); + assert_eq!(s.write_bytes, 27200423456); + } + + #[test] + fn disk_stats_parse_empty_line() { + let s = DiskStats::parse(""); + assert_eq!(s.reads_completed, 0); + assert_eq!(s.write_bytes, 0); + } + + #[test] + fn disk_stats_kbps_delta_positive() { + let prev = DiskStats { read_bytes: 1000, write_bytes: 500, reads_completed: 0, writes_completed: 0 }; + let now = DiskStats { read_bytes: 5000, write_bytes: 1500, reads_completed: 0, writes_completed: 0 }; + let (r, w) = DiskStats::kbps_delta(&now, &prev, 2.0); + assert_eq!(r, 1.953125); // (5000-1000)/2/1024 + assert_eq!(w, 0.48828125); // (1500-500)/2/1024 + } + + #[test] + fn disk_stats_kbps_delta_zero_dt() { + let prev = DiskStats::default(); + let now = DiskStats::default(); + let (r, w) = DiskStats::kbps_delta(&now, &prev, 0.0); + assert_eq!(r, 0.0); + assert_eq!(w, 0.0); + } + + #[test] + fn storage_info_is_empty_when_no_sys_block() { + let info = StorageInfo::default(); + assert!(info.is_empty()); + assert_eq!(info.count(), 0); + } + + #[test] + fn disk_info_kind_label() { + let nvme = DiskInfo { name: "nvme0n1".to_string(), path: PathBuf::from("/sys/block/nvme0n1"), ..Default::default() }; + assert_eq!(nvme.kind_label(), "NVMe SSD"); + + let ssd = DiskInfo { name: "sda".to_string(), path: PathBuf::from("/sys/block/sda"), ..Default::default() }; + assert_eq!(ssd.kind_label(), "SSD"); + + let mut hdd = DiskInfo::default(); + hdd.rotational = true; + assert_eq!(hdd.kind_label(), "HDD"); + + let mut removable = DiskInfo::default(); + removable.removable = true; + assert_eq!(removable.kind_label(), "Removable"); + } +} diff --git a/local/recipes/tui/tlc/source/src/editor/menubar.rs b/local/recipes/tui/tlc/source/src/editor/menubar.rs index 6c278b099d..2b903ccaaf 100644 --- a/local/recipes/tui/tlc/source/src/editor/menubar.rs +++ b/local/recipes/tui/tlc/source/src/editor/menubar.rs @@ -1,16 +1,16 @@ //! Menu bar for the editor (F9). //! //! Mirrors `filemanager::menubar::MenuBar` — six top-level menus -//! (File, Edit, Search, Bookmark, Goto, Options) with hotkey +//! (File, Edit, Search, Bookmark, Goto, Options) with arrow-key //! navigation, dropdown rendering, and item dispatch. use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Clear, Paragraph}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; -use crate::key::{Key, Modifiers}; +use crate::key::Key; use crate::terminal::color::Theme; use crate::terminal::mc_skin; @@ -20,28 +20,51 @@ use crate::terminal::mc_skin; /// `MC src/editor/editmenu.c` / `edit.h`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum EditorCmd { + /// No-op (used for separators). Nop, + /// Ctrl-N — open a new (empty) buffer. New, + /// Ctrl-O — open a file. Open, + /// Ctrl-S — save the current buffer. Save, + /// F2 — save as a new path. SaveAs, + /// Ctrl-Q — quit the editor. Quit, + /// Ctrl-Z — undo. Undo, + /// Ctrl-Y / Shift-Ctrl-Z — redo. Redo, + /// Ctrl-X — cut selection. Cut, + /// Ctrl-C — copy selection. Copy, + /// Ctrl-V — paste from clipboard. Paste, + /// Ctrl-F — find. Find, + /// F3 / Shift-F7 — find next. FindNext, + /// Shift-F3 — find previous. FindPrev, + /// Ctrl-H — replace. Replace, + /// Toggle bookmark at cursor (Alt-M). BookmarkToggle, + /// Jump to next bookmark. BookmarkNext, + /// Jump to previous bookmark. BookmarkPrev, + /// Flush all bookmarks. BookmarkClearAll, + /// Alt-L — go to a typed line number. GotoLine, + /// Ctrl-Home — go to top of buffer. GotoTop, + /// Ctrl-End — go to bottom of buffer. GotoBottom, + /// Open settings dialog. Settings, } @@ -50,23 +73,47 @@ pub enum EditorCmd { pub enum EditorMenuOutcome { /// Key was consumed but nothing to report. Handled, - /// Close the menu bar (Esc / F10). + /// Close the menu bar (Esc / F10 / F9 again). Close, /// Dispatch an editor command. Dispatch(EditorCmd), - /// Select a specific menu item (menu_idx, item_idx). + /// Select a specific menu item by (menu_idx, item_idx). Select(usize, usize), } /// A single item inside a dropdown menu. #[derive(Debug, Clone)] pub struct MenuItem { + /// Display label, e.g. "Open..." or "---". pub label: String, + /// Command to dispatch when Enter is pressed. pub cmd: EditorCmd, + /// Single-letter hotkey that activates the item from within the + /// dropdown (e.g. 'o' for "Open"). pub hotkey: char, } impl MenuItem { + /// Construct a regular menu item. + pub fn item(label: &str, hotkey: char, cmd: EditorCmd) -> Self { + Self { + label: label.to_string(), + cmd, + hotkey, + } + } + + /// Construct a horizontal separator. Labels starting with `"---"` + /// are treated as separators (non-selectable, non-dispatchable). + pub fn separator() -> Self { + Self { + label: "---".to_string(), + cmd: EditorCmd::Nop, + hotkey: '\0', + } + } + + /// True if this is a separator entry. pub fn is_separator(&self) -> bool { self.label.starts_with("---") } @@ -75,77 +122,102 @@ impl MenuItem { /// One top-level menu (e.g. "File") with its dropdown items. #[derive(Debug, Clone)] pub struct Menu { + /// Top-bar title, e.g. "File". pub title: String, + /// Dropdown items, in render order. pub items: Vec, } +impl Menu { + /// Construct a menu with a title and items. + pub fn new(title: &str, items: Vec) -> Self { + Self { + title: title.to_string(), + items, + } + } +} + /// The editor menu bar state. #[derive(Debug, Clone)] pub struct EditorMenuBar { + /// 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 EditorMenuBar { + fn default() -> Self { + Self::new() + } +} + impl EditorMenuBar { + /// Construct the default MC-parity menu set: File, Edit, Search, + /// Bookmark, Goto, Options. + #[must_use] pub fn new() -> Self { - use EditorCmd::*; let menus = vec![ - Menu { - title: "File".to_string(), - items: vec![ - MenuItem { label: "New".to_string(), cmd: New, hotkey: 'n' }, - MenuItem { label: "Open...".to_string(), cmd: Open, hotkey: 'o' }, - MenuItem { label: "Save".to_string(), cmd: Save, hotkey: 's' }, - MenuItem { label: "Save as...".to_string(), cmd: SaveAs, hotkey: 'a' }, - MenuItem { label: "---".to_string(), cmd: Nop, hotkey: '\0' }, - MenuItem { label: "Quit".to_string(), cmd: Quit, hotkey: 'q' }, + Menu::new( + "File", + vec![ + MenuItem::item("New", 'n', EditorCmd::New), + MenuItem::item("Open...", 'o', EditorCmd::Open), + MenuItem::separator(), + MenuItem::item("Save", 's', EditorCmd::Save), + MenuItem::item("Save as...", 'a', EditorCmd::SaveAs), + MenuItem::separator(), + MenuItem::item("Quit", 'q', EditorCmd::Quit), ], - }, - Menu { - title: "Edit".to_string(), - items: vec![ - MenuItem { label: "Undo".to_string(), cmd: Undo, hotkey: 'u' }, - MenuItem { label: "Redo".to_string(), cmd: Redo, hotkey: 'r' }, - MenuItem { label: "---".to_string(), cmd: Nop, hotkey: '\0' }, - MenuItem { label: "Cut".to_string(), cmd: Cut, hotkey: 'x' }, - MenuItem { label: "Copy".to_string(), cmd: Copy, hotkey: 'c' }, - MenuItem { label: "Paste".to_string(), cmd: Paste, hotkey: 'v' }, + ), + Menu::new( + "Edit", + vec![ + MenuItem::item("Undo", 'u', EditorCmd::Undo), + MenuItem::item("Redo", 'r', EditorCmd::Redo), + MenuItem::separator(), + MenuItem::item("Cut", 'x', EditorCmd::Cut), + MenuItem::item("Copy", 'c', EditorCmd::Copy), + MenuItem::item("Paste", 'v', EditorCmd::Paste), ], - }, - Menu { - title: "Search".to_string(), - items: vec![ - MenuItem { label: "Find...".to_string(), cmd: Find, hotkey: 'f' }, - MenuItem { label: "Find next".to_string(), cmd: FindNext, hotkey: 'n' }, - MenuItem { label: "Find prev".to_string(), cmd: FindPrev, hotkey: 'p' }, - MenuItem { label: "Replace...".to_string(), cmd: Replace, hotkey: 'r' }, + ), + Menu::new( + "Search", + vec![ + MenuItem::item("Find...", 'f', EditorCmd::Find), + MenuItem::item("Find next", 'n', EditorCmd::FindNext), + MenuItem::item("Find prev", 'p', EditorCmd::FindPrev), + MenuItem::item("Replace...", 'r', EditorCmd::Replace), ], - }, - Menu { - title: "Bookmark".to_string(), - items: vec![ - MenuItem { label: "Toggle".to_string(), cmd: BookmarkToggle, hotkey: 't' }, - MenuItem { label: "Next".to_string(), cmd: BookmarkNext, hotkey: 'n' }, - MenuItem { label: "Prev".to_string(), cmd: BookmarkPrev, hotkey: 'p' }, - MenuItem { label: "Clear all".to_string(), cmd: BookmarkClearAll, hotkey: 'c' }, + ), + Menu::new( + "Bookmark", + vec![ + MenuItem::item("Toggle", 't', EditorCmd::BookmarkToggle), + MenuItem::item("Next", 'n', EditorCmd::BookmarkNext), + MenuItem::item("Prev", 'p', EditorCmd::BookmarkPrev), + MenuItem::item("Clear all", 'c', EditorCmd::BookmarkClearAll), ], - }, - Menu { - title: "Goto".to_string(), - items: vec![ - MenuItem { label: "Line...".to_string(), cmd: GotoLine, hotkey: 'l' }, - MenuItem { label: "Top".to_string(), cmd: GotoTop, hotkey: 't' }, - MenuItem { label: "Bottom".to_string(), cmd: GotoBottom, hotkey: 'b' }, + ), + Menu::new( + "Goto", + vec![ + MenuItem::item("Line...", 'l', EditorCmd::GotoLine), + MenuItem::item("Top", 't', EditorCmd::GotoTop), + MenuItem::item("Bottom", 'b', EditorCmd::GotoBottom), ], - }, - Menu { - title: "Options".to_string(), - items: vec![ - MenuItem { label: "Settings...".to_string(), cmd: Settings, hotkey: 's' }, - ], - }, + ), + Menu::new( + "Options", + vec![MenuItem::item("Settings...", 's', EditorCmd::Settings)], + ), ]; Self { menus, @@ -155,72 +227,95 @@ impl EditorMenuBar { } } + /// Number of top-level menus. + #[must_use] pub fn menu_count(&self) -> usize { self.menus.len() } - pub fn menu_titles(&self) -> Vec { - self.menus.iter().map(|m| m.title.clone()).collect() - } - + /// Currently active top-level menu index. + #[must_use] pub fn active_menu(&self) -> usize { self.active_menu } - pub fn set_active_menu(&mut self, idx: usize) { - self.active_menu = idx.min(self.menus.len() - 1); - } - + /// Whether the dropdown is currently open (vs. just the bar). + #[must_use] pub fn is_dropdown_open(&self) -> bool { self.dropdown_open } - pub fn active_items(&self) -> Vec<(String, EditorCmd)> { - let menu = &self.menus[self.active_menu]; - menu.items.iter().map(|it| (it.label.clone(), it.cmd.clone())).collect() - } - + /// Index of the highlighted item within the active menu's + /// dropdown. + #[must_use] pub fn selected_item(&self) -> usize { self.selected_item } - /// Handle a key press while the menu bar is active. + /// Set the active menu by index. Clamped to the valid range. + pub fn set_active_menu(&mut self, idx: usize) { + if self.menus.is_empty() { + return; + } + self.active_menu = idx.min(self.menus.len() - 1); + } + + /// Borrow the items of the active menu (label + cmd). + #[must_use] + pub fn active_items(&self) -> Vec<(String, EditorCmd)> { + if self.menus.is_empty() { + return Vec::new(); + } + self.menus[self.active_menu] + .items + .iter() + .map(|it| (it.label.clone(), it.cmd)) + .collect() + } + + /// Handle a key press while the menu bar is active. Routes to + /// the active menu's navigation: arrows move, Enter dispatches, + /// Esc/F9 closes. pub fn handle_key(&mut self, key: Key) -> EditorMenuOutcome { use EditorMenuOutcome::*; + const LEFT: u32 = 0x2190; + const RIGHT: u32 = 0x2192; + const UP: u32 = 0x2191; + const DOWN: u32 = 0x2193; - if key.code == Key::ESCAPE.code - || (key.code == Key::f(9).code && key.mods.is_empty()) - { + // Esc / F9 (alone, no modifiers) → close. + if key == Key::ESCAPE || (key == Key::f(9) && key.mods.is_empty()) { return Close; } if !self.dropdown_open { match key.code { - c if c == Key::LEFT.code => { - self.active_menu = self.active_menu.wrapping_sub(1); - if self.active_menu >= self.menus.len() { + LEFT => { + if self.active_menu == 0 { self.active_menu = self.menus.len() - 1; + } else { + self.active_menu -= 1; } + self.selected_item = 0; return Handled; } - c if c == Key::RIGHT.code => { + RIGHT => { self.active_menu = (self.active_menu + 1) % self.menus.len(); + self.selected_item = 0; return Handled; } - c if c == Key::DOWN.code => { + DOWN => { self.dropdown_open = true; self.selected_item = 0; return Handled; } c if c == Key::ENTER.code => { - // Enter on closed bar → open dropdown self.dropdown_open = true; self.selected_item = 0; return Handled; } _ => { - // Letter hotkey selects menu by title - if let Some(ch) = key.to_char() { + if let Some(ch) = Self::key_to_char(key) { let ch_lower = ch.to_ascii_lowercase(); for (i, menu) in self.menus.iter().enumerate() { if menu.title.to_ascii_lowercase().starts_with(ch_lower) { @@ -236,26 +331,37 @@ impl EditorMenuBar { } } - // Dropdown is open + // Dropdown is open. match key.code { - c if c == Key::ESCAPE.code => { - self.dropdown_open = false; - Close + LEFT => { + if self.active_menu == 0 { + self.active_menu = self.menus.len() - 1; + } else { + self.active_menu -= 1; + } + self.selected_item = 0; + return Handled; } - c if c == Key::UP.code => { + RIGHT => { + self.active_menu = (self.active_menu + 1) % self.menus.len(); + self.selected_item = 0; + return Handled; + } + UP => { let items = &self.menus[self.active_menu].items; loop { - self.selected_item = self.selected_item.wrapping_sub(1); - if self.selected_item >= items.len() { + if self.selected_item == 0 { self.selected_item = items.len() - 1; + } else { + self.selected_item -= 1; } if !items[self.selected_item].is_separator() { break; } } - Handled + return Handled; } - c if c == Key::DOWN.code => { + DOWN => { let items = &self.menus[self.active_menu].items; loop { self.selected_item = (self.selected_item + 1) % items.len(); @@ -263,25 +369,28 @@ impl EditorMenuBar { break; } } - Handled + return Handled; } c if c == Key::ENTER.code => { let menu = &self.menus[self.active_menu]; let item = &menu.items[self.selected_item]; - let cmd = item.cmd.clone(); - self.dropdown_open = false; - Dispatch(cmd) + let cmd = item.cmd; + if matches!(cmd, EditorCmd::Nop) { + return Handled; + } + return Dispatch(cmd); } _ => { - // Letter hotkey inside dropdown - if let Some(ch) = key.to_char() { + if let Some(ch) = Self::key_to_char(key) { let ch_lower = ch.to_ascii_lowercase(); - let items = &self.menus[self.active_menu].items; + let items = self.menus[self.active_menu].items.clone(); for (i, item) in items.iter().enumerate() { - if item.hotkey == ch_lower { + if item.hotkey != '\0' && item.hotkey == ch_lower { self.selected_item = i; - let cmd = item.cmd.clone(); - self.dropdown_open = false; + let cmd = item.cmd; + if matches!(cmd, EditorCmd::Nop) { + return Handled; + } return Dispatch(cmd); } } @@ -291,6 +400,17 @@ impl EditorMenuBar { } } + /// Decode a printable ASCII letter from a Key (if any). Returns + /// `None` for non-letter keys. + fn key_to_char(key: Key) -> Option { + if key.code >= 0x20 && key.code <= 0x7E { + if key.mods.is_empty() { + return Some(key.code as u8 as char); + } + } + None + } + /// Render the menu bar at the top of `area`. Mirrors /// `filemanager::menubar::MenuBar::render` — top row of menu /// titles with active title highlighted, dropdown panel below @@ -327,7 +447,10 @@ impl EditorMenuBar { } else { menu_hot.unwrap_or(pair) }; - spans.push(Span::styled(" ", Style::default().fg(pair.fg).bg(pair.bg))); + spans.push(Span::styled( + " ", + Style::default().fg(pair.fg).bg(pair.bg), + )); let mut chars = menu.title.chars(); if let Some(first) = chars.next() { spans.push(Span::styled( @@ -349,7 +472,10 @@ impl EditorMenuBar { )); } } - spans.push(Span::styled(" ", Style::default().fg(pair.fg).bg(pair.bg))); + spans.push(Span::styled( + " ", + Style::default().fg(pair.fg).bg(pair.bg), + )); x_offset += w; } spans.push(Span::styled( @@ -370,8 +496,7 @@ impl EditorMenuBar { fn render_dropdown(&self, frame: &mut Frame, area: Rect, theme: &Theme) { let menu = &self.menus[self.active_menu]; let items = &menu.items; - // Position dropdown under the active title's first character - // (left edge of bar at x=1). + // Position dropdown under the active title's first character. let mut x = 1u16; for (i, m) in self.menus.iter().enumerate() { if i == self.active_menu { @@ -394,8 +519,8 @@ impl EditorMenuBar { frame.render_widget(Clear, dropdown_area); - let block = ratatui::widgets::Block::default() - .borders(ratatui::widgets::Borders::ALL) + let block = Block::default() + .borders(Borders::ALL) .border_style( Style::default().fg(theme.title_fg).bg( mc_skin::color_pair(theme.name, "menu", "_default_") @@ -415,7 +540,12 @@ impl EditorMenuBar { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints(items.iter().map(|_| Constraint::Length(1)).collect::>()) + .constraints( + items + .iter() + .map(|_| Constraint::Length(1)) + .collect::>(), + ) .split(inner); for (idx, item) in items.iter().enumerate() { @@ -430,7 +560,7 @@ impl EditorMenuBar { } else { Style::default().fg(theme.foreground).bg(theme.background) }; - let line = Line::from(Span::styled(item.label, style)); + let line = Line::from(Span::styled(item.label.clone(), style)); frame.render_widget(Paragraph::new(line), chunks[idx]); } } @@ -444,11 +574,21 @@ mod tests { EditorMenuBar::new() } + fn k(c: char) -> Key { + Key { + code: c as u32, + mods: crate::key::Modifiers::empty(), + } + } + #[test] fn new_has_six_menus() { let m = mb(); assert_eq!(m.menu_count(), 6); - assert_eq!(m.menu_titles(), vec!["File", "Edit", "Search", "Bookmark", "Goto", "Options"]); + assert_eq!( + m.active_items().len(), + 7 // File menu has 7 entries + ); } #[test] @@ -458,7 +598,7 @@ mod tests { } #[test] - fn f9_closes() { + fn f9_closes_when_alone() { let mut m = mb(); assert_eq!(m.handle_key(Key::f(9)), EditorMenuOutcome::Close); } @@ -469,16 +609,9 @@ mod tests { assert_eq!(m.active_menu(), 0); m.handle_key(Key { code: 0x2192, - mods: Modifiers::empty(), + mods: crate::key::Modifiers::empty(), }); assert_eq!(m.active_menu(), 1); - for _ in 0..5 { - m.handle_key(Key { - code: 0x2192, - mods: Modifiers::empty(), - }); - } - assert_eq!(m.active_menu(), 0); // wrapped } #[test] @@ -486,57 +619,82 @@ mod tests { let mut m = mb(); m.handle_key(Key { code: 0x2190, - mods: Modifiers::empty(), + mods: crate::key::Modifiers::empty(), }); assert_eq!(m.active_menu(), m.menu_count() - 1); } #[test] - fn down_skips_separators_and_dispatches_first_real() { + fn down_opens_dropdown_and_selects_first() { let mut m = mb(); - // File menu starts with "New" — no leading separator. - assert_eq!(m.active_items()[0].0, "New"); m.handle_key(Key { code: 0x2193, - mods: Modifiers::empty(), + mods: crate::key::Modifiers::empty(), }); - assert_eq!(m.active_items()[m.selected_item()].0, "Open..."); + assert!(m.is_dropdown_open()); + assert_eq!(m.selected_item(), 0); + } + + #[test] + fn down_skips_separators() { + let mut m = mb(); + m.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + // File menu: New(0), Open(1), ---(2), Save(3)... + // selected_item starts at 0 (New, not separator). + m.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + // Down from 0 → 1 (Open, not separator). + assert_eq!(m.selected_item(), 1); + m.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + // Down from 1 → 2 (---, separator) → 3 (Save). + assert_eq!(m.selected_item(), 3); } #[test] fn enter_dispatches_active_item() { let mut m = mb(); - m.set_active_menu(0); - m.handle_key(Key::ENTER); - // First item is "New" → EditorCmd::New. - // We can only check the outcome variant here, not the value, - // because handle_key consumed by value. - // Use a fresh bar: - let mut m = mb(); - m.handle_key(Key::ENTER); - // Can't inspect via Drop, but we can dispatch a known item: - m.set_active_menu(0); // File - m.handle_key(Key::ENTER); // should dispatch New - // Already dispatched above — use a new bar for assertion: - let mut m = mb(); - // Skip past "New" via Down, then Enter — should dispatch "Open..." + // Open File menu dropdown. m.handle_key(Key { code: 0x2193, - mods: Modifiers::empty(), + mods: crate::key::Modifiers::empty(), }); let outcome = m.handle_key(Key::ENTER); match outcome { - EditorMenuOutcome::Dispatch(EditorCmd::Open) => {} - other => panic!("expected Dispatch(Open), got {other:?}"), + EditorMenuOutcome::Dispatch(EditorCmd::New) => {} + other => panic!("expected Dispatch(New), got {other:?}"), } } + #[test] + fn letter_hotkey_selects_menu_by_title() { + let mut m = mb(); + // 'S' should jump to Search menu (third). + let _ = m.handle_key(k('s')); + // 's' might match 's' for Search (Se...) or s in 'Bookmark'... + // First match wins. The first menu whose title starts with + // the given char wins. Title 'File' starts with 'f' (no match). + // 'Edit' starts with 'e' (no). 'Search' starts with 's' (match). + assert_eq!(m.active_menu(), 2); + } + #[test] fn render_smoke() { let mut m = mb(); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); - terminal.draw(|f| { - m.render(f, f.size(), &Theme::default()); - }).unwrap(); + let backend = ratatui::backend::TestBackend::new(80, 24); + let mut terminal = + ratatui::Terminal::new(backend).expect("create test terminal"); + terminal + .draw(|f| { + m.render(f, f.area(), &crate::terminal::color::DEFAULT_THEME); + }) + .expect("render"); } -} +} \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/key/mod.rs b/local/recipes/tui/tlc/source/src/key/mod.rs index 052d22f72b..e090ee7486 100644 --- a/local/recipes/tui/tlc/source/src/key/mod.rs +++ b/local/recipes/tui/tlc/source/src/key/mod.rs @@ -50,6 +50,35 @@ impl Key { code: 0x7F, mods: Modifiers::empty(), }; + /// The "Left" arrow key. + pub const LEFT: Key = Key { + code: 0x2190, + mods: Modifiers::empty(), + }; + /// The "Right" arrow key. + pub const RIGHT: Key = Key { + code: 0x2192, + mods: Modifiers::empty(), + }; + /// The "Up" arrow key. + pub const UP: Key = Key { + code: 0x2191, + mods: Modifiers::empty(), + }; + /// The "Down" arrow key. + pub const DOWN: Key = Key { + code: 0x2193, + mods: Modifiers::empty(), + }; + + /// Convert to a printable character, if any. + pub fn to_char(self) -> Option { + if self.mods.is_empty() && self.code >= 0x20 && self.code <= 0x7E { + char::from_u32(self.code) + } else { + None + } + } /// Construct a function-key constant (F1..F12). #[must_use]