use std::io;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::widgets::Paragraph;
use ratatui::Terminal;
use sysinfo::System;
use crate::collect;
use crate::tabs::{self, TabId, ALL_TABS};
use crate::ui::chrome;
use crate::ui::palette as p;
pub const DEFAULT_SMART_INTERVAL_SECS: u64 = 300;
pub const MIN_SMART_INTERVAL_SECS: u64 = 5;
pub const INTERVAL_STEP_SECS: u64 = 60;
pub const DEFAULT_REFRESH_INTERVAL_MS: u64 = 1000;
pub const MIN_REFRESH_INTERVAL_MS: u64 = 250;
pub const REFRESH_INTERVAL_PRESETS_MS: [u64; 5] = [250, 500, 1000, 2000, 4000];
pub struct Options {
pub start_tab: TabId,
pub view: ViewMode,
pub smart_interval_secs: u64,
pub refresh_interval_ms: u64,
pub temp_unit: TempUnit,
pub visible_columns: VisibleColumns,
pub watch_roots: Vec<PathBuf>,
pub config_source: Option<PathBuf>,
pub config_warnings: Vec<String>,
}
impl Options {
#[cfg(test)]
pub fn new(start_tab: TabId, view: ViewMode) -> Self {
Self {
start_tab,
view,
smart_interval_secs: DEFAULT_SMART_INTERVAL_SECS,
refresh_interval_ms: DEFAULT_REFRESH_INTERVAL_MS,
temp_unit: TempUnit::Celsius,
visible_columns: VisibleColumns(VisibleColumns::ALL),
watch_roots: collect::hot_files::default_roots(),
config_source: None,
config_warnings: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Full,
Lite,
Dense,
}
pub const VIEW_MODE_NAMES: &[&str] = &["full", "lite", "dense"];
impl ViewMode {
pub fn from_name(name: &str) -> Option<Self> {
match name.to_ascii_lowercase().as_str() {
"full" => Some(ViewMode::Full),
"lite" => Some(ViewMode::Lite),
"dense" | "v2" | "btop" | "2.0" => Some(ViewMode::Dense),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
ViewMode::Full => "full",
ViewMode::Lite => "lite",
ViewMode::Dense => "dense",
}
}
pub fn next(self) -> Self {
match self {
ViewMode::Full => ViewMode::Lite,
ViewMode::Lite => ViewMode::Dense,
ViewMode::Dense => ViewMode::Full,
}
}
}
fn cycle_view(app: &mut App) {
app.view = app.view.next();
if app.view != ViewMode::Lite {
app.lite.detail_open = false;
}
if app.view != ViewMode::Dense {
app.dense.filter_input = false;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VisibleColumns(pub u8);
impl VisibleColumns {
pub const SIZE: u8 = 1 << 0;
pub const FREE: u8 = 1 << 1;
pub const USED_PCT: u8 = 1 << 2;
pub const TEMP: u8 = 1 << 3;
pub const SMART: u8 = 1 << 4;
pub const ALL: u8 = Self::SIZE | Self::FREE | Self::USED_PCT | Self::TEMP | Self::SMART;
pub fn contains(self, other: u8) -> bool {
self.0 & other != 0
}
pub fn toggle(&mut self, col: u8) {
self.0 ^= col;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TempUnit {
Celsius,
Fahrenheit,
}
impl TempUnit {
pub fn next(self) -> Self {
match self {
TempUnit::Celsius => TempUnit::Fahrenheit,
TempUnit::Fahrenheit => TempUnit::Celsius,
}
}
pub fn label(self) -> &'static str {
match self {
TempUnit::Celsius => "Celsius (°C)",
TempUnit::Fahrenheit => "Fahrenheit (°F)",
}
}
pub fn format_temp(self, c: i16) -> String {
match self {
TempUnit::Celsius => format!("{}°C", c),
TempUnit::Fahrenheit => {
let f = c as f64 * 9.0 / 5.0 + 32.0;
format!("{:.0}°F", f)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiveState {
Live,
Paused,
}
#[derive(Debug, Clone)]
pub struct HostInfo {
pub hostname: String,
pub os: String,
pub uptime_secs: u64,
pub device_count: usize,
}
pub struct App {
pub active_tab: TabId,
pub view: ViewMode,
pub lite: crate::ui::lite::LiteState,
pub dense: crate::ui::dense::DenseState,
pub last_area: Rect,
pub live: LiveState,
pub host: HostInfo,
pub devices: Vec<collect::DeviceTick>,
pub filesystems: Vec<collect::FsTick>,
pub volumes: collect::VolumeTick,
pub io: collect::IoCollector,
pub smart: collect::SmartCollector,
pub hot_files: collect::hot_files::HotFileWatcher,
pub processes: collect::ProcessCollector,
pub growth: collect::GrowthTracker,
pub insights: Vec<crate::insights::Insight>,
pub selected_device: usize,
pub selected_fs: usize,
pub hot_selected: usize,
pub hot_offset: usize,
last_metadata_refresh: Instant,
last_usage_refresh: Instant,
pub smart_interval_secs: u64,
pub smart_interval_label: String,
pub refresh_interval_ms: u64,
pub show_help: bool,
pub show_settings: bool,
pub settings_cursor: usize,
pub visible_columns: VisibleColumns,
pub temp_unit: TempUnit,
pub smart_refresh_requested: bool,
pub should_quit: bool,
pub config_source: Option<PathBuf>,
pub config_warnings: Vec<String>,
}
#[cfg(test)]
pub fn draw_for_test(f: &mut ratatui::Frame, app: &mut App) {
draw(f, app);
}
impl App {
#[cfg(test)]
pub fn new_for_test(start: TabId, view: ViewMode) -> Self {
Self::new(start, view)
}
#[cfg(test)]
fn new(start: TabId, view: ViewMode) -> Self {
Self::with_options(Options::new(start, view))
}
fn with_options(opts: Options) -> Self {
let devices = collect::devices::collect();
let filesystems = collect::filesystems::collect();
let volumes = collect::volumes::collect();
let io = collect::IoCollector::new();
let mut smart = collect::SmartCollector::new();
smart.set_interval(Duration::from_secs(opts.smart_interval_secs));
smart.refresh_if_due(&devices);
let root_refs: Vec<&std::path::Path> =
opts.watch_roots.iter().map(|p| p.as_path()).collect();
let hot_files = collect::hot_files::HotFileWatcher::start(&root_refs);
Self {
active_tab: opts.start_tab,
view: opts.view,
lite: crate::ui::lite::LiteState::default(),
dense: crate::ui::dense::DenseState::default(),
last_area: Rect::new(0, 0, 0, 0),
live: LiveState::Live,
host: read_host(devices.len()),
selected_device: 0,
selected_fs: 0,
hot_selected: 0,
hot_offset: 0,
devices,
filesystems,
volumes,
io,
smart,
hot_files,
processes: collect::ProcessCollector::new(),
growth: collect::GrowthTracker::new(),
insights: Vec::new(),
last_metadata_refresh: Instant::now(),
last_usage_refresh: Instant::now(),
smart_interval_secs: opts.smart_interval_secs,
smart_interval_label: format_smart_label(opts.smart_interval_secs),
refresh_interval_ms: opts.refresh_interval_ms,
show_help: false,
show_settings: false,
settings_cursor: 0,
visible_columns: opts.visible_columns,
temp_unit: opts.temp_unit,
smart_refresh_requested: false,
should_quit: false,
config_source: opts.config_source,
config_warnings: opts.config_warnings,
}
}
fn tick(&mut self) {
if matches!(self.live, LiveState::Paused) {
return;
}
self.io.sample();
self.processes.refresh();
let usage_elapsed = self.last_usage_refresh.elapsed();
if usage_elapsed >= Duration::from_millis(self.refresh_interval_ms) {
collect::devices::refresh_usage(&mut self.devices);
self.filesystems = collect::filesystems::collect();
self.growth.observe(&self.filesystems);
if self.selected_fs >= self.filesystems.len() && !self.filesystems.is_empty() {
self.selected_fs = self.filesystems.len() - 1;
}
self.hot_files.decay(usage_elapsed);
self.host.uptime_secs = System::uptime();
self.last_usage_refresh = Instant::now();
}
if self.last_metadata_refresh.elapsed() >= Duration::from_secs(30) {
self.devices = collect::devices::collect();
self.volumes = collect::volumes::collect();
self.host.device_count = self.devices.len();
if self.selected_device >= self.devices.len() && !self.devices.is_empty() {
self.selected_device = self.devices.len() - 1;
}
self.last_metadata_refresh = Instant::now();
}
self.smart
.set_interval(Duration::from_secs(self.smart_interval_secs));
if self.smart_refresh_requested {
self.smart.force_refresh(&self.devices);
self.smart_refresh_requested = false;
} else {
self.smart.refresh_if_due(&self.devices);
}
self.insights = crate::insights::evaluate(
&self.devices,
&self.filesystems,
&self.io.latest,
&self.smart,
&self.hot_files,
);
}
}
pub fn run(opts: Options) -> Result<()> {
let mut app = App::with_options(opts);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = main_loop(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
res
}
fn main_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
let frame_budget = Duration::from_millis(50);
loop {
terminal.draw(|f| draw(f, app))?;
if event::poll(frame_budget)? {
if let Event::Key(k) = event::read()? {
if k.kind != KeyEventKind::Release {
handle_key(app, k.code);
}
}
}
app.tick();
if app.should_quit {
return Ok(());
}
}
}
fn handle_key(app: &mut App, key: KeyCode) {
if app.show_settings && !app.show_help {
handle_settings_key(app, key);
return;
}
if app.view == ViewMode::Lite && !app.show_help {
handle_lite_key(app, key);
return;
}
if app.view == ViewMode::Dense && !app.show_help {
handle_dense_key(app, key);
return;
}
if app.show_help {
match key {
KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Esc => app.show_help = false,
KeyCode::Char('Q') => app.should_quit = true,
_ => app.show_help = false,
}
return;
}
if app.show_settings {
handle_settings_key(app, key);
return;
}
match key {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('p') => {
app.live = match app.live {
LiveState::Live => LiveState::Paused,
LiveState::Paused => LiveState::Live,
};
}
KeyCode::Char('?') => {
app.show_help = true;
}
KeyCode::Char(',') => {
app.show_settings = !app.show_settings;
app.settings_cursor = 0;
}
KeyCode::Char('L') => app.view = ViewMode::Lite,
KeyCode::Char('v') | KeyCode::Char('V') => cycle_view(app),
KeyCode::Char('r') | KeyCode::Char('R') => {
app.smart_refresh_requested = true;
}
KeyCode::Char('+') | KeyCode::Char('=') => {
let next = app.smart_interval_secs.saturating_add(INTERVAL_STEP_SECS);
app.smart_interval_secs = next;
app.smart_interval_label = format_smart_label(next);
let collector_secs = app.smart.current_interval().as_secs();
if collector_secs != next {
app.smart.set_interval(Duration::from_secs(next));
}
}
KeyCode::Char('-') | KeyCode::Char('_') => {
let next = app
.smart_interval_secs
.saturating_sub(INTERVAL_STEP_SECS)
.max(MIN_SMART_INTERVAL_SECS);
app.smart_interval_secs = next;
app.smart_interval_label = format_smart_label(next);
app.smart.set_interval(Duration::from_secs(next));
}
KeyCode::Char('0') => {
app.smart_interval_secs = DEFAULT_SMART_INTERVAL_SECS;
app.smart_interval_label = format_smart_label(DEFAULT_SMART_INTERVAL_SECS);
app.smart
.set_interval(Duration::from_secs(DEFAULT_SMART_INTERVAL_SECS));
app.smart_refresh_requested = true;
}
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => cycle_tab(app, -1),
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => cycle_tab(app, 1),
KeyCode::Up | KeyCode::Char('k') => {
if has_selection(app) {
move_selection(app, -1, 0);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if has_selection(app) {
move_selection(app, 1, 0);
}
}
KeyCode::Home => {
if has_selection(app) {
move_selection(app, 0, -1);
}
}
KeyCode::End => {
if has_selection(app) {
move_selection(app, 0, 1);
}
}
KeyCode::PageUp => {
if has_selection(app) {
move_selection(app, -5, 0);
}
}
KeyCode::PageDown => {
if has_selection(app) {
move_selection(app, 5, 0);
}
}
KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
let idx = (c as u8 - b'1') as usize;
if let Some(t) = ALL_TABS.get(idx) {
app.active_tab = *t;
}
}
_ => {}
}
if app.active_tab == TabId::Hot {
clamp_hot_scroll(app);
}
}
fn clamp_hot_scroll(app: &mut App) {
let count = app.hot_files.active_count();
if count == 0 {
app.hot_selected = 0;
app.hot_offset = 0;
return;
}
app.hot_selected = app.hot_selected.min(count - 1);
let content_h = app.last_area.height.saturating_sub(5);
let content = Rect::new(0, 0, app.last_area.width, content_h);
let visible = (tabs::hot::visible_rows(app, content) as usize).max(1);
if app.hot_selected < app.hot_offset {
app.hot_offset = app.hot_selected;
} else if app.hot_selected >= app.hot_offset + visible {
app.hot_offset = app.hot_selected + 1 - visible;
}
app.hot_offset = app.hot_offset.min(count.saturating_sub(visible.min(count)));
}
fn handle_lite_key(app: &mut App, key: KeyCode) {
use crate::ui::lite;
if app.lite.filter_input {
match key {
KeyCode::Esc => {
app.lite.filter_input = false;
app.lite.filter_text.clear();
app.lite.selected = 0;
app.lite.offset = 0;
}
KeyCode::Enter => app.lite.filter_input = false,
KeyCode::Backspace => {
app.lite.filter_text.pop();
app.lite.selected = 0;
app.lite.offset = 0;
}
KeyCode::Char(c) => {
app.lite.filter_text.push(c);
app.lite.selected = 0;
app.lite.offset = 0;
}
_ => {}
}
return;
}
let count = lite::filter_rows(lite::collect_rows(app), &app.lite.filter_text).len();
let visible = lite::Layout::new(app.last_area).visible_files(app.lite.detail_open);
match key {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('L') => {
app.view = ViewMode::Full;
app.lite.detail_open = false;
app.lite.filter_input = false;
}
KeyCode::Char('v') | KeyCode::Char('V') => {
app.lite.filter_input = false;
cycle_view(app);
}
KeyCode::Char('?') => app.show_help = true,
KeyCode::Char('p') => {
app.live = match app.live {
LiveState::Live => LiveState::Paused,
LiveState::Paused => LiveState::Live,
};
}
KeyCode::Char('/') => {
app.lite.filter_input = true;
app.lite.filter_text.clear();
}
KeyCode::Enter => app.lite.detail_open = !app.lite.detail_open,
KeyCode::Esc => {
if app.lite.detail_open {
app.lite.detail_open = false;
} else if !app.lite.filter_text.is_empty() {
app.lite.filter_text.clear();
app.lite.selected = 0;
app.lite.offset = 0;
} else {
app.should_quit = true;
}
}
KeyCode::Down | KeyCode::Char('j') if count > 0 => {
app.lite.selected = (app.lite.selected + 1).min(count - 1);
}
KeyCode::Up | KeyCode::Char('k') => {
app.lite.selected = app.lite.selected.saturating_sub(1);
}
KeyCode::Home => app.lite.selected = 0,
KeyCode::End => app.lite.selected = count.saturating_sub(1),
_ => {}
}
clamp_lite_scroll(app, count, visible);
}
fn handle_dense_key(app: &mut App, key: KeyCode) {
use crate::ui::dense;
if app.dense.filter_input {
match key {
KeyCode::Esc => {
app.dense.filter_input = false;
app.dense.filter_text.clear();
app.dense.selected = 0;
app.dense.offset = 0;
}
KeyCode::Enter => app.dense.filter_input = false,
KeyCode::Backspace => {
app.dense.filter_text.pop();
app.dense.selected = 0;
app.dense.offset = 0;
}
KeyCode::Char(c) => {
app.dense.filter_text.push(c);
app.dense.selected = 0;
app.dense.offset = 0;
}
_ => {}
}
return;
}
let count = dense::sorted_rows(app).len();
match key {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('v') | KeyCode::Char('V') => {
app.dense.filter_input = false;
cycle_view(app);
}
KeyCode::Char('L') => {
app.view = ViewMode::Lite;
app.dense.filter_input = false;
}
KeyCode::Char(',') => {
app.show_settings = true;
app.settings_cursor = 0;
}
KeyCode::Char('?') => app.show_help = true,
KeyCode::Char('p') => {
app.live = match app.live {
LiveState::Live => LiveState::Paused,
LiveState::Paused => LiveState::Live,
};
}
KeyCode::Char('/') => {
app.dense.filter_input = true;
app.dense.filter_text.clear();
}
KeyCode::Char('s') => app.dense.sort = app.dense.sort.next(),
KeyCode::Char(c) if dense::DenseBox::from_digit(c).is_some() => {
let b = dense::DenseBox::from_digit(c);
app.dense.zoomed = if app.dense.zoomed == b { None } else { b };
}
KeyCode::Esc => {
if !app.dense.filter_text.is_empty() {
app.dense.filter_text.clear();
app.dense.selected = 0;
app.dense.offset = 0;
} else if app.dense.zoomed.is_some() {
app.dense.zoomed = None;
} else {
app.should_quit = true;
}
}
KeyCode::Down | KeyCode::Char('j') if count > 0 => {
app.dense.selected = (app.dense.selected + 1).min(count - 1);
}
KeyCode::Up | KeyCode::Char('k') => {
app.dense.selected = app.dense.selected.saturating_sub(1);
}
KeyCode::Home => app.dense.selected = 0,
KeyCode::End => app.dense.selected = count.saturating_sub(1),
_ => {}
}
let visible = dense::visible_files(app, app.last_area);
clamp_dense_scroll(app, count, visible);
}
fn clamp_dense_scroll(app: &mut App, count: usize, visible: u16) {
let st = &mut app.dense;
if count == 0 || visible == 0 {
st.selected = 0;
st.offset = 0;
return;
}
st.selected = st.selected.min(count - 1);
let visible = visible as usize;
if st.selected < st.offset {
st.offset = st.selected;
} else if st.selected >= st.offset + visible {
st.offset = st.selected + 1 - visible;
}
st.offset = st.offset.min(count.saturating_sub(visible.min(count)));
}
fn clamp_lite_scroll(app: &mut App, count: usize, visible: u16) {
let lite = &mut app.lite;
if count == 0 {
lite.selected = 0;
lite.offset = 0;
return;
}
lite.selected = lite.selected.min(count - 1);
let visible = visible.max(1) as usize;
if lite.selected < lite.offset {
lite.offset = lite.selected;
}
if lite.selected >= lite.offset + visible {
lite.offset = lite.selected + 1 - visible;
}
let max_offset = count.saturating_sub(visible);
lite.offset = lite.offset.min(max_offset);
}
fn has_selection(app: &App) -> bool {
matches!(
app.active_tab,
TabId::Devices | TabId::Smart | TabId::Fs | TabId::Hot
)
}
const SETTINGS_ROWS: usize = 12;
const SETTINGS_FIRST_CYCLE: usize = 5;
fn handle_settings_key(app: &mut App, key: KeyCode) {
match key {
KeyCode::Esc | KeyCode::Char(',') | KeyCode::Char('q') => {
app.show_settings = false;
}
KeyCode::Up | KeyCode::Char('k') => {
if app.settings_cursor == 0 {
app.settings_cursor = SETTINGS_ROWS - 1;
} else {
app.settings_cursor -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
app.settings_cursor = (app.settings_cursor + 1) % SETTINGS_ROWS;
}
KeyCode::Char(' ') | KeyCode::Enter => {
match app.settings_cursor {
0 => app.visible_columns.toggle(VisibleColumns::SIZE),
1 => app.visible_columns.toggle(VisibleColumns::FREE),
2 => app.visible_columns.toggle(VisibleColumns::USED_PCT),
3 => app.visible_columns.toggle(VisibleColumns::TEMP),
4 => app.visible_columns.toggle(VisibleColumns::SMART),
5 => {
app.temp_unit = app.temp_unit.next();
}
6 => {
let current = app.smart_interval_secs;
let presets = [10u64, 30, 60, 300];
let next = presets
.iter()
.find(|&&p| p > current)
.copied()
.unwrap_or(presets[0]);
app.smart_interval_secs = next;
app.smart_interval_label = format_smart_label(next);
app.smart.set_interval(Duration::from_secs(next));
app.smart_refresh_requested = true;
}
7 => {
let current = app.refresh_interval_ms;
let next = REFRESH_INTERVAL_PRESETS_MS
.iter()
.find(|&&p| p > current)
.copied()
.unwrap_or(REFRESH_INTERVAL_PRESETS_MS[0]);
app.refresh_interval_ms = next;
}
8 => {
crate::ui::theme::cycle();
}
9 => cycle_view(app),
10 => {
crate::ui::graph::cycle();
}
11 => {
crate::ui::graph::toggle_fade();
}
_ => {}
}
}
_ => {}
}
}
fn move_selection(app: &mut App, dy: i32, mode: i32) {
let (cur, len) = match app.active_tab {
TabId::Fs => (app.selected_fs, app.filesystems.len()),
TabId::Hot => (app.hot_selected, app.hot_files.active_count()),
_ => (app.selected_device, app.devices.len()),
};
if len == 0 {
return;
}
let next: usize = if mode == -1 {
0
} else if mode == 1 {
len - 1
} else {
let n = len as i32;
let r = ((cur as i32 + dy) % n + n) % n;
r as usize
};
match app.active_tab {
TabId::Fs => app.selected_fs = next,
TabId::Hot => app.hot_selected = next,
_ => app.selected_device = next,
}
}
fn cycle_tab(app: &mut App, delta: i32) {
let cur = ALL_TABS
.iter()
.position(|t| *t == app.active_tab)
.unwrap_or(0);
let n = ALL_TABS.len() as i32;
let next = ((cur as i32 + delta) % n + n) % n;
app.active_tab = ALL_TABS[next as usize];
}
fn format_smart_label(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
format!("{}h", secs / 3600)
}
}
fn format_refresh_label(ms: u64) -> String {
if ms < 1000 {
return format!("{ms}ms");
}
let secs = ms as f64 / 1000.0;
if ms % 1000 == 0 {
format!("{}s", ms / 1000)
} else {
format!("{secs:.1}s")
}
}
fn step_label() -> String {
format!("±{}s", INTERVAL_STEP_SECS)
}
fn draw(f: &mut ratatui::Frame, app: &mut App) {
app.last_area = f.area();
draw_inner(f, app);
}
fn draw_inner(f: &mut ratatui::Frame, app: &App) {
let full = f.area();
if full.width == 0 || full.height == 0 {
return;
}
f.render_widget(Paragraph::new("").style(Style::default().bg(p::bg())), full);
if app.view == ViewMode::Lite {
crate::ui::lite::render(f, app, full);
if app.show_help {
draw_lite_help_overlay(f, full);
}
return;
}
if app.view == ViewMode::Dense {
crate::ui::dense::render(f, full, app);
if app.show_settings {
draw_settings_overlay(f, full, app);
}
if app.show_help {
draw_dense_help_overlay(f, full);
}
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(2), Constraint::Min(1), Constraint::Length(2), ])
.split(full);
chrome::draw_header(f, layout[0], &app.host, app.live);
chrome::draw_tab_bar(f, layout[1], app.active_tab, app.insights.len());
let content = Rect {
x: layout[2].x,
y: layout[2].y,
width: layout[2].width,
height: layout[2].height,
};
tabs::draw(f, content, app);
let step = step_label();
let extra: Vec<(char, &str)> = vec![
('r', "Refresh"),
('+', app.smart_interval_label.as_str()),
('-', step.as_str()),
];
chrome::draw_footer(f, layout[3], &extra);
if app.show_help {
draw_help_overlay(f, full);
}
if app.show_settings {
draw_settings_overlay(f, full, app);
}
}
fn draw_help_overlay(f: &mut ratatui::Frame, area: Rect) {
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
if area.width < 4 || area.height < 4 {
return;
}
let popup_w = 60u16.min(area.width.saturating_sub(4));
let popup_h = 23u16.min(area.height.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup = Rect {
x,
y,
width: popup_w,
height: popup_h,
};
if popup.width == 0 || popup.height == 0 {
return;
}
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(p::cyan()))
.title(Span::styled(
" DiskWatch — key bindings ",
Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(p::bg()));
let inner = block.inner(popup);
f.render_widget(block, popup);
let key = |k: &'static str| {
Span::styled(
format!(" {:<10}", k),
Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
)
};
let desc = |d: &'static str| Span::styled(d, Style::default().fg(p::fg()));
let lines = vec![
Line::from(""),
Line::from(vec![key("Tab / ←→ / h l"), desc("previous / next tab")]),
Line::from(vec![key("Shift+Tab"), desc("previous tab")]),
Line::from(vec![key("1 — 8"), desc("jump directly to tab N")]),
Line::from(""),
Line::from(vec![
key("↑ ↓ / j k"),
desc("move device / fs / file selection"),
]),
Line::from(vec![key("Home End"), desc("jump to first / last")]),
Line::from(vec![key("PgUp PgDn"), desc("jump by 5")]),
Line::from(""),
Line::from(vec![key("r"), desc("force SMART refresh now")]),
Line::from(vec![key("+ -"), desc("nudge SMART poll interval")]),
Line::from(vec![key("0"), desc("reset interval to 5 min (default)")]),
Line::from(""),
Line::from(vec![key("p"), desc("pause / resume live updates")]),
Line::from(vec![key("L"), desc("switch to Lite — one 80×24 screen")]),
Line::from(vec![key("V"), desc("cycle view: full → lite → dense")]),
Line::from(vec![key("?"), desc("toggle this help")]),
Line::from(vec![
key(","),
desc("settings — toggle columns / units / interval"),
]),
Line::from(vec![key("q / Esc"), desc("quit")]),
Line::from(""),
Line::from(Span::styled(
" press any key to dismiss",
Style::default().fg(p::dim()),
)),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_lite_help_overlay(f: &mut ratatui::Frame, area: Rect) {
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
if area.width < 4 || area.height < 4 {
return;
}
let popup_w = 54u16.min(area.width.saturating_sub(4));
let popup_h = 15u16.min(area.height.saturating_sub(4));
let popup = Rect {
x: area.x + (area.width.saturating_sub(popup_w)) / 2,
y: area.y + (area.height.saturating_sub(popup_h)) / 2,
width: popup_w,
height: popup_h,
};
if popup.width == 0 || popup.height == 0 {
return;
}
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(p::cyan()))
.title(Span::styled(
" DiskWatch Lite — key bindings ",
Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(p::bg()));
let inner = block.inner(popup);
f.render_widget(block, popup);
let key = |k: &'static str| {
Span::styled(
format!(" {:<10}", k),
Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
)
};
let desc = |d: &'static str| Span::styled(d, Style::default().fg(p::fg()));
let lines = vec![
Line::from(vec![key("↑ ↓ / j k"), desc("move selection")]),
Line::from(vec![key("↵"), desc("expand / collapse detail")]),
Line::from(vec![key("/"), desc("filter by file or path")]),
Line::from(vec![key("Esc"), desc("close detail, then filter")]),
Line::from(vec![key("Home End"), desc("first / last row")]),
Line::from(""),
Line::from(vec![key("p"), desc("pause / resume")]),
Line::from(vec![key("V"), desc("cycle view: full → lite → dense")]),
Line::from(vec![key("L"), desc("back to the full 8-tab view")]),
Line::from(vec![key("q"), desc("quit")]),
Line::from(""),
Line::from(Span::styled(
" theme + graph style: V or L, then , (or --graph dots)",
Style::default().fg(p::dim()),
)),
Line::from(Span::styled(
" read-only: diskwatch never deletes or trims",
Style::default().fg(p::dim()),
)),
Line::from(Span::styled(
" press any key to dismiss",
Style::default().fg(p::dim()),
)),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_dense_help_overlay(f: &mut ratatui::Frame, area: Rect) {
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
if area.width < 4 || area.height < 4 {
return;
}
let popup_w = 62u16.min(area.width.saturating_sub(4));
let popup_h = 17u16.min(area.height.saturating_sub(4));
let popup = Rect {
x: area.x + (area.width.saturating_sub(popup_w)) / 2,
y: area.y + (area.height.saturating_sub(popup_h)) / 2,
width: popup_w,
height: popup_h,
};
if popup.width == 0 || popup.height == 0 {
return;
}
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(p::cyan()))
.title(Span::styled(
" DiskWatch Dense — key bindings ",
Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(p::bg()));
let inner = block.inner(popup);
f.render_widget(block, popup);
let key = |k: &'static str| {
Span::styled(
format!(" {:<10}", k),
Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
)
};
let desc = |d: &'static str| Span::styled(d, Style::default().fg(p::fg()));
let note = |d: &'static str| Line::from(Span::styled(d, Style::default().fg(p::dim())));
let lines = vec![
Line::from(vec![key("↑ ↓ / j k"), desc("move selection in files")]),
Line::from(vec![key("/"), desc("filter by file or path")]),
Line::from(vec![key("s"), desc("cycle sort: events / total / name")]),
Line::from(vec![key("Home End"), desc("first / last row")]),
Line::from(vec![
key("1-6"),
desc("zoom the matching box full-screen, again to restore"),
]),
Line::from(""),
Line::from(vec![key("p"), desc("pause / resume")]),
Line::from(vec![key("V"), desc("cycle view: full → lite → dense")]),
Line::from(vec![key("L"), desc("jump straight to Lite")]),
Line::from(vec![key(","), desc("settings (theme, view, graphs)")]),
Line::from(vec![key("q"), desc("quit")]),
Line::from(""),
note(" util needs /proc/diskstats — macOS shows --"),
note(" latency buckets sample per-tick means, not per-op"),
note(" press any key to dismiss"),
];
f.render_widget(Paragraph::new(lines), inner);
}
const SETTINGS_POPUP_W: u16 = 60;
const SETTINGS_LABEL_W: u16 = 38;
const SETTINGS_MARKER_W: u16 = 3;
const fn settings_value_w() -> u16 {
SETTINGS_POPUP_W - 2 - SETTINGS_MARKER_W - SETTINGS_LABEL_W
}
fn settings_rows(app: &App) -> Vec<(&'static str, String)> {
let on = "[x]";
let off = "[ ]";
vec![
(
"Overview DEVICES — SIZE column",
(if app.visible_columns.contains(VisibleColumns::SIZE) {
on
} else {
off
})
.to_string(),
),
(
"Overview DEVICES — FREE column",
(if app.visible_columns.contains(VisibleColumns::FREE) {
on
} else {
off
})
.to_string(),
),
(
"Overview DEVICES — USED % column",
(if app.visible_columns.contains(VisibleColumns::USED_PCT) {
on
} else {
off
})
.to_string(),
),
(
"Overview DEVICES — TEMP column",
(if app.visible_columns.contains(VisibleColumns::TEMP) {
on
} else {
off
})
.to_string(),
),
(
"Overview DEVICES — SMART column",
(if app.visible_columns.contains(VisibleColumns::SMART) {
on
} else {
off
})
.to_string(),
),
("Temperature unit", app.temp_unit.label().to_string()),
(
"SMART poll interval",
format!("{} (r refreshes)", app.smart_interval_label),
),
(
"Refresh speed",
format_refresh_label(app.refresh_interval_ms),
),
(
"Theme",
crate::ui::theme::name().to_string(),
),
("View", app.view.name().to_string()),
("Graph style", crate::ui::graph::name().to_string()),
(
"Graph fade (btop gradient)",
if crate::ui::theme::name() == "terminal" {
"n/a (terminal)".to_string()
} else if crate::ui::graph::fade_enabled() {
"on".to_string()
} else {
"off".to_string()
},
),
]
}
pub fn tilde(path: &std::path::Path) -> String {
let display = path.display().to_string();
let Some(home) = std::env::var_os("HOME") else {
return display;
};
let home = std::path::PathBuf::from(home).display().to_string();
if home.is_empty() {
return display;
}
match display.strip_prefix(&home) {
Some(rest) => format!("~{rest}"),
None => display,
}
}
fn settings_footer(app: &App) -> Vec<String> {
let mut out = vec![" (changes last for this session only)".to_string()];
match &app.config_source {
Some(path) => out.push(format!(" persist them in {}", tilde(path))),
None => out.push(" `diskwatch --write-config` starts a config file".to_string()),
}
if !app.config_warnings.is_empty() {
out.push(format!(
" config: {} problem(s) — `diskwatch --diag` lists them",
app.config_warnings.len()
));
}
out
}
fn draw_settings_overlay(f: &mut ratatui::Frame, area: Rect, app: &App) {
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
if area.width < 4 || area.height < 4 {
return;
}
let footer = settings_footer(app);
let popup_w = SETTINGS_POPUP_W.min(area.width.saturating_sub(4));
let popup_h =
(SETTINGS_ROWS as u16 + 5 + footer.len() as u16).min(area.height.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup = Rect {
x,
y,
width: popup_w,
height: popup_h,
};
if popup.width == 0 || popup.height == 0 {
return;
}
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(p::cyan()))
.title(Span::styled(
" DiskWatch — settings ",
Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(p::bg()));
let inner = block.inner(popup);
f.render_widget(block, popup);
let rows = settings_rows(app);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(
" Move: ↑ ↓ Toggle: Space / Enter Close: Esc / , / q",
Style::default().fg(p::dim()),
)));
lines.push(Line::from(""));
for (i, (label, value)) in rows.iter().enumerate() {
let marker = format!(
"{:^width$}",
if i == app.settings_cursor { ">" } else { "" },
width = SETTINGS_MARKER_W as usize
);
let is_cycle = i >= SETTINGS_FIRST_CYCLE;
let label_color = if is_cycle { p::yellow() } else { p::fg() };
let value_color = if is_cycle {
p::cyan()
} else if value.contains("[x]") {
p::green()
} else {
p::dim()
};
let marker_color = if i == app.settings_cursor {
p::br_white()
} else {
p::dim()
};
lines.push(Line::from(vec![
Span::styled(marker, Style::default().fg(marker_color)),
Span::styled(
format!("{:<width$}", label, width = SETTINGS_LABEL_W as usize),
Style::default().fg(label_color),
),
Span::styled(
crate::ui::lite::truncate_end(value, settings_value_w()),
Style::default().fg(value_color),
),
]));
}
lines.push(Line::from(""));
for (i, text) in footer.iter().enumerate() {
let color = if i + 1 == footer.len() && !app.config_warnings.is_empty() {
p::red()
} else {
p::dim()
};
lines.push(Line::from(Span::styled(
crate::ui::lite::truncate_end(text, popup_w.saturating_sub(2)),
Style::default().fg(color),
)));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn read_host(device_count: usize) -> HostInfo {
let hostname = System::host_name().unwrap_or_else(|| "localhost".to_string());
let name = System::name().unwrap_or_else(|| "unknown".to_string());
let version = System::os_version().unwrap_or_default();
let arch = std::env::consts::ARCH;
let os = if version.is_empty() {
format!("{} {}", name, arch)
} else {
format!("{} {} {}", name, version, arch)
};
HostInfo {
hostname,
os,
uptime_secs: System::uptime(),
device_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
#[test]
fn settings_rows_match_their_action_dispatch() {
let app = App::new(TabId::Overview, ViewMode::Full);
let backend = TestBackend::new(130, 36);
let mut term = Terminal::new(backend).expect("terminal");
term.draw(|f| super::draw_settings_overlay(f, Rect::new(0, 0, 130, 36), &app))
.expect("draw");
let mut app = App::new(TabId::Overview, ViewMode::Full);
app.settings_cursor = SETTINGS_ROWS - 1;
let before = crate::ui::graph::fade_enabled();
handle_settings_key(&mut app, KeyCode::Char(' '));
assert_ne!(
crate::ui::graph::fade_enabled(),
before,
"the last settings row is unreachable — SETTINGS_ROWS and the \
dispatch arms have drifted apart"
);
crate::ui::graph::set_fade(before);
}
#[test]
fn arrows_on_volumes_do_not_corrupt_the_device_picker() {
for tab in [TabId::Volumes, TabId::Overview] {
let mut app = App::new(TabId::Overview, ViewMode::Full);
app.active_tab = tab;
let before = app.selected_device;
for key in [
KeyCode::Up,
KeyCode::Down,
KeyCode::Char('j'),
KeyCode::Char('k'),
] {
super::handle_key(&mut app, key);
}
assert_eq!(
app.selected_device, before,
"arrows on {tab:?} must not touch the Devices/SMART selection"
);
}
}
#[test]
fn left_right_cycle_all_tabs_without_moving_selections() {
let mut app = App::new(TabId::Overview, ViewMode::Full);
app.selected_device = 2;
app.selected_fs = 3;
app.hot_selected = 4;
let tabs = super::ALL_TABS;
for (key, direction) in [
(KeyCode::Right, 1isize),
(KeyCode::Left, -1),
(KeyCode::Char('l'), 1),
(KeyCode::Char('h'), -1),
] {
app.active_tab = tabs[0];
for step in 1..=tabs.len() * 2 {
super::handle_key(&mut app, key);
let index = (step as isize * direction).rem_euclid(tabs.len() as isize);
assert_eq!(app.active_tab, tabs[index as usize], "{key:?}, step {step}");
assert_eq!(
(app.selected_device, app.selected_fs, app.hot_selected),
(2, 3, 4),
"tab navigation must preserve selections"
);
}
}
}
#[test]
fn hot_files_up_down_move_its_own_selection() {
let mut app = App::new(TabId::Overview, ViewMode::Full);
app.active_tab = TabId::Hot;
{
let mut s = app.hot_files.state.lock().unwrap();
for i in 0..5 {
s.activity.insert(
std::path::PathBuf::from(format!("/tmp/f{i}")),
crate::collect::hot_files::FileActivity {
path: std::path::PathBuf::from(format!("/tmp/f{i}")),
events_per_sec: (5 - i) as f64,
total_events: 1,
last_kind: crate::collect::hot_files::ActivityKind::Modified,
last_seen: std::time::Instant::now(),
history: std::collections::VecDeque::new(),
pushed: 0,
},
);
}
}
assert_eq!(app.hot_selected, 0);
super::handle_key(&mut app, KeyCode::Down);
assert_eq!(
app.hot_selected, 1,
"Down should move Hot Files' own cursor"
);
let before_tab = app.active_tab;
super::handle_key(&mut app, KeyCode::Right);
assert_ne!(
app.active_tab, before_tab,
"Left/Right cycle tabs from Hot Files"
);
}
#[test]
fn the_settings_overlay_names_the_config_file_and_its_problems() {
let mut app = App::new(TabId::Overview, ViewMode::Full);
app.show_settings = true;
let render = |app: &App| {
let backend = TestBackend::new(130, 40);
let mut term = Terminal::new(backend).expect("terminal");
term.draw(|f| super::draw_settings_overlay(f, Rect::new(0, 0, 130, 40), app))
.expect("draw");
let buf = term.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let out = render(&app);
assert!(
out.contains("--write-config"),
"with no config file the overlay should say how to start one"
);
app.config_source = Some(std::path::PathBuf::from("/etc/diskwatch/config.toml"));
app.config_warnings = vec!["line 3: unknown key \"colours\"".to_string()];
let out = render(&app);
assert!(
out.contains("/etc/diskwatch/config.toml"),
"the overlay should name the file settings persist in"
);
assert!(
out.contains("1 problem"),
"a config warning has to be visible somewhere the user will look"
);
assert!(
out.contains("--diag"),
"the last footer line was clipped by the popup border"
);
}
#[test]
fn settings_values_fit_their_column() {
let mut app = App::new(TabId::Overview, ViewMode::Full);
let budget = settings_value_w() as usize;
for theme in crate::ui::theme::THEME_NAMES {
crate::ui::theme::set_by_name(theme);
for unit in [TempUnit::Celsius, TempUnit::Fahrenheit] {
app.temp_unit = unit;
for fade in [true, false] {
crate::ui::graph::set_fade(fade);
for (label, value) in settings_rows(&app) {
assert!(
value.chars().count() <= budget,
"settings value {value:?} for {label:?} is {} cols, \
but the column is {budget} — it will render truncated",
value.chars().count()
);
}
}
}
}
crate::ui::theme::set_by_name("dark");
crate::ui::graph::set_fade(false);
}
#[test]
fn settings_row_count_matches_the_rendered_rows() {
let app = App::new(TabId::Overview, ViewMode::Full);
assert_eq!(settings_rows(&app).len(), SETTINGS_ROWS);
}
#[test]
fn graph_style_survives_a_round_trip_through_the_settings_overlay() {
let mut app = App::new(TabId::Overview, ViewMode::Full);
let start = crate::ui::graph::name();
app.settings_cursor = SETTINGS_ROWS - 2; handle_settings_key(&mut app, KeyCode::Char(' '));
assert_ne!(crate::ui::graph::name(), start);
handle_settings_key(&mut app, KeyCode::Char(' '));
assert_eq!(crate::ui::graph::name(), start);
}
#[test]
fn devices_collector_returns_something() {
let devs = crate::collect::devices::collect();
if !devs.is_empty() {
let d = &devs[0];
assert!(!d.name.is_empty(), "device name should not be empty");
#[cfg(target_os = "macos")]
{
assert!(d.size_bytes > 0, "macOS device should report size");
assert!(
!d.model.is_empty() && d.model != "Unknown",
"macOS device should have a model"
);
}
}
}
fn render_all_tabs(width: u16, height: u16) {
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).expect("terminal");
let mut app = App::new(TabId::Overview, ViewMode::Full);
for tab in ALL_TABS {
app.active_tab = *tab;
term.draw(|f| super::draw(f, &mut app)).expect("draw");
}
}
fn render_lite(width: u16, height: u16) {
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).expect("terminal");
let mut app = App::new(TabId::Overview, ViewMode::Lite);
app.growth.observe(&app.filesystems);
for (paused, detail, filter, help) in [
(false, false, "", false),
(true, false, "", false),
(false, true, "", false),
(false, false, "log", false),
(false, true, "log", false),
(false, false, "", true),
] {
app.live = if paused {
LiveState::Paused
} else {
LiveState::Live
};
app.lite.detail_open = detail;
app.lite.filter_text = filter.to_string();
app.show_help = help;
term.draw(|f| super::draw(f, &mut app)).expect("draw");
}
}
fn render_dense(width: u16, height: u16) {
let backend = TestBackend::new(width, height);
let mut term = Terminal::new(backend).expect("terminal");
let mut app = App::new(TabId::Overview, ViewMode::Dense);
app.growth.observe(&app.filesystems);
app.io.sample();
for (filter, filtering, sort, help) in [
("", false, crate::ui::dense::FileSort::Rate, false),
("", false, crate::ui::dense::FileSort::Total, false),
("", false, crate::ui::dense::FileSort::Name, false),
("log", false, crate::ui::dense::FileSort::Rate, false),
("lo", true, crate::ui::dense::FileSort::Rate, false),
("", false, crate::ui::dense::FileSort::Rate, true),
] {
app.dense.filter_text = filter.to_string();
app.dense.filter_input = filtering;
app.dense.sort = sort;
app.show_help = help;
term.draw(|f| super::draw(f, &mut app)).expect("draw");
}
}
#[test]
fn dense_renders_at_the_reference_grid() {
render_dense(130, 44);
}
#[test]
fn dense_renders_across_the_sizes_a_terminal_actually_takes() {
for (w, h) in [
(crate::ui::dense::MIN_FULL_W, crate::ui::dense::MIN_FULL_H),
(
crate::ui::dense::MIN_FULL_W - 1,
crate::ui::dense::MIN_FULL_H,
),
(
crate::ui::dense::MIN_FULL_W,
crate::ui::dense::MIN_FULL_H - 1,
),
(crate::ui::dense::MIN_W, crate::ui::dense::MIN_H),
(crate::ui::dense::MIN_W - 1, crate::ui::dense::MIN_H - 1),
(200, 60),
(400, 100),
(80, 24),
(100, 30),
(60, 16),
(20, 6),
] {
render_dense(w, h);
}
}
#[test]
fn dense_key_surface_does_not_leak_into_the_full_tui() {
let mut app = App::new(TabId::Overview, ViewMode::Dense);
super::handle_key(&mut app, KeyCode::Char('3'));
assert_eq!(app.active_tab, TabId::Overview);
let before = app.dense.sort;
super::handle_key(&mut app, KeyCode::Char('s'));
assert_ne!(app.dense.sort, before);
}
#[test]
fn a_box_number_zooms_it_and_pressing_it_again_restores_the_grid() {
use crate::ui::dense::DenseBox;
let mut app = App::new(TabId::Overview, ViewMode::Dense);
assert_eq!(app.dense.zoomed, None);
super::handle_key(&mut app, KeyCode::Char('4'));
assert_eq!(app.dense.zoomed, Some(DenseBox::Volumes));
super::handle_key(&mut app, KeyCode::Char('1'));
assert_eq!(app.dense.zoomed, Some(DenseBox::Io));
super::handle_key(&mut app, KeyCode::Char('1'));
assert_eq!(app.dense.zoomed, None);
}
#[test]
fn esc_clears_zoom_before_it_would_otherwise_quit() {
use crate::ui::dense::DenseBox;
let mut app = App::new(TabId::Overview, ViewMode::Dense);
super::handle_key(&mut app, KeyCode::Char('2'));
assert_eq!(app.dense.zoomed, Some(DenseBox::Devices));
super::handle_key(&mut app, KeyCode::Esc);
assert_eq!(app.dense.zoomed, None, "Esc should un-zoom first");
assert!(!app.should_quit, "and not also quit in the same press");
super::handle_key(&mut app, KeyCode::Esc);
assert!(app.should_quit);
}
#[test]
fn v_cycles_every_view_and_returns_to_where_it_started() {
for start in [ViewMode::Full, ViewMode::Lite, ViewMode::Dense] {
let mut app = App::new(TabId::Overview, start);
let mut seen = vec![app.view];
for _ in 0..VIEW_MODE_NAMES.len() {
super::handle_key(&mut app, KeyCode::Char('V'));
seen.push(app.view);
}
assert_eq!(app.view, start, "V did not return to {start:?}");
assert_eq!(
seen.len() - 1,
VIEW_MODE_NAMES.len(),
"the cycle skipped a view"
);
for m in [ViewMode::Full, ViewMode::Lite, ViewMode::Dense] {
assert!(seen.contains(&m), "{m:?} is unreachable from {start:?}");
}
}
let mut app = App::new(TabId::Overview, ViewMode::Full);
super::handle_key(&mut app, KeyCode::Char('v'));
assert_eq!(app.view, ViewMode::Lite);
}
#[test]
fn the_settings_menu_reaches_the_views_and_names_them_as_the_cli_does() {
let mut app = App::new(TabId::Overview, ViewMode::Dense);
super::handle_key(&mut app, KeyCode::Char(','));
assert!(app.show_settings, "`,` does not open settings from dense");
let view_row = settings_rows(&app)
.into_iter()
.position(|(label, _)| label == "View")
.expect("a View row");
app.settings_cursor = view_row;
let before = app.view;
super::handle_key(&mut app, KeyCode::Enter);
assert_ne!(app.view, before, "the View row did not cycle");
assert!(app.show_settings, "the overlay closed on a cycle");
assert!(
VIEW_MODE_NAMES.contains(&app.view.name()),
"{} is not a spelling the CLI accepts",
app.view.name()
);
app.view = ViewMode::Dense;
let sort = app.dense.sort;
super::handle_key(&mut app, KeyCode::Char('s'));
assert_eq!(app.dense.sort, sort, "a key leaked past the open dialog");
}
#[test]
fn every_key_the_2_0_view_advertises_actually_does_something() {
let fresh = || App::new(TabId::Overview, ViewMode::Dense);
let mut app = fresh();
super::handle_key(&mut app, KeyCode::Char('q'));
assert!(app.should_quit, "q");
let mut app = fresh();
super::handle_key(&mut app, KeyCode::Char('/'));
assert!(app.dense.filter_input, "/");
let mut app = fresh();
let sort = app.dense.sort;
super::handle_key(&mut app, KeyCode::Char('s'));
assert_ne!(app.dense.sort, sort, "s");
let mut app = fresh();
super::handle_key(&mut app, KeyCode::Char('V'));
assert_ne!(app.view, ViewMode::Dense, "V");
let mut app = fresh();
super::handle_key(&mut app, KeyCode::Char(','));
assert!(app.show_settings, ",");
let mut app = fresh();
super::handle_key(&mut app, KeyCode::Char('?'));
assert!(app.show_help, "?");
let mut app = fresh();
super::handle_key(&mut app, KeyCode::Down);
super::handle_key(&mut app, KeyCode::End);
let count = crate::ui::dense::sorted_rows(&app).len();
assert!(app.dense.selected < count.max(1), "↑↓ left the list");
}
#[test]
fn dense_filter_captures_printable_keys() {
let mut app = App::new(TabId::Overview, ViewMode::Dense);
super::handle_key(&mut app, KeyCode::Char('/'));
let sort = app.dense.sort;
for c in "syslog".chars() {
super::handle_key(&mut app, KeyCode::Char(c));
}
assert_eq!(app.dense.filter_text, "syslog");
assert_eq!(app.dense.sort, sort, "sort changed while typing a filter");
super::handle_key(&mut app, KeyCode::Esc);
assert!(app.dense.filter_text.is_empty());
}
#[test]
fn dense_esc_unwinds_the_filter_before_it_quits() {
let mut app = App::new(TabId::Overview, ViewMode::Dense);
app.dense.filter_text = "log".into();
super::handle_key(&mut app, KeyCode::Esc);
assert!(app.dense.filter_text.is_empty());
assert!(!app.should_quit);
super::handle_key(&mut app, KeyCode::Esc);
assert!(app.should_quit);
}
#[test]
fn lite_renders_at_the_reference_grid() {
render_lite(crate::ui::lite::GRID_W, crate::ui::lite::GRID_H);
}
#[test]
fn lite_renders_on_a_wide_terminal() {
render_lite(200, 60);
}
#[test]
fn lite_renders_below_its_floor_without_panicking() {
render_lite(40, 12);
render_lite(79, 23);
}
#[test]
fn lite_key_surface_does_not_leak_into_the_full_tui() {
let mut app = App::new(TabId::Overview, ViewMode::Lite);
super::handle_key(&mut app, KeyCode::Char('3'));
assert_eq!(app.active_tab, TabId::Overview);
assert_eq!(app.view, ViewMode::Lite);
super::handle_key(&mut app, KeyCode::Char('L'));
assert_eq!(app.view, ViewMode::Full);
super::handle_key(&mut app, KeyCode::Char('L'));
assert_eq!(app.view, ViewMode::Lite);
}
#[test]
fn lite_filter_captures_printable_keys() {
let mut app = App::new(TabId::Overview, ViewMode::Lite);
super::handle_key(&mut app, KeyCode::Char('/'));
super::handle_key(&mut app, KeyCode::Char('p'));
super::handle_key(&mut app, KeyCode::Char('q'));
assert_eq!(app.lite.filter_text, "pq");
assert_eq!(app.live, LiveState::Live, "p must not have paused");
assert!(!app.should_quit, "q must not have quit");
}
#[test]
fn lite_esc_unwinds_one_layer_at_a_time() {
let mut app = App::new(TabId::Overview, ViewMode::Lite);
app.lite.filter_text = "log".into();
app.lite.detail_open = true;
super::handle_key(&mut app, KeyCode::Esc);
assert!(!app.lite.detail_open, "first Esc closes detail");
assert_eq!(app.lite.filter_text, "log", "and leaves the filter");
super::handle_key(&mut app, KeyCode::Esc);
assert!(app.lite.filter_text.is_empty(), "second Esc clears filter");
assert!(!app.should_quit, "and still does not quit");
super::handle_key(&mut app, KeyCode::Esc);
assert!(app.should_quit, "third Esc, with nothing open, quits");
}
#[test]
fn renders_at_design_size() {
render_all_tabs(130, 36);
}
#[test]
fn renders_at_minimum_supported_size() {
render_all_tabs(110, 30);
}
#[test]
fn renders_at_undersized_terminal_without_panic() {
render_all_tabs(60, 20);
}
}