tlc: add missing key constants (LEFT/RIGHT/UP/DOWN) and to_char() method

This commit is contained in:
2026-06-20 19:57:00 +03:00
parent 714f7c2115
commit 0d999dc4ed
6 changed files with 524 additions and 147 deletions
@@ -325,6 +325,17 @@ impl App {
self.net = crate::network::NetInfo::read();
}
// Storage device traffic (read_bytes / write_bytes from
// /sys/block/<dev>/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 {
@@ -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(),
@@ -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<Line<'a>> = 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<u32>,
@@ -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(())
}
@@ -172,4 +172,90 @@ impl StorageInfo {
pub fn count(&self) -> usize {
self.disks.len()
}
}
}
#[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");
}
}
+303 -145
View File
@@ -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<MenuItem>,
}
impl Menu {
/// Construct a menu with a title and items.
pub fn new(title: &str, items: Vec<MenuItem>) -> Self {
Self {
title: title.to_string(),
items,
}
}
}
/// The editor menu bar state.
#[derive(Debug, Clone)]
pub struct EditorMenuBar {
/// All top-level menus.
menus: Vec<Menu>,
/// 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<String> {
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<char> {
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::<Vec<_>>())
.constraints(
items
.iter()
.map(|_| Constraint::Length(1))
.collect::<Vec<_>>(),
)
.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");
}
}
}
@@ -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<char> {
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]