diff --git a/Cargo.lock b/Cargo.lock index dec5c19..267d912 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,6 +271,7 @@ dependencies = [ "clap", "crossterm", "directories", + "nucleo-matcher", "ratatui", "serde", "serde_json", @@ -434,6 +435,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index efdf817..ec301f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ thiserror = "2" directories = "6" ratatui = "0.29" crossterm = "0.28" +nucleo-matcher = "0.3" diff --git a/docs/plans/2026-03-08-tui-redesign.md b/docs/plans/2026-03-08-tui-redesign.md new file mode 100644 index 0000000..cc69ba6 --- /dev/null +++ b/docs/plans/2026-03-08-tui-redesign.md @@ -0,0 +1,952 @@ +# TUI Redesign — Ricing-First UX with fzf Process Finder + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Redesign gim's TUI into a ricing-friendly dashboard with zoom-in detail views, fzf-style process fuzzy finder, terminal-native ANSI colors, Nerd Font icons, and a color palette bar. + +**Architecture:** Hybrid layout — compact dashboard overview as default, Enter zooms into any module for full-screen detail. Process zoom-in becomes a full-screen fuzzy finder with nucleo matching. Colors default to ANSI 16 palette (inherits terminal theme) with optional hex override via `theme_mode: "custom"` in config. + +**Tech Stack:** ratatui 0.29, crossterm 0.28, nucleo-matcher (fuzzy search), sysinfo 0.37 + +--- + +### Task 1: Add nucleo-matcher dependency + +**Files:** +- Modify: `Cargo.toml:9-18` + +**Step 1: Add nucleo-matcher to dependencies** + +In `Cargo.toml`, add after the `crossterm` line: + +```toml +nucleo-matcher = "0.3" +``` + +**Step 2: Verify it compiles** + +Run: `cargo check` +Expected: Compiles successfully + +**Step 3: Commit** + +```bash +git add Cargo.toml Cargo.lock +git commit -m "deps: add nucleo-matcher for fuzzy process search" +``` + +--- + +### Task 2: Config — Add theme_mode, icons toggle, and ANSI color defaults + +**Files:** +- Modify: `src/config/mod.rs` + +**Step 1: Add `theme_mode` and `icons` fields to config structs** + +Add to `TuiConfig` struct (after `show_help`): + +```rust +pub icons: bool, +``` + +Add a new field to `Config` struct (after `theme`): + +```rust +pub theme_mode: ThemeMode, +``` + +Add enum before `Config`: + +```rust +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ThemeMode { + Terminal, + Custom, +} + +impl Default for ThemeMode { + fn default() -> Self { + ThemeMode::Terminal + } +} +``` + +**Step 2: Update defaults** + +In `Default for TuiConfig`, add: +```rust +icons: true, +``` + +In `Default for Config`, add: +```rust +theme_mode: ThemeMode::default(), +``` + +**Step 3: Update `parse_color` to support ANSI mapping** + +Add a new public function that resolves colors based on theme mode: + +```rust +/// Maps module names to ANSI terminal colors (adapts to user's terminal theme) +pub fn ansi_module_color(name: &str) -> Color { + match name { + "cpu" => Color::Cyan, + "memory" => Color::Green, + "disk" => Color::Yellow, + "network" => Color::Magenta, + "process" => Color::Red, + "system" => Color::Blue, + _ => Color::White, + } +} + +pub fn ansi_chrome_border() -> Color { + Color::DarkGray +} + +pub fn ansi_chrome_title() -> Color { + Color::White +} +``` + +**Step 4: Verify it compiles** + +Run: `cargo check` +Expected: Compiles successfully + +**Step 5: Commit** + +```bash +git add src/config/mod.rs +git commit -m "feat(config): add theme_mode (terminal/custom), icons toggle" +``` + +--- + +### Task 3: Refactor App state for view modes (Dashboard vs ZoomIn) + +**Files:** +- Modify: `src/tui/mod.rs:22-65` + +**Step 1: Add ViewMode enum and update App struct** + +Add before `App` struct: + +```rust +#[derive(Debug, Clone, PartialEq)] +enum ViewMode { + Dashboard, + ZoomIn(usize), // index of zoomed module +} +``` + +Add to `App` struct: + +```rust +view_mode: ViewMode, +search_query: String, +search_active: bool, +filtered_processes: Vec, +process_scroll: usize, +process_selected: usize, +all_processes: Vec, +``` + +Add the `ProcessEntry` struct: + +```rust +#[derive(Debug, Clone)] +struct ProcessEntry { + pid: u32, + name: String, + cpu: f32, + memory: u64, + command: String, + score: Option, // fuzzy match score +} +``` + +Update `App::new()` to initialize these fields. + +**Step 2: Verify it compiles** + +Run: `cargo check` +Expected: Compiles successfully + +**Step 3: Commit** + +```bash +git add src/tui/mod.rs +git commit -m "feat(tui): add ViewMode enum and process fuzzy finder state" +``` + +--- + +### Task 4: Update keybindings for zoom-in/out navigation + +**Files:** +- Modify: `src/tui/mod.rs` (event loop, around line 83-123) + +**Step 1: Add Enter/Esc keybindings** + +In the key event match block, add: + +```rust +KeyCode::Enter => { + match &app.view_mode { + ViewMode::Dashboard => { + app.view_mode = ViewMode::ZoomIn(app.selected_tab); + // If zooming into process module, refresh process list + if let Some(snapshot) = &app.snapshot { + if let Some((name, _)) = snapshot.modules.get(app.selected_tab) { + if name == "process" { + app.refresh_processes(); + app.search_active = true; + } + } + } + } + ViewMode::ZoomIn(_) => { + // In process view with search active, selecting does nothing yet + } + } + } + KeyCode::Esc => { + if app.search_active { + app.search_active = false; + app.search_query.clear(); + app.filtered_processes = app.all_processes.clone(); + } else { + match &app.view_mode { + ViewMode::ZoomIn(_) => { + app.view_mode = ViewMode::Dashboard; + } + ViewMode::Dashboard => { + app.should_quit = true; + } + } + } + } +``` + +Add Char handler for search input when in process zoom-in: + +```rust +KeyCode::Char(c) => { + if app.search_active { + app.search_query.push(c); + app.apply_fuzzy_filter(); + } else if c == 'q' { + match &app.view_mode { + ViewMode::Dashboard => app.should_quit = true, + ViewMode::ZoomIn(_) => app.view_mode = ViewMode::Dashboard, + } + } else if c == '/' || c == 'f' { + // Activate search in process zoom + if let ViewMode::ZoomIn(idx) = &app.view_mode { + if let Some(snapshot) = &app.snapshot { + if let Some((name, _)) = snapshot.modules.get(*idx) { + if name == "process" { + app.search_active = true; + } + } + } + } + } else if c == 'j' { + app.navigate_down(); + } else if c == 'k' { + app.navigate_up(); + } else if c == 'l' { + // existing tab next + } else if c == 'h' { + // existing tab prev + } +} +KeyCode::Backspace => { + if app.search_active { + app.search_query.pop(); + app.apply_fuzzy_filter(); + } +} +KeyCode::Down => app.navigate_down(), +KeyCode::Up => app.navigate_up(), +``` + +**Step 2: Add navigation methods to App** + +```rust +impl App { + fn navigate_down(&mut self) { + if let ViewMode::ZoomIn(_) = &self.view_mode { + let max = self.filtered_processes.len().saturating_sub(1); + self.process_selected = (self.process_selected + 1).min(max); + } + } + + fn navigate_up(&mut self) { + if let ViewMode::ZoomIn(_) = &self.view_mode { + self.process_selected = self.process_selected.saturating_sub(1); + } + } + + fn refresh_processes(&mut self) { + use sysinfo::{System, RefreshKind, ProcessRefreshKind}; + let sys = System::new_with_specifics( + RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + ); + self.all_processes = sys.processes().values().map(|p| { + ProcessEntry { + pid: p.pid().as_u32(), + name: p.name().to_string_lossy().to_string(), + cpu: p.cpu_usage(), + memory: p.memory(), + command: p.exe().map(|e| e.display().to_string()).unwrap_or_default(), + score: None, + } + }).collect(); + self.all_processes.sort_by(|a, b| b.memory.cmp(&a.memory)); + self.filtered_processes = self.all_processes.clone(); + self.process_selected = 0; + self.process_scroll = 0; + } + + fn apply_fuzzy_filter(&mut self) { + use nucleo_matcher::pattern::{Pattern, CaseMatching, Normalization}; + use nucleo_matcher::{Matcher, Config as NucleoConfig}; + use nucleo_matcher::Utf32Str; + + if self.search_query.is_empty() { + self.filtered_processes = self.all_processes.clone(); + self.process_selected = 0; + return; + } + + let mut matcher = Matcher::new(NucleoConfig::DEFAULT); + let pattern = Pattern::parse(&self.search_query, CaseMatching::Smart, Normalization::Smart); + let mut buf = Vec::new(); + + let mut scored: Vec<(u32, ProcessEntry)> = self.all_processes.iter().filter_map(|p| { + let haystack = Utf32Str::new(&p.name, &mut buf); + pattern.score(haystack, &mut matcher).map(|score| { + let mut entry = p.clone(); + entry.score = Some(score); + (score, entry) + }) + }).collect(); + + scored.sort_by(|a, b| b.0.cmp(&a.0)); + self.filtered_processes = scored.into_iter().map(|(_, e)| e).collect(); + self.process_selected = 0; + } +} +``` + +**Step 3: Verify it compiles** + +Run: `cargo check` +Expected: Compiles successfully + +**Step 4: Commit** + +```bash +git add src/tui/mod.rs +git commit -m "feat(tui): add zoom-in keybindings and process fuzzy search logic" +``` + +--- + +### Task 5: Build the dashboard renderer with ANSI colors and Nerd Font icons + +**Files:** +- Modify: `src/tui/mod.rs` (draw_ui, draw_header, draw_modules, draw_footer) + +**Step 1: Update `draw_ui` to route based on ViewMode** + +```rust +fn draw_ui(frame: &mut ratatui::Frame, app: &App) { + match &app.view_mode { + ViewMode::Dashboard => draw_dashboard(frame, app), + ViewMode::ZoomIn(idx) => { + if let Some(snapshot) = &app.snapshot { + if let Some((name, _)) = snapshot.modules.get(*idx) { + if name == "process" { + draw_process_finder(frame, app); + } else { + draw_module_detail(frame, app, *idx); + } + } + } + } + } +} +``` + +**Step 2: Refactor color resolution to respect theme_mode** + +Add helper function: + +```rust +fn resolve_module_fg(config: &Config, name: &str) -> Color { + match config.theme_mode { + crate::config::ThemeMode::Terminal => crate::config::ansi_module_color(name), + crate::config::ThemeMode::Custom => module_fg_color(config, name), + } +} + +fn resolve_module_accent(config: &Config, name: &str) -> Color { + match config.theme_mode { + crate::config::ThemeMode::Terminal => crate::config::ansi_module_color(name), + crate::config::ThemeMode::Custom => module_accent_color(config, name), + } +} + +fn resolve_border(config: &Config) -> Color { + match config.theme_mode { + crate::config::ThemeMode::Terminal => crate::config::ansi_chrome_border(), + crate::config::ThemeMode::Custom => parse_color(&config.theme.chrome.border), + } +} + +fn resolve_title(config: &Config) -> Color { + match config.theme_mode { + crate::config::ThemeMode::Terminal => crate::config::ansi_chrome_title(), + crate::config::ThemeMode::Custom => parse_color(&config.theme.chrome.title), + } +} +``` + +**Step 3: Update Nerd Font icon mapping** + +Update `module_label` to use icons when enabled: + +```rust +fn module_label(config: &Config, name: &str) -> String { + if config.tui.icons { + match name { + "cpu" => " CPU".into(), + "memory" => " MEM".into(), + "disk" => " DSK".into(), + "network" => "󰈀 NET".into(), + "process" => " PRC".into(), + "system" => "󰒋 SYS".into(), + _ => name.to_uppercase(), + } + } else { + let label = &module_theme(config, name).label; + if label.is_empty() { + name.to_uppercase() + } else { + label.clone() + } + } +} +``` + +**Step 4: Update draw_header to use resolve functions and show Nerd Font labels** + +Replace all `module_fg_color` calls with `resolve_module_fg`, `parse_color(&theme.chrome.border)` with `resolve_border`, etc. + +**Step 5: Add color palette bar to footer** + +Update `draw_footer`: + +```rust +fn draw_footer( + frame: &mut ratatui::Frame, + area: Rect, + app: &App, + border_type: ratatui::widgets::BorderType, +) { + if !app.config.tui.show_help { + return; + } + + let border_color = resolve_border(&app.config); + + // Split footer: help keys on left, color palette on right + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(30), Constraint::Length(38)]) + .split(area); + + // Help keys + let help = Paragraph::new(Line::from(vec![ + Span::styled("q", Style::default().add_modifier(Modifier::BOLD).fg(Color::White)), + Span::styled(" quit ", Style::default().fg(Color::DarkGray)), + Span::styled("←→", Style::default().add_modifier(Modifier::BOLD).fg(Color::White)), + Span::styled(" tab ", Style::default().fg(Color::DarkGray)), + Span::styled("⏎", Style::default().add_modifier(Modifier::BOLD).fg(Color::White)), + Span::styled(" zoom ", Style::default().fg(Color::DarkGray)), + Span::styled("esc", Style::default().add_modifier(Modifier::BOLD).fg(Color::White)), + Span::styled(" back", Style::default().fg(Color::DarkGray)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)), + ); + frame.render_widget(help, chunks[0]); + + // Color palette — shows terminal's ANSI 16 colors + let palette_colors = [ + Color::Black, Color::Red, Color::Green, Color::Yellow, + Color::Blue, Color::Magenta, Color::Cyan, Color::White, + Color::DarkGray, Color::LightRed, Color::LightGreen, Color::LightYellow, + Color::LightBlue, Color::LightMagenta, Color::LightCyan, Color::White, + ]; + let palette_spans: Vec = palette_colors + .iter() + .map(|&c| Span::styled("██", Style::default().fg(c))) + .collect(); + + let palette = Paragraph::new(Line::from(palette_spans)) + .alignment(ratatui::layout::Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)), + ); + frame.render_widget(palette, chunks[1]); +} +``` + +**Step 6: Update draw_modules to use resolve functions** + +Replace all hardcoded color calls with `resolve_module_fg` / `resolve_module_accent` / `resolve_border`. + +**Step 7: Verify it compiles** + +Run: `cargo check` +Expected: Compiles successfully + +**Step 8: Commit** + +```bash +git add src/tui/mod.rs +git commit -m "feat(tui): ricing-first dashboard with ANSI colors, Nerd Font icons, color palette" +``` + +--- + +### Task 6: Build the process fuzzy finder full-screen view + +**Files:** +- Modify: `src/tui/mod.rs` + +**Step 1: Implement `draw_process_finder`** + +```rust +fn draw_process_finder(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let border_color = resolve_border(&app.config); + let accent = resolve_module_fg(&app.config, "process"); + + let border_type = match app.config.tui.borders { + BorderStyle::None => ratatui::widgets::BorderType::Plain, + BorderStyle::Plain => ratatui::widgets::BorderType::Plain, + BorderStyle::Rounded => ratatui::widgets::BorderType::Rounded, + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // search bar + Constraint::Length(2), // column headers + Constraint::Min(5), // process list + Constraint::Length(3), // footer help + ]) + .split(area); + + // Search bar + let search_icon = if app.config.tui.icons { " " } else { ">" }; + let search_text = format!("{} {}", search_icon, app.search_query); + let cursor = if app.search_active { "█" } else { "" }; + let search = Paragraph::new(Line::from(vec![ + Span::styled(&search_text, Style::default().fg(Color::White)), + Span::styled(cursor, Style::default().fg(accent)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(if app.search_active { accent } else { border_color })) + .title(Span::styled( + if app.config.tui.icons { " Process Finder " } else { " Process Finder " }, + Style::default().fg(accent).add_modifier(Modifier::BOLD), + )), + ); + frame.render_widget(search, chunks[0]); + + // Column headers + let headers = Paragraph::new(Line::from(vec![ + Span::styled( + format!(" {:>7} {:<20} {:>6} {:>10} {}", + "PID", "NAME", "CPU%", "MEM", "COMMAND"), + Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD), + ), + ])); + frame.render_widget(headers, chunks[1]); + + // Process list + let visible_height = chunks[2].height as usize; + // Adjust scroll to keep selected visible + let scroll = if app.process_selected >= visible_height { + app.process_selected - visible_height + 1 + } else { + 0 + }; + + let mut lines: Vec = Vec::new(); + for (i, proc) in app.filtered_processes.iter().enumerate().skip(scroll).take(visible_height) { + let is_selected = i == app.process_selected; + let mem_str = format_mem(proc.memory); + let line_str = format!( + " {:>7} {:<20} {:>5.1}% {:>10} {}", + proc.pid, + truncate_str(&proc.name, 20), + proc.cpu, + mem_str, + truncate_str(&proc.command, 40), + ); + + let style = if is_selected { + Style::default().bg(accent).fg(Color::Black).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + lines.push(Line::styled(line_str, style)); + } + + let list_block = Block::default() + .borders(Borders::LEFT | Borders::RIGHT) + .border_type(border_type) + .border_style(Style::default().fg(border_color)); + let process_list = Paragraph::new(lines).block(list_block); + frame.render_widget(process_list, chunks[2]); + + // Footer with count and help + let count_text = format!( + " {}/{} processes", + app.filtered_processes.len(), + app.all_processes.len() + ); + let footer = Paragraph::new(Line::from(vec![ + Span::styled(&count_text, Style::default().fg(accent)), + Span::raw(" "), + Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(Color::White)), + Span::styled(" search ", Style::default().fg(Color::DarkGray)), + Span::styled("j/k", Style::default().add_modifier(Modifier::BOLD).fg(Color::White)), + Span::styled(" navigate ", Style::default().fg(Color::DarkGray)), + Span::styled("esc", Style::default().add_modifier(Modifier::BOLD).fg(Color::White)), + Span::styled(" back", Style::default().fg(Color::DarkGray)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)), + ); + frame.render_widget(footer, chunks[3]); +} + +fn truncate_str(s: &str, max: usize) -> String { + if s.len() > max { + format!("{}…", &s[..max - 1]) + } else { + s.to_string() + } +} + +fn format_mem(bytes: u64) -> String { + if bytes >= 1_073_741_824 { + format!("{:.1} GB", bytes as f64 / 1_073_741_824.0) + } else if bytes >= 1_048_576 { + format!("{:.1} MB", bytes as f64 / 1_048_576.0) + } else if bytes >= 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{} B", bytes) + } +} +``` + +**Step 2: Verify it compiles** + +Run: `cargo check` +Expected: Compiles successfully + +**Step 3: Commit** + +```bash +git add src/tui/mod.rs +git commit -m "feat(tui): full-screen process fuzzy finder with nucleo matching" +``` + +--- + +### Task 7: Build generic module zoom-in detail view + +**Files:** +- Modify: `src/tui/mod.rs` + +**Step 1: Implement `draw_module_detail` for non-process modules** + +```rust +fn draw_module_detail(frame: &mut ratatui::Frame, app: &App, idx: usize) { + let area = frame.area(); + let snapshot = match &app.snapshot { + Some(s) => s, + None => return, + }; + let (name, data) = match snapshot.modules.get(idx) { + Some(m) => m, + None => return, + }; + + let fg = resolve_module_fg(&app.config, name); + let accent = resolve_module_accent(&app.config, name); + let border_color = resolve_border(&app.config); + + let border_type = match app.config.tui.borders { + BorderStyle::None => ratatui::widgets::BorderType::Plain, + BorderStyle::Plain => ratatui::widgets::BorderType::Plain, + BorderStyle::Rounded => ratatui::widgets::BorderType::Rounded, + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), // gauge (if applicable) + Constraint::Length(8), // sparkline + Constraint::Min(5), // details + Constraint::Length(3), // footer + ]) + .split(area); + + // Gauge + if let Some(gauge_data) = extract_gauge(name, data) { + let gauge = Gauge::default() + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)) + .title(Span::styled( + format!(" {} ", module_label(&app.config, name)), + Style::default().fg(fg).add_modifier(Modifier::BOLD), + )), + ) + .gauge_style(Style::default().fg(accent).bg(Color::DarkGray)) + .use_unicode(true) + .ratio(gauge_data.ratio.clamp(0.0, 1.0)) + .label(Span::styled( + format!("{:.1}%", gauge_data.ratio * 100.0), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )); + frame.render_widget(gauge, chunks[0]); + } else { + // Title block for modules without gauge + let title_block = Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)) + .title(Span::styled( + format!(" {} ", module_label(&app.config, name)), + Style::default().fg(fg).add_modifier(Modifier::BOLD), + )); + frame.render_widget(title_block, chunks[0]); + } + + // Sparkline + if let Some(history) = app.history.get(name) { + if !history.is_empty() { + let sparkline_data: Vec = history.iter().map(|&v| v as u64).collect(); + let sparkline = Sparkline::default() + .block( + Block::default() + .borders(Borders::LEFT | Borders::RIGHT) + .border_type(border_type) + .border_style(Style::default().fg(border_color)) + .title(Span::styled(" History ", Style::default().fg(Color::DarkGray))), + ) + .data(sparkline_data) + .style(Style::default().fg(accent)) + .max(100); + frame.render_widget(sparkline, chunks[1]); + } + } + + // Detail key-value pairs + let mut lines: Vec = Vec::new(); + let mut entries: Vec<_> = data.metrics.iter().collect(); + entries.sort_by_key(|(k, _)| *k); + + for (key, value) in entries { + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", key), Style::default().fg(fg).add_modifier(Modifier::BOLD)), + Span::styled("→ ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{}", metric_display(value)), + Style::default().fg(Color::White), + ), + ])); + } + + let detail = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)) + .title(Span::styled(" Details ", Style::default().fg(Color::DarkGray))), + ) + .wrap(Wrap { trim: true }); + frame.render_widget(detail, chunks[2]); + + // Footer + let footer = Paragraph::new(Line::from(vec![ + Span::styled("esc", Style::default().add_modifier(Modifier::BOLD).fg(Color::White)), + Span::styled(" back ", Style::default().fg(Color::DarkGray)), + Span::styled("q", Style::default().add_modifier(Modifier::BOLD).fg(Color::White)), + Span::styled(" quit", Style::default().fg(Color::DarkGray)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)), + ); + frame.render_widget(footer, chunks[3]); +} +``` + +**Step 2: Verify it compiles** + +Run: `cargo check` +Expected: Compiles successfully + +**Step 3: Commit** + +```bash +git add src/tui/mod.rs +git commit -m "feat(tui): full-screen module detail view with gauge, sparkline, and details" +``` + +--- + +### Task 8: Polish dashboard layout — improved grid, separators, alignment + +**Files:** +- Modify: `src/tui/mod.rs` (draw_modules function) + +**Step 1: Rename `draw_modules` to `draw_dashboard` and refactor** + +Create `draw_dashboard` that wraps: draw_header + draw_modules + draw_footer, using the resolved color functions throughout. Update the module grid to use 3 columns for 6 modules (2 rows x 3 cols). + +Improve detail rendering inside each module panel: +- Right-align keys with fixed width +- Use `→` separator instead of `›` +- Add padding for cleaner look + +**Step 2: Verify and commit** + +Run: `cargo check && cargo run -- tui` +Expected: Dashboard renders with improved layout + +```bash +git add src/tui/mod.rs +git commit -m "feat(tui): polish dashboard grid layout and metric alignment" +``` + +--- + +### Task 9: Update output/mod.rs to use Nerd Font icons in print mode + +**Files:** +- Modify: `src/output/mod.rs` + +**Step 1: Add icon support to format_snapshot** + +Update the module header/footer borders to optionally include Nerd Font icons: + +```rust +fn module_icon(name: &str) -> &'static str { + match name { + "cpu" => "", + "memory" => "", + "disk" => "", + "network" => "󰈀", + "process" => "", + "system" => "󰒋", + _ => "●", + } +} +``` + +Update `format_snapshot` to use: `format!("{} {}", module_icon(name), name.to_uppercase())` + +**Step 2: Verify and commit** + +Run: `cargo run -- print -m cpu` +Expected: Shows icon in output + +```bash +git add src/output/mod.rs +git commit -m "feat(output): add Nerd Font icons to print mode module headers" +``` + +--- + +### Task 10: Integration test — full flow verification + +**Step 1: Build release and test** + +Run: `cargo build --release` +Expected: Builds successfully + +**Step 2: Test dashboard mode** + +Run: `cargo run -- tui` +Expected: +- Dashboard shows all 6 modules in 3x2 grid +- Nerd Font icons visible in tab bar and panel titles +- ANSI colors adapt to terminal theme +- Color palette bar visible in footer +- Tab/arrow keys navigate between modules + +**Step 3: Test zoom-in** + +- Press Enter on CPU tab → full-screen CPU detail with gauge + sparkline + details +- Press Esc → back to dashboard +- Navigate to Process tab, press Enter → full-screen fuzzy finder +- Type to filter processes, j/k to navigate +- Esc clears search, Esc again returns to dashboard + +**Step 4: Test print mode** + +Run: `cargo run -- print -m cpu,memory` +Expected: Shows Nerd Font icons in module headers + +**Step 5: Final commit** + +```bash +git add -A +git commit -m "feat(tui): complete ricing-first TUI redesign with fzf process finder" +``` + +--- + +## Summary of Changes + +| File | Change | +|------|--------| +| `Cargo.toml` | Add `nucleo-matcher` dependency | +| `src/config/mod.rs` | Add `ThemeMode`, `icons` toggle, ANSI color helpers | +| `src/tui/mod.rs` | Complete rewrite: ViewMode, dashboard, zoom-in, process finder, color palette | +| `src/output/mod.rs` | Add Nerd Font icons to print output | diff --git a/src/config/mod.rs b/src/config/mod.rs index 76298f3..87437ea 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -4,6 +4,19 @@ use ratatui::style::Color; use serde::Deserialize; use std::path::{Path, PathBuf}; +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ThemeMode { + Terminal, + Custom, +} + +impl Default for ThemeMode { + fn default() -> Self { + ThemeMode::Terminal + } +} + #[derive(Debug, Deserialize, Clone)] #[serde(default)] pub struct Config { @@ -11,6 +24,7 @@ pub struct Config { pub print: PrintConfig, pub tui: TuiConfig, pub theme: ThemeConfig, + pub theme_mode: ThemeMode, } #[derive(Debug, Deserialize, Clone)] @@ -34,6 +48,7 @@ pub struct TuiConfig { pub refresh_ms: Option, pub borders: BorderStyle, pub show_help: bool, + pub icons: bool, } #[derive(Debug, Deserialize, Clone)] @@ -80,6 +95,7 @@ impl Default for Config { print: PrintConfig::default(), tui: TuiConfig::default(), theme: ThemeConfig::default(), + theme_mode: ThemeMode::default(), } } } @@ -116,6 +132,7 @@ impl Default for TuiConfig { refresh_ms: None, borders: BorderStyle::Rounded, show_help: true, + icons: true, } } } @@ -253,3 +270,23 @@ pub fn parse_color(color_str: &str) -> Color { _ => Color::White, } } + +pub fn ansi_module_color(name: &str) -> Color { + match name { + "cpu" => Color::Cyan, + "memory" => Color::Green, + "disk" => Color::Yellow, + "network" => Color::Magenta, + "process" => Color::Red, + "system" => Color::Blue, + _ => Color::White, + } +} + +pub fn ansi_chrome_border() -> Color { + Color::DarkGray +} + +pub fn ansi_chrome_title() -> Color { + Color::White +} diff --git a/src/output/mod.rs b/src/output/mod.rs index 04acc2d..05d744b 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -29,11 +29,25 @@ impl OutputFormat { } } +fn module_icon(name: &str) -> &'static str { + match name { + "cpu" => "\u{f085}", // nf-fa-cogs + "memory" => "\u{f2db}", // nf-fa-microchip + "disk" => "\u{f0a0}", // nf-fa-hdd_o + "network" => "\u{f0200}", // nf-md-lan + "process" => "\u{f013}", // nf-fa-cog + "system" => "\u{f04cb}", // nf-md-monitor + _ => "●", + } +} + pub fn format_snapshot(snapshot: &MetricsSnapshot, format: &OutputFormat) -> String { let mut output = String::new(); for (name, data) in &snapshot.modules { - output.push_str(&format!("=== {} ===\n", name.to_uppercase())); + let header = format!("{} {}", module_icon(name), name.to_uppercase()); + output.push_str(&format!("╭── {} ──╮\n", header)); output.push_str(&format_output(data, format)); + output.push_str(&format!("╰── {} ──╯\n", header)); output.push('\n'); } output diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5d71eb4..3e07ef1 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::io; use std::time::{Duration, Instant}; @@ -7,23 +8,47 @@ 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::{Modifier, Style}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Sparkline, Wrap}; use ratatui::Terminal; -use crate::config::{parse_color, BorderStyle, Config, ModuleTheme}; +use crate::config::{parse_color, BorderStyle, Config, ModuleTheme, ThemeMode}; use crate::core::MetricValue; use crate::engine::{Engine, MetricsSnapshot}; use crate::error::Result; +// ── Data types ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +enum ViewMode { + Dashboard, + ZoomIn(usize), +} + +#[derive(Debug, Clone)] +struct ProcessEntry { + pid: u32, + name: String, + cpu: f32, + memory: u64, + command: String, +} + pub struct App { engine: Engine, config: Config, snapshot: Option, selected_tab: usize, should_quit: bool, + history: HashMap>, + view_mode: ViewMode, + search_query: String, + search_active: bool, + filtered_processes: Vec, + process_selected: usize, + all_processes: Vec, } impl App { @@ -34,21 +59,101 @@ impl App { snapshot: None, selected_tab: 0, should_quit: false, + history: HashMap::new(), + view_mode: ViewMode::Dashboard, + search_query: String::new(), + search_active: false, + filtered_processes: Vec::new(), + process_selected: 0, + all_processes: Vec::new(), } } fn refresh(&mut self) { self.snapshot = Some(self.engine.collect_once()); + + if let Some(snapshot) = &self.snapshot { + for (name, data) in &snapshot.modules { + if let Some(value) = extract_history_value(name, data) { + let entry = self.history.entry(name.clone()).or_default(); + entry.push(value); + if entry.len() > 60 { + entry.remove(0); + } + } + } + } } fn tab_count(&self) -> usize { + self.snapshot.as_ref().map(|s| s.modules.len()).unwrap_or(0) + } + + fn current_module_name(&self) -> Option { self.snapshot .as_ref() - .map(|s| s.modules.len()) - .unwrap_or(0) + .and_then(|s| s.modules.get(self.selected_tab)) + .map(|(name, _)| name.clone()) + } + + fn refresh_processes(&mut self) { + use sysinfo::{ProcessRefreshKind, RefreshKind, System}; + let sys = System::new_with_specifics( + RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + ); + self.all_processes = sys + .processes() + .values() + .map(|p| ProcessEntry { + pid: p.pid().as_u32(), + name: p.name().to_string_lossy().to_string(), + cpu: p.cpu_usage(), + memory: p.memory(), + command: p + .exe() + .map(|e| e.display().to_string()) + .unwrap_or_default(), + }) + .collect(); + self.all_processes.sort_by(|a, b| b.memory.cmp(&a.memory)); + self.filtered_processes = self.all_processes.clone(); + self.process_selected = 0; + } + + fn apply_fuzzy_filter(&mut self) { + use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; + use nucleo_matcher::{Config as NucleoConfig, Matcher, Utf32Str}; + + if self.search_query.is_empty() { + self.filtered_processes = self.all_processes.clone(); + self.process_selected = 0; + return; + } + + let mut matcher = Matcher::new(NucleoConfig::DEFAULT); + let pattern = + Pattern::parse(&self.search_query, CaseMatching::Smart, Normalization::Smart); + let mut buf = Vec::new(); + + let mut scored: Vec<(u32, ProcessEntry)> = self + .all_processes + .iter() + .filter_map(|p| { + let haystack = Utf32Str::new(&p.name, &mut buf); + pattern + .score(haystack, &mut matcher) + .map(|score| (score, p.clone())) + }) + .collect(); + + scored.sort_by(|a, b| b.0.cmp(&a.0)); + self.filtered_processes = scored.into_iter().map(|(_, e)| e).collect(); + self.process_selected = 0; } } +// ── Main loop ─────────────────────────────────────────────────────── + pub fn run_tui(engine: Engine, config: Config) -> Result<()> { enable_raw_mode().map_err(|e| crate::error::GimError::Tui(e.to_string()))?; let mut stdout = io::stdout(); @@ -72,25 +177,11 @@ pub fn run_tui(engine: Engine, config: Config) -> Result<()> { let timeout = refresh_dur.saturating_sub(last_refresh.elapsed()); if event::poll(timeout).map_err(|e| crate::error::GimError::Tui(e.to_string()))? { - if let Event::Key(key) = event::read().map_err(|e| crate::error::GimError::Tui(e.to_string()))? { + if let Event::Key(key) = + event::read().map_err(|e| crate::error::GimError::Tui(e.to_string()))? + { if key.kind == KeyEventKind::Press { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - let count = app.tab_count(); - if count > 0 { - app.selected_tab = (app.selected_tab + 1) % count; - } - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - let count = app.tab_count(); - if count > 0 { - app.selected_tab = - app.selected_tab.checked_sub(1).unwrap_or(count - 1); - } - } - _ => {} - } + handle_key(&mut app, key.code); } } } @@ -101,6 +192,19 @@ pub fn run_tui(engine: Engine, config: Config) -> Result<()> { if last_refresh.elapsed() >= refresh_dur { app.refresh(); + // Also refresh processes if in process zoom view + if let ViewMode::ZoomIn(idx) = &app.view_mode { + if app + .snapshot + .as_ref() + .and_then(|s| s.modules.get(*idx)) + .map(|(n, _)| n == "process") + .unwrap_or(false) + { + app.refresh_processes(); + app.apply_fuzzy_filter(); + } + } last_refresh = Instant::now(); } } @@ -115,56 +219,157 @@ pub fn run_tui(engine: Engine, config: Config) -> Result<()> { Ok(()) } +// ── Input handling ────────────────────────────────────────────────── + +fn handle_key(app: &mut App, code: KeyCode) { + // If search is active, handle text input first + if app.search_active { + match code { + KeyCode::Esc => { + app.search_active = false; + app.search_query.clear(); + app.filtered_processes = app.all_processes.clone(); + app.process_selected = 0; + } + KeyCode::Char(c) => { + app.search_query.push(c); + app.apply_fuzzy_filter(); + } + KeyCode::Backspace => { + app.search_query.pop(); + app.apply_fuzzy_filter(); + } + KeyCode::Down | KeyCode::Tab => { + let max = app.filtered_processes.len().saturating_sub(1); + app.process_selected = (app.process_selected + 1).min(max); + } + KeyCode::Up | KeyCode::BackTab => { + app.process_selected = app.process_selected.saturating_sub(1); + } + _ => {} + } + return; + } + + match &app.view_mode { + ViewMode::Dashboard => match code { + KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + let count = app.tab_count(); + if count > 0 { + app.selected_tab = (app.selected_tab + 1) % count; + } + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + let count = app.tab_count(); + if count > 0 { + app.selected_tab = app.selected_tab.checked_sub(1).unwrap_or(count - 1); + } + } + KeyCode::Enter => { + let is_process = app.current_module_name().as_deref() == Some("process"); + app.view_mode = ViewMode::ZoomIn(app.selected_tab); + if is_process { + app.refresh_processes(); + app.search_active = true; + } + } + _ => {} + }, + ViewMode::ZoomIn(idx) => { + let is_process = app + .snapshot + .as_ref() + .and_then(|s| s.modules.get(*idx)) + .map(|(n, _)| n == "process") + .unwrap_or(false); + + match code { + KeyCode::Esc | KeyCode::Char('q') => { + app.view_mode = ViewMode::Dashboard; + app.search_query.clear(); + app.search_active = false; + } + KeyCode::Char('/') | KeyCode::Char('f') if is_process => { + app.search_active = true; + } + KeyCode::Down | KeyCode::Char('j') if is_process => { + let max = app.filtered_processes.len().saturating_sub(1); + app.process_selected = (app.process_selected + 1).min(max); + } + KeyCode::Up | KeyCode::Char('k') if is_process => { + app.process_selected = app.process_selected.saturating_sub(1); + } + _ => {} + } + } + } +} + +// ── Drawing ───────────────────────────────────────────────────────── + fn draw_ui(frame: &mut ratatui::Frame, app: &App) { - let area = frame.area(); - let theme = &app.config.theme; + match &app.view_mode { + ViewMode::Dashboard => draw_dashboard(frame, app), + ViewMode::ZoomIn(idx) => { + if let Some(snapshot) = &app.snapshot { + if let Some((name, _)) = snapshot.modules.get(*idx) { + if name == "process" { + draw_process_finder(frame, app); + } else { + draw_module_detail(frame, app, *idx); + } + } + } + } + } +} - let chrome_border = parse_color(&theme.chrome.border); - let chrome_title = parse_color(&theme.chrome.title); +// ── Dashboard view ────────────────────────────────────────────────── - let border_type = match app.config.tui.borders { - BorderStyle::None => ratatui::widgets::BorderType::Plain, - BorderStyle::Plain => ratatui::widgets::BorderType::Plain, - BorderStyle::Rounded => ratatui::widgets::BorderType::Rounded, - }; +fn draw_dashboard(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let border_type = resolve_border_type(&app.config); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), - Constraint::Min(10), - Constraint::Length(3), + Constraint::Length(3), // header + Constraint::Min(10), // modules + Constraint::Length(3), // footer ]) .split(area); - draw_header(frame, chunks[0], app, chrome_border, chrome_title, border_type); + draw_header(frame, chunks[0], app, border_type); draw_modules(frame, chunks[1], app, border_type); - draw_footer(frame, chunks[2], app, chrome_border, border_type); + draw_footer(frame, chunks[2], app, border_type); } fn draw_header( frame: &mut ratatui::Frame, area: Rect, app: &App, - border_color: ratatui::style::Color, - title_color: ratatui::style::Color, border_type: ratatui::widgets::BorderType, ) { + let border_color = resolve_border(&app.config); + let title_color = resolve_title(&app.config); + let mut tabs: Vec = Vec::new(); if let Some(snapshot) = &app.snapshot { for (i, (name, _)) in snapshot.modules.iter().enumerate() { - let module_color = module_fg_color(&app.config, name); + let fg = resolve_module_fg(&app.config, name); + let label = module_label(&app.config, name); if i == app.selected_tab { tabs.push(Span::styled( - format!(" [{}] ", name.to_uppercase()), + format!(" [{}] ", label), Style::default() - .fg(module_color) + .fg(fg) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), )); } else { tabs.push(Span::styled( - format!(" {} ", name.to_uppercase()), - Style::default().fg(module_color), + format!(" {} ", label), + Style::default().fg(fg), )); } } @@ -185,34 +390,6 @@ fn draw_header( frame.render_widget(header, area); } -fn draw_footer( - frame: &mut ratatui::Frame, - area: Rect, - app: &App, - border_color: ratatui::style::Color, - border_type: ratatui::widgets::BorderType, -) { - if !app.config.tui.show_help { - return; - } - - let help = Paragraph::new(Line::from(vec![ - Span::styled("q", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" quit "), - Span::styled("←/→", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" switch tab "), - Span::styled("Tab", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" next "), - ])) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(border_type) - .border_style(Style::default().fg(border_color)), - ); - frame.render_widget(help, area); -} - fn draw_modules( frame: &mut ratatui::Frame, area: Rect, @@ -223,81 +400,557 @@ fn draw_modules( Some(s) => s, None => return, }; - if snapshot.modules.is_empty() { return; } - let module_constraints: Vec = snapshot - .modules - .iter() - .map(|_| Constraint::Ratio(1, snapshot.modules.len() as u32)) - .collect(); + let num = snapshot.modules.len(); + let cols = if num <= 3 { num as u32 } else { 3 }; + let rows = (num as u32 + cols - 1) / cols; - let module_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints(module_constraints) + let row_constraints: Vec = (0..rows).map(|_| Constraint::Ratio(1, rows)).collect(); + let row_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(row_constraints) .split(area); + let mut rects = Vec::new(); + for row_area in row_chunks.iter() { + let col_constraints: Vec = + (0..cols).map(|_| Constraint::Ratio(1, cols)).collect(); + let col_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(col_constraints) + .split(*row_area); + rects.extend(col_chunks.iter().copied()); + } + + let border_color = resolve_border(&app.config); + for (i, (name, data)) in snapshot.modules.iter().enumerate() { - let fg = module_fg_color(&app.config, name); - let accent = module_accent_color(&app.config, name); - let border_color = if i == app.selected_tab { - fg - } else { - parse_color(&app.config.theme.chrome.border) - }; + let target = rects.get(i).copied().unwrap_or_default(); + if target.width == 0 || target.height == 0 { + continue; + } + + let fg = resolve_module_fg(&app.config, name); + let accent = resolve_module_accent(&app.config, name); + let panel_border = if i == app.selected_tab { fg } else { border_color }; let block = Block::default() .borders(Borders::ALL) .border_type(border_type) - .border_style(Style::default().fg(border_color)) + .border_style(Style::default().fg(panel_border)) + .padding(ratatui::widgets::Padding::symmetric(1, 0)) .title(Span::styled( format!(" {} ", module_label(&app.config, name)), Style::default().fg(fg).add_modifier(Modifier::BOLD), )); - let inner = block.inner(module_chunks[i]); - frame.render_widget(block, module_chunks[i]); + let inner = block.inner(target); + frame.render_widget(block, target); + + // Layout: gauge (if applicable) + sparkline + details + let has_gauge = extract_gauge(name, data).is_some(); + let has_history = app + .history + .get(name) + .map(|h| !h.is_empty()) + .unwrap_or(false); + + let mut constraints = Vec::new(); + if has_gauge { + constraints.push(Constraint::Length(2)); + } + if has_history { + constraints.push(Constraint::Length(4)); + } + constraints.push(Constraint::Min(1)); let inner_chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(1)]) + .constraints(constraints) .split(inner); + let mut chunk_idx = 0; + + // Gauge if let Some(gauge_data) = extract_gauge(name, data) { let gauge = Gauge::default() - .gauge_style(Style::default().fg(accent)) + .gauge_style(Style::default().fg(accent).bg(Color::DarkGray)) + .use_unicode(true) .ratio(gauge_data.ratio.clamp(0.0, 1.0)) - .label(format!( - "{}: {:.1}%", - gauge_data.label, - gauge_data.ratio * 100.0 + .label(Span::styled( + format!("{:.1}%", gauge_data.ratio * 100.0), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), )); - frame.render_widget(gauge, inner_chunks[0]); + frame.render_widget(gauge, inner_chunks[chunk_idx]); + chunk_idx += 1; + } + + // Sparkline + if has_history { + if let Some(history) = app.history.get(name) { + let data_vec: Vec = history.iter().map(|&v| v as u64).collect(); + let sparkline = Sparkline::default() + .data(data_vec) + .style(Style::default().fg(accent)) + .max(100); + frame.render_widget(sparkline, inner_chunks[chunk_idx]); + } + chunk_idx += 1; } + // Details + let detail_area = inner_chunks[chunk_idx]; let mut lines: Vec = Vec::new(); let mut entries: Vec<_> = data.metrics.iter().collect(); - entries.sort_by_key(|(k, _)| k.clone()); + entries.sort_by_key(|(k, _)| *k); for (key, value) in entries { lines.push(Line::from(vec![ - Span::styled( - format!("{}: ", key), - Style::default().fg(fg).add_modifier(Modifier::BOLD), - ), - Span::raw(metric_display(value)), + Span::styled(format!("{} ", key), Style::default().fg(fg)), + Span::styled("→ ", Style::default().fg(Color::DarkGray)), + Span::styled(metric_display(value), Style::default().fg(Color::White)), ])); } let detail = Paragraph::new(lines).wrap(Wrap { trim: true }); - frame.render_widget(detail, inner_chunks[1]); + frame.render_widget(detail, detail_area); + } +} + +fn draw_footer( + frame: &mut ratatui::Frame, + area: Rect, + app: &App, + border_type: ratatui::widgets::BorderType, +) { + if !app.config.tui.show_help { + return; + } + + let border_color = resolve_border(&app.config); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(30), Constraint::Length(36)]) + .split(area); + + // Help keys + let help = Paragraph::new(Line::from(vec![ + Span::styled( + "q", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ), + Span::styled(" quit ", Style::default().fg(Color::DarkGray)), + Span::styled( + "←→", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ), + Span::styled(" tab ", Style::default().fg(Color::DarkGray)), + Span::styled( + "⏎", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ), + Span::styled(" zoom ", Style::default().fg(Color::DarkGray)), + Span::styled( + "esc", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ), + Span::styled(" back", Style::default().fg(Color::DarkGray)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)), + ); + frame.render_widget(help, chunks[0]); + + // Color palette — terminal's ANSI 16 colors + let palette_colors = [ + Color::Black, + Color::Red, + Color::Green, + Color::Yellow, + Color::Blue, + Color::Magenta, + Color::Cyan, + Color::White, + Color::DarkGray, + Color::LightRed, + Color::LightGreen, + Color::LightYellow, + Color::LightBlue, + Color::LightMagenta, + Color::LightCyan, + Color::White, + ]; + let spans: Vec = palette_colors + .iter() + .map(|&c| Span::styled("██", Style::default().fg(c))) + .collect(); + + let palette = Paragraph::new(Line::from(spans)) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)), + ); + frame.render_widget(palette, chunks[1]); +} + +// ── Process finder view ───────────────────────────────────────────── + +fn draw_process_finder(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let border_color = resolve_border(&app.config); + let accent = resolve_module_fg(&app.config, "process"); + let border_type = resolve_border_type(&app.config); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // search bar + Constraint::Length(1), // column headers + Constraint::Min(5), // process list + Constraint::Length(3), // footer + ]) + .split(area); + + // Search bar + let search_icon = if app.config.tui.icons { " " } else { ">" }; + let cursor = if app.search_active { "█" } else { "" }; + let search = Paragraph::new(Line::from(vec![ + Span::styled( + format!("{} {}", search_icon, app.search_query), + Style::default().fg(Color::White), + ), + Span::styled(cursor, Style::default().fg(accent)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(if app.search_active { + accent + } else { + border_color + })) + .title(Span::styled( + if app.config.tui.icons { + " Process Finder " + } else { + " Process Finder " + }, + Style::default().fg(accent).add_modifier(Modifier::BOLD), + )), + ); + frame.render_widget(search, chunks[0]); + + // Column headers + let headers = Paragraph::new(Line::from(vec![Span::styled( + format!( + " {:>7} {:<20} {:>6} {:>10} {}", + "PID", "NAME", "CPU%", "MEM", "COMMAND" + ), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + )])); + frame.render_widget(headers, chunks[1]); + + // Process list + let visible_height = chunks[2].height as usize; + let scroll = if app.process_selected >= visible_height { + app.process_selected - visible_height + 1 + } else { + 0 + }; + + let mut lines: Vec = Vec::new(); + for (i, proc_entry) in app + .filtered_processes + .iter() + .enumerate() + .skip(scroll) + .take(visible_height) + { + let is_selected = i == app.process_selected; + let mem_str = format_mem(proc_entry.memory); + let line_str = format!( + " {:>7} {:<20} {:>5.1}% {:>10} {}", + proc_entry.pid, + truncate_str(&proc_entry.name, 20), + proc_entry.cpu, + mem_str, + truncate_str(&proc_entry.command, 40), + ); + + let style = if is_selected { + Style::default() + .bg(accent) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + lines.push(Line::styled(line_str, style)); + } + + let list_block = Block::default() + .borders(Borders::LEFT | Borders::RIGHT) + .border_type(border_type) + .border_style(Style::default().fg(border_color)); + let process_list = Paragraph::new(lines).block(list_block); + frame.render_widget(process_list, chunks[2]); + + // Footer + let count_text = format!( + " {}/{} processes", + app.filtered_processes.len(), + app.all_processes.len() + ); + let footer = Paragraph::new(Line::from(vec![ + Span::styled(count_text, Style::default().fg(accent)), + Span::raw(" "), + Span::styled( + "/", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ), + Span::styled(" search ", Style::default().fg(Color::DarkGray)), + Span::styled( + "j/k", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ), + Span::styled(" navigate ", Style::default().fg(Color::DarkGray)), + Span::styled( + "esc", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ), + Span::styled(" back", Style::default().fg(Color::DarkGray)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)), + ); + frame.render_widget(footer, chunks[3]); +} + +// ── Module detail view ────────────────────────────────────────────── + +fn draw_module_detail(frame: &mut ratatui::Frame, app: &App, idx: usize) { + let area = frame.area(); + let snapshot = match &app.snapshot { + Some(s) => s, + None => return, + }; + let (name, data) = match snapshot.modules.get(idx) { + Some(m) => m, + None => return, + }; + + let fg = resolve_module_fg(&app.config, name); + let accent = resolve_module_accent(&app.config, name); + let border_color = resolve_border(&app.config); + let border_type = resolve_border_type(&app.config); + + let has_gauge = extract_gauge(name, data).is_some(); + let has_history = app + .history + .get(name) + .map(|h| !h.is_empty()) + .unwrap_or(false); + + let mut constraints = Vec::new(); + if has_gauge { + constraints.push(Constraint::Length(4)); + } + if has_history { + constraints.push(Constraint::Length(8)); + } + constraints.push(Constraint::Min(5)); // details + constraints.push(Constraint::Length(3)); // footer + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let mut chunk_idx = 0; + + // Gauge + if let Some(gauge_data) = extract_gauge(name, data) { + let gauge = Gauge::default() + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)) + .title(Span::styled( + format!(" {} ", module_label(&app.config, name)), + Style::default().fg(fg).add_modifier(Modifier::BOLD), + )), + ) + .gauge_style(Style::default().fg(accent).bg(Color::DarkGray)) + .use_unicode(true) + .ratio(gauge_data.ratio.clamp(0.0, 1.0)) + .label(Span::styled( + format!("{:.1}%", gauge_data.ratio * 100.0), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )); + frame.render_widget(gauge, chunks[chunk_idx]); + chunk_idx += 1; + } + + // Sparkline + if has_history { + if let Some(history) = app.history.get(name) { + let data_vec: Vec = history.iter().map(|&v| v as u64).collect(); + let sparkline = Sparkline::default() + .block( + Block::default() + .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP) + .border_type(border_type) + .border_style(Style::default().fg(border_color)) + .title(Span::styled( + " History ", + Style::default().fg(Color::DarkGray), + )), + ) + .data(data_vec) + .style(Style::default().fg(accent)) + .max(100); + frame.render_widget(sparkline, chunks[chunk_idx]); + } + chunk_idx += 1; + } + + // Details + let mut lines: Vec = Vec::new(); + let mut entries: Vec<_> = data.metrics.iter().collect(); + entries.sort_by_key(|(k, _)| *k); + + for (key, value) in entries { + lines.push(Line::from(vec![ + Span::styled( + format!(" {} ", key), + Style::default().fg(fg).add_modifier(Modifier::BOLD), + ), + Span::styled("→ ", Style::default().fg(Color::DarkGray)), + Span::styled(metric_display(value), Style::default().fg(Color::White)), + ])); + } + + let detail_title = if !has_gauge { + format!(" {} — Details ", module_label(&app.config, name)) + } else { + " Details ".to_string() + }; + + let detail = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)) + .title(Span::styled( + detail_title, + Style::default().fg(if has_gauge { Color::DarkGray } else { fg }), + )), + ) + .wrap(Wrap { trim: true }); + frame.render_widget(detail, chunks[chunk_idx]); + chunk_idx += 1; + + // Footer + let footer = Paragraph::new(Line::from(vec![ + Span::styled( + "esc", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ), + Span::styled(" back ", Style::default().fg(Color::DarkGray)), + Span::styled( + "q", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ), + Span::styled(" quit", Style::default().fg(Color::DarkGray)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type) + .border_style(Style::default().fg(border_color)), + ); + frame.render_widget(footer, chunks[chunk_idx]); +} + +// ── Helpers ───────────────────────────────────────────────────────── + +fn resolve_border_type(config: &Config) -> ratatui::widgets::BorderType { + match config.tui.borders { + BorderStyle::None | BorderStyle::Plain => ratatui::widgets::BorderType::Plain, + BorderStyle::Rounded => ratatui::widgets::BorderType::Rounded, + } +} + +fn resolve_module_fg(config: &Config, name: &str) -> Color { + match config.theme_mode { + ThemeMode::Terminal => crate::config::ansi_module_color(name), + ThemeMode::Custom => module_fg_color(config, name), + } +} + +fn resolve_module_accent(config: &Config, name: &str) -> Color { + match config.theme_mode { + ThemeMode::Terminal => crate::config::ansi_module_color(name), + ThemeMode::Custom => module_accent_color(config, name), + } +} + +fn resolve_border(config: &Config) -> Color { + match config.theme_mode { + ThemeMode::Terminal => crate::config::ansi_chrome_border(), + ThemeMode::Custom => parse_color(&config.theme.chrome.border), + } +} + +fn resolve_title(config: &Config) -> Color { + match config.theme_mode { + ThemeMode::Terminal => crate::config::ansi_chrome_title(), + ThemeMode::Custom => parse_color(&config.theme.chrome.title), } } struct GaugeData { - label: String, ratio: f64, } @@ -310,10 +963,21 @@ fn extract_gauge(module_name: &str, data: &crate::core::MetricData) -> Option Some(GaugeData { - label: module_name.to_uppercase(), - ratio: *f / 100.0, - }), + MetricValue::Float(f) => Some(GaugeData { ratio: *f / 100.0 }), + _ => None, + }) +} + +fn extract_history_value(module_name: &str, data: &crate::core::MetricData) -> Option { + let key = match module_name { + "cpu" => "cpu_usage_percent", + "memory" => "memory_usage_percent", + "disk" => "usage_percent", + _ => return None, + }; + + data.metrics.get(key).and_then(|v| match v { + MetricValue::Float(f) => Some(*f), _ => None, }) } @@ -335,16 +999,37 @@ fn metric_display(value: &MetricValue) -> String { fn format_bytes_smart(value: i64) -> String { let abs = value.unsigned_abs(); if abs >= 1_073_741_824 { - format!("{:.2} GB", abs as f64 / 1_073_741_824.0) + format!("{:.1} GB", abs as f64 / 1_073_741_824.0) } else if abs >= 1_048_576 { - format!("{:.2} MB", abs as f64 / 1_048_576.0) + format!("{:.1} MB", abs as f64 / 1_048_576.0) } else if abs >= 1024 { - format!("{:.2} KB", abs as f64 / 1024.0) + format!("{:.1} KB", abs as f64 / 1024.0) } else { value.to_string() } } +fn format_mem(bytes: u64) -> String { + if bytes >= 1_073_741_824 { + format!("{:.1} GB", bytes as f64 / 1_073_741_824.0) + } else if bytes >= 1_048_576 { + format!("{:.1} MB", bytes as f64 / 1_048_576.0) + } else if bytes >= 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{} B", bytes) + } +} + +fn truncate_str(s: &str, max: usize) -> String { + if s.chars().count() > max { + let truncated: String = s.chars().take(max - 1).collect(); + format!("{}…", truncated) + } else { + s.to_string() + } +} + fn module_theme<'a>(config: &'a Config, name: &str) -> &'a ModuleTheme { match name { "cpu" => &config.theme.cpu, @@ -357,19 +1042,31 @@ fn module_theme<'a>(config: &'a Config, name: &str) -> &'a ModuleTheme { } } -fn module_fg_color(config: &Config, name: &str) -> ratatui::style::Color { +fn module_fg_color(config: &Config, name: &str) -> Color { parse_color(&module_theme(config, name).fg) } -fn module_accent_color(config: &Config, name: &str) -> ratatui::style::Color { +fn module_accent_color(config: &Config, name: &str) -> Color { parse_color(&module_theme(config, name).accent) } fn module_label(config: &Config, name: &str) -> String { - let label = &module_theme(config, name).label; - if label.is_empty() { - name.to_uppercase() + if config.tui.icons { + match name { + "cpu" => " CPU".into(), + "memory" => " MEM".into(), + "disk" => " DSK".into(), + "network" => "󰈀 NET".into(), + "process" => " PRC".into(), + "system" => "󰒋 SYS".into(), + _ => name.to_uppercase(), + } } else { - label.clone() + let label = &module_theme(config, name).label; + if label.is_empty() { + name.to_uppercase() + } else { + label.clone() + } } }