From 99464d9f6dc03678ffee80b2fcf7133c67b0db31 Mon Sep 17 00:00:00 2001 From: Eduardo Dluhosch Date: Fri, 21 Aug 2026 21:12:38 -0300 Subject: [PATCH] feat: give four rows and twenty columns back to the data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items 4 and 3 of the TUI review. On an 80x24 terminal, chrome took 61% of the screen: three rows of header and three of status bar to show two lines of text, plus 25 of 80 columns for eight labels that never change. Header and status bar are one borderless row each. The header now claims width for the security badges first โ€” `[READ-ONLY]` used to be on the right of a fixed split and was the first thing an 80-column terminal cut off โ€” and degrades the identity text instead, dropping the app name before the host. The sidebar collapses below 100 columns to a rail of tab numbers, three columns wide. The numbers are not decoration: 1-8 now jump straight to a tab, which is what makes the collapsed form usable rather than merely present. Together that turns a 53x14 content area at 80x24 into 77x18 โ€” from 39% of the screen to 72%. Chain and action are readable there now instead of `forwar` and `acce`. Icon alignment is measured rather than assumed. `โš™` and `๐Ÿ›ก` occupy one column while the rest occupy two, and the old list compensated with hand-placed trailing spaces โ€” correctly except for `๐Ÿ“ก`, which had one too many and pushed its row a column right. Icons are now bare glyphs padded to a measured two columns with `unicode-width`, the same crate ratatui uses for layout, pinned to the version already in the tree. The alignment test measures the column a label lands in, not the char index of a concatenated row: a wide emoji occupies one cell and leaves the next holding an empty string, so string offsets measure neither width nor position. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 1 + src/app.rs | 40 +++++++++++++- src/main.rs | 8 +++ src/ui/header.rs | 97 +++++++++++++++++--------------- src/ui/mod.rs | 131 ++++++++++++++++++++++++++++++++++++++++++-- src/ui/sidebar.rs | 80 ++++++++++++++++++++++++--- src/ui/statusbar.rs | 27 ++++----- 9 files changed, 312 insertions(+), 74 deletions(-) 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]); }