Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
40 changes: 37 additions & 3 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tab> {
n.checked_sub(1).and_then(|i| Tab::ALL.get(i)).copied()
}
}

/// Fetch the one resource a tab displays.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]
Expand Down
8 changes: 8 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Expand Down
97 changes: 54 additions & 43 deletions src/ui/header.rs
Original file line number Diff line number Diff line change
@@ -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]);
}
131 changes: 126 additions & 5 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand All @@ -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]);

Expand Down Expand Up @@ -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::<String>()
};
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::<String>()
};
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<u16> = 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() {
Expand Down
Loading
Loading