diff --git a/Cargo.lock b/Cargo.lock index 23272ef..a62309d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1701,6 +1701,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "unicode-width 0.2.0", "windows-native-keyring-store", "zbus-secret-service-keyring-store", ] diff --git a/Cargo.toml b/Cargo.toml index f2276bc..f2bc02d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ dirs = "5.0" inquire = "0.7" keyring-core = { version = "1.0", optional = true } +unicode-width = "0.2" [target.'cfg(all(unix, not(any(target_os = "macos", target_os = "ios"))))'.dependencies] # rt-tokio-crypto-rust: reuse the Tokio runtime MikroTUI already has, and take the diff --git a/README.md b/README.md index e551e25..d1d543f 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ mikrotui exec "/ip address print" | Shortcut | Action | | :--- | :--- | | **Tab / Shift+Tab** | Switch active menu tab (or use `←` / `→` Arrow keys / `h` / `l`) | +| **1** – **8** | Jump straight to a tab, by the number shown beside it | | **↑ / ↓** (or `k` / `j`) | Navigate up / down through table rows | | **PgUp / PgDn** | Scroll a whole screenful (also `Ctrl+B` / `Ctrl+F`) | | **Home / End** (or `g` / `G`) | Jump to the first / last row | diff --git a/src/app.rs b/src/app.rs index 060b957..4edea9e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -45,18 +45,25 @@ impl Tab { } } + /// The bare glyph. Padding to a fixed width is the renderer's job, since how many + /// columns it occupies is a property of the glyph, not of this list. pub fn icon(&self) -> &'static str { match self { - Tab::System => "⚙ ", + Tab::System => "⚙", Tab::Interfaces => "🔌", Tab::IpAddresses => "🌐", Tab::IpRoutes => "🔀", Tab::DhcpLeases => "💻", - Tab::Firewall => "🛡 ", - Tab::Neighbors => "📡 ", + Tab::Firewall => "🛡", + Tab::Neighbors => "📡", Tab::Logs => "📜", } } + + /// The tab selected by the number keys, counting from 1. + pub fn from_number(n: usize) -> Option { + n.checked_sub(1).and_then(|i| Tab::ALL.get(i)).copied() + } } /// Fetch the one resource a tab displays. @@ -626,6 +633,14 @@ impl App { self.reset_scroll(); } + /// Switch directly to `tab`, as the number keys do. + pub fn go_to_tab(&mut self, tab: Tab) { + if tab != self.active_tab { + self.active_tab = tab; + self.reset_scroll(); + } + } + /// Return to the top of the list. Each tab holds a different number of rows, so a /// scroll offset carried across a tab switch would point at nothing. pub fn reset_scroll(&mut self) { @@ -1184,6 +1199,25 @@ mod tests { assert!(!app.active_tab_needs_data()); } + /// Every tab is reachable by number, which is what the collapsed sidebar shows. + #[test] + fn the_number_keys_map_onto_the_tabs() { + assert_eq!(Tab::from_number(1), Some(Tab::System)); + assert_eq!(Tab::from_number(8), Some(Tab::Logs)); + assert_eq!(Tab::from_number(0), None); + assert_eq!(Tab::from_number(9), None); + + for (i, expected) in Tab::ALL.iter().enumerate() { + assert_eq!(Tab::from_number(i + 1), Some(*expected)); + } + + let mut app = app(); + app.selected_index = 7; + app.go_to_tab(Tab::Firewall); + assert_eq!(app.active_tab, Tab::Firewall); + assert_eq!(app.selected_index, 0, "a new tab starts at the top"); + } + /// `is_loading` gates every refresh, so a failure has to clear it or reloading is /// disabled for the rest of the session. #[test] diff --git a/src/main.rs b/src/main.rs index 37e14a4..292ab0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -732,6 +732,14 @@ async fn run_app( (KeyCode::PageUp, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => { app.page_up() } + // Jump straight to a tab. With the sidebar collapsed to numbers + // on a narrow terminal, this is what those numbers are for. + (KeyCode::Char(c), _) if c.is_ascii_digit() && c != '0' => { + if let Some(tab) = app::Tab::from_number(c as usize - '0' as usize) { + app.go_to_tab(tab); + } + } + (KeyCode::Home, _) | (KeyCode::Char('g'), _) => app.select_first(), (KeyCode::End, _) | (KeyCode::Char('G'), _) => app.select_last(), diff --git a/src/ui/header.rs b/src/ui/header.rs index 35897f6..5a63c04 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -1,65 +1,76 @@ use crate::app::App; use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::Modifier, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::Paragraph, Frame, }; +/// One row, no border. +/// +/// This was a three-row box holding a single line of text — an eighth of a 24-row +/// terminal spent on chrome. With the status bar the same, four rows go back to the +/// table, a third more data on a short screen. +pub const HEIGHT: u16 = 1; + pub fn render_header(f: &mut Frame, app: &App, area: Rect) { let t = &app.theme; + // The badges report security state and are the reason to look at this row at all, so + // they claim their width first. They used to sit on the right of a fixed split, which + // made `[READ-ONLY]` the first thing an 80-column terminal cut off. + let (key_badge, key_style) = if app.client.config.demo_mode { + (" DEMO ", t.host_key_unverified) + } else if app.host_key_verified { + (" HOST KEY: VERIFIED ", t.host_key_verified) + } else { + (" HOST KEY: UNVERIFIED ", t.host_key_unverified) + }; + let read_only = " READ-ONLY "; + let badge_width = (key_badge.len() + read_only.len() + 1) as u16; + let chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(35), // Title & App Name - Constraint::Min(30), // Active host & host key status - ]) + .constraints([Constraint::Min(8), Constraint::Length(badge_width)]) .split(area); - // Left Title Block (version dynamically extracted from Cargo.toml at compile time) - let version_str = format!(" v{} ", env!("CARGO_PKG_VERSION")); - let title_spans = vec![ - Span::styled(" MikroTUI ", t.title), - Span::styled(version_str, t.muted_text), - Span::styled(" (WinBox TUI) ", t.accent.add_modifier(Modifier::BOLD)), - ]; - let title_p = Paragraph::new(Line::from(title_spans)).block( - Block::default() - .borders(Borders::ALL) - .border_style(t.border), - ); - f.render_widget(title_p, chunks[0]); + // Identity takes what is left, in the longest form that fits. + let host = &app.client.config.host; + let board = &app.system_resource.board_name; + let version = env!("CARGO_PKG_VERSION"); - // Right Status Block - let host_info = format!( - " Router: {} ({}) ", - app.client.config.host, app.system_resource.board_name - ); + let with_board = if board.is_empty() { + format!(" {host} ") + } else { + format!(" {host} ({board}) ") + }; + let full = format!(" MikroTUI {version} · {}", with_board.trim_start()); - // Reports the actual state of host key verification rather than a badge that was - // decorative: in demo mode there is no SSH session to verify at all. - let (key_badge, key_style) = if app.client.config.demo_mode { - (" [DEMO] ", t.host_key_unverified) - } else if app.host_key_verified { - (" [HOST KEY: VERIFIED] ", t.host_key_verified) + let room = chunks[0].width as usize; + let identity = if full.chars().count() <= room { + full + } else if with_board.chars().count() <= room { + with_board } else { - (" [HOST KEY: UNVERIFIED] ", t.host_key_unverified) + format!(" {host} ") }; - let status_spans = vec![ - Span::styled(host_info, t.normal_text), - Span::raw(" | "), - Span::styled(key_badge, key_style), - Span::raw(" | "), - Span::styled(" [READ-ONLY] ", t.read_only_badge), - ]; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + identity, + t.title.add_modifier(Modifier::BOLD), + ))), + chunks[0], + ); - let status_p = Paragraph::new(Line::from(status_spans)).block( - Block::default() - .borders(Borders::ALL) - .border_style(t.border), + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(key_badge, key_style), + Span::raw(" "), + Span::styled(read_only, t.read_only_badge), + ])) + .alignment(Alignment::Right), + chunks[1], ); - f.render_widget(status_p, chunks[1]); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4e57bd6..021fda2 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,9 +19,9 @@ pub fn render(f: &mut Frame, app: &App) { let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Header - Constraint::Min(10), // Content (Sidebar + Active View) - Constraint::Length(3), // Statusbar + Constraint::Length(header::HEIGHT), + Constraint::Min(5), // Content (sidebar + active view) + Constraint::Length(statusbar::HEIGHT), ]) .split(f.area()); @@ -32,8 +32,10 @@ pub fn render(f: &mut Frame, app: &App) { let content_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(25), // Sidebar width - Constraint::Min(40), // Main view width + // The sidebar collapses to a numbered rail on a narrow terminal; see + // `sidebar::width_for`. + Constraint::Length(sidebar::width_for(f.area().width)), + Constraint::Min(20), ]) .split(main_chunks[1]); @@ -215,6 +217,125 @@ mod tests { ); } + /// Chrome was three rows of header plus three of status bar — a quarter of a 24-row + /// terminal — to show two lines of text. + #[test] + fn chrome_costs_two_rows_not_six() { + assert_eq!(header::HEIGHT, 1); + assert_eq!(statusbar::HEIGHT, 1); + + let app = app_with_interfaces(60); + let backend = TestBackend::new(120, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| render(f, &app)).unwrap(); + + // Header on row 0, table border on row 1, so the table starts immediately. + let buffer = terminal.backend().buffer(); + let row1: String = (0..120u16).map(|x| buffer[(x, 1u16)].symbol()).collect(); + assert!( + row1.contains("Network Interfaces"), + "the table should start on row 1, got: {}", + row1.trim() + ); + } + + /// 25 columns of static labels is nearly a third of an 80-column screen. + #[test] + fn the_sidebar_collapses_on_a_narrow_terminal() { + assert_eq!(sidebar::width_for(80), sidebar::RAIL_WIDTH); + assert_eq!(sidebar::width_for(99), sidebar::RAIL_WIDTH); + assert_eq!(sidebar::width_for(100), sidebar::FULL_WIDTH); + assert_eq!(sidebar::width_for(200), sidebar::FULL_WIDTH); + + let app = app_with_interfaces(3); + + let narrow = { + let mut t = Terminal::new(TestBackend::new(80, 20)).unwrap(); + t.draw(|f| render(f, &app)).unwrap(); + let b = t.backend().buffer(); + (0..20u16) + .flat_map(|y| (0..80u16).map(move |x| (x, y))) + .map(|(x, y)| b[(x, y)].symbol()) + .collect::() + }; + assert!( + !narrow.contains("System Resources"), + "the labelled sidebar should be gone at 80 columns" + ); + + let wide = { + let mut t = Terminal::new(TestBackend::new(120, 20)).unwrap(); + t.draw(|f| render(f, &app)).unwrap(); + let b = t.backend().buffer(); + (0..20u16) + .flat_map(|y| (0..120u16).map(move |x| (x, y))) + .map(|(x, y)| b[(x, y)].symbol()) + .collect::() + }; + assert!( + wide.contains("System Resources"), + "the labelled sidebar should be back at 120 columns" + ); + } + + /// The security badges are the reason to look at the header, so they must not be the + /// first thing a narrow terminal drops. + #[test] + fn the_read_only_badge_survives_a_narrow_terminal() { + let app = app_with_interfaces(3); + for width in [80u16, 100, 120, 200] { + let mut t = Terminal::new(TestBackend::new(width, 20)).unwrap(); + t.draw(|f| render(f, &app)).unwrap(); + let b = t.backend().buffer(); + let row0: String = (0..width).map(|x| b[(x, 0u16)].symbol()).collect(); + assert!( + row0.contains("READ-ONLY"), + "badge missing at {width} columns: {}", + row0.trim() + ); + } + } + + /// Sidebar labels must line up. Measuring icon width in `char`s cannot show this — + /// `⚙ ` is two chars and one column wide, `🔌` is one char and two columns — so the + /// check is on where the labels actually land. `📡 ` carried an extra space and + /// pushed its row one column right. + #[test] + fn the_sidebar_labels_line_up() { + let app = app_with_interfaces(3); + let mut terminal = Terminal::new(TestBackend::new(120, 20)).unwrap(); + terminal.draw(|f| render(f, &app)).unwrap(); + let buffer = terminal.backend().buffer(); + + let starts: Vec = crate::app::Tab::ALL + .iter() + .enumerate() + .map(|(i, tab)| { + let y = (i + 2) as u16; // row 0 is the header, row 1 the block border + let first_word = tab.title().split_whitespace().next().unwrap(); + let first_char = first_word.chars().next().unwrap().to_string(); + + // Column, not char index: a wide emoji occupies one cell and leaves the + // next holding an empty string, so concatenating symbols and calling + // `find` measures neither one reliably. + (0..sidebar::FULL_WIDTH) + .find(|x| { + buffer[(*x, y)].symbol() == first_char + && (1..first_word.len() as u16).all(|k| { + buffer[(x + k, y)].symbol() + == first_word.chars().nth(k as usize).unwrap().to_string() + }) + }) + .unwrap_or_else(|| panic!("{} not found on row {y}", tab.title())) + }) + .collect(); + + assert!( + starts.windows(2).all(|w| w[0] == w[1]), + "labels start at different columns: {starts:?}" + ); + } + /// A scrollbar is the only on-screen cue that a list continues past the viewport. #[test] fn a_scrollbar_appears_only_when_the_list_overflows() { diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 2cd7be5..cc8bd65 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -2,24 +2,67 @@ use crate::app::{App, Tab}; use ratatui::{ layout::Rect, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem}, + widgets::{Block, Borders, List, ListItem, Paragraph}, Frame, }; +use unicode_width::UnicodeWidthStr; + +/// Width of the labelled sidebar. +pub const FULL_WIDTH: u16 = 25; + +/// Width of the collapsed rail: two digits and a space. +pub const RAIL_WIDTH: u16 = 3; + +/// Below this, the labelled sidebar costs more than it is worth. +/// +/// At 80 columns it took 25 of them — nearly a third of the screen — for eight labels +/// that never change, while the tables were being squeezed to four characters a column. +pub const COLLAPSE_BELOW: u16 = 100; + +/// How much horizontal room the sidebar wants at this terminal width. +pub fn width_for(total: u16) -> u16 { + if total < COLLAPSE_BELOW { + RAIL_WIDTH + } else { + FULL_WIDTH + } +} + +/// Pad an icon to two display columns. +/// +/// The glyphs are not all the same width — `⚙` occupies one column while `🔌` occupies +/// two — so baking spaces into the list left the labels beside them starting at different +/// columns. Measuring is the only way to get this right; counting `char`s cannot, since a +/// wide emoji is one char and two columns. +fn pad_icon(icon: &str) -> String { + const TARGET: usize = 2; + let width = icon.width(); + format!("{icon}{}", " ".repeat(TARGET.saturating_sub(width))) +} pub fn render_sidebar(f: &mut Frame, app: &App, area: Rect) { + if area.width < FULL_WIDTH { + render_rail(f, app, area); + } else { + render_full(f, app, area); + } +} + +fn render_full(f: &mut Frame, app: &App, area: Rect) { let t = &app.theme; let items: Vec = Tab::ALL .iter() - .map(|tab| { - let is_selected = *tab == app.active_tab; - let style = if is_selected { + .enumerate() + .map(|(index, tab)| { + let style = if *tab == app.active_tab { t.selected_row } else { t.normal_text }; - - let text = format!(" {} {}", tab.icon(), tab.title()); + // The number is what the rail shows when collapsed, and what the 1-8 keys + // select, so it is worth showing here too. + let text = format!(" {} {} {}", index + 1, pad_icon(tab.icon()), tab.title()); ListItem::new(Line::from(Span::styled(text, style))) }) .collect(); @@ -28,8 +71,31 @@ pub fn render_sidebar(f: &mut Frame, app: &App, area: Rect) { Block::default() .borders(Borders::ALL) .border_style(t.border) - .title(" Menu WinBox "), + .title(" Menu "), ); f.render_widget(list, area); } + +/// Just the tab numbers, so position among the eight is still visible. +/// +/// Icons are deliberately not used here: emoji width varies between terminals, and a rail +/// this narrow has no slack to absorb a cell of drift. +fn render_rail(f: &mut Frame, app: &App, area: Rect) { + let t = &app.theme; + + let lines: Vec = Tab::ALL + .iter() + .enumerate() + .map(|(index, tab)| { + let style = if *tab == app.active_tab { + t.selected_row + } else { + t.muted_text + }; + Line::from(Span::styled(format!("{:>2} ", index + 1), style)) + }) + .collect(); + + f.render_widget(Paragraph::new(lines), area); +} diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 3ce6ec9..161b1c6 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -1,8 +1,8 @@ use crate::app::{App, InputMode}; use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::Paragraph, Frame, }; @@ -32,6 +32,9 @@ enum HotkeyTone { Danger, } +/// One row, no border. See [`crate::ui::header::HEIGHT`]. +pub const HEIGHT: u16 = 1; + pub fn render_statusbar(f: &mut Frame, app: &App, area: Rect) { let t = &app.theme; @@ -45,8 +48,7 @@ pub fn render_statusbar(f: &mut Frame, app: &App, area: Rect) { .constraints([Constraint::Min(20), Constraint::Length(filter_width)]) .split(area); - // Room inside the block, minus its two borders. - let available = chunks[0].width.saturating_sub(2) as usize; + let available = chunks[0].width as usize; // The message goes first and is never dropped. It is the only place errors surface — // a rejected host key, a refresh that gave up — and it used to be appended after ten @@ -74,12 +76,7 @@ pub fn render_statusbar(f: &mut Frame, app: &App, area: Rect) { )); } - let status_p = Paragraph::new(Line::from(spans)).block( - Block::default() - .borders(Borders::ALL) - .border_style(t.border), - ); - f.render_widget(status_p, chunks[0]); + f.render_widget(Paragraph::new(Line::from(spans)), chunks[0]); // Right filter bar let (filter_text, filter_style) = match app.input_mode { @@ -93,11 +90,9 @@ pub fn render_statusbar(f: &mut Frame, app: &App, area: Rect) { } }; - let filter_p = Paragraph::new(Line::from(Span::styled(filter_text, filter_style))).block( - Block::default() - .borders(Borders::ALL) - .border_style(t.border) - .title("Filter"), + f.render_widget( + Paragraph::new(Line::from(Span::styled(filter_text, filter_style))) + .alignment(Alignment::Right), + chunks[1], ); - f.render_widget(filter_p, chunks[1]); }