From 5c90afdad8802be64033db0d2cc1ada3d0de1313 Mon Sep 17 00:00:00 2001 From: Vasilito Date: Fri, 8 May 2026 11:56:55 +0100 Subject: [PATCH] feat: -i interactive ratatui TUI for redbear-info and redbear-netctl - redbear-info: -i launches ratatui dashboard (System/Hardware/Network/Integrations/Health tabs) - redbear-netctl: -i wires existing netctl-console ratatui TUI - Same -i switch convention as cub - Feature-gated behind 'tui' feature for both apps --- local/recipes/system/redbear-info/recipe.toml | 1 + .../system/redbear-info/source/Cargo.toml | 6 + .../system/redbear-info/source/src/main.rs | 16 ++ .../system/redbear-info/source/src/tui.rs | 165 ++++++++++++++++++ .../system/redbear-netctl/source/Cargo.toml | 7 + .../system/redbear-netctl/source/src/main.rs | 8 + 6 files changed, 203 insertions(+) create mode 100644 local/recipes/system/redbear-info/source/src/tui.rs diff --git a/local/recipes/system/redbear-info/recipe.toml b/local/recipes/system/redbear-info/recipe.toml index 6aa97e264..20d2529a8 100644 --- a/local/recipes/system/redbear-info/recipe.toml +++ b/local/recipes/system/redbear-info/recipe.toml @@ -3,6 +3,7 @@ path = "source" [build] template = "cargo" +cargoflags = ["--features", "tui"] [package.files] "/usr/bin/redbear-info" = "redbear-info" diff --git a/local/recipes/system/redbear-info/source/Cargo.toml b/local/recipes/system/redbear-info/source/Cargo.toml index 2127adb5c..17dd46e22 100644 --- a/local/recipes/system/redbear-info/source/Cargo.toml +++ b/local/recipes/system/redbear-info/source/Cargo.toml @@ -9,5 +9,11 @@ path = "src/main.rs" [dependencies] redox-driver-sys = { path = "../../../../recipes/drivers/redox-driver-sys/source" } +ratatui = { version = "0.30", default-features = false, features = ["termion"], optional = true } +termion = { version = "4", optional = true } serde_json = "1" toml = "0.8" + +[features] +default = [] +tui = ["ratatui", "termion"] diff --git a/local/recipes/system/redbear-info/source/src/main.rs b/local/recipes/system/redbear-info/source/src/main.rs index 4b5cb898f..297f4926f 100644 --- a/local/recipes/system/redbear-info/source/src/main.rs +++ b/local/recipes/system/redbear-info/source/src/main.rs @@ -13,6 +13,9 @@ use toml::Value; #[cfg(test)] use std::path::Path; +#[cfg(feature = "tui")] +mod tui; + const RESET: &str = "\x1b[0m"; const GREEN: &str = "\x1b[32m"; const YELLOW: &str = "\x1b[33m"; @@ -38,6 +41,7 @@ enum OutputMode { Device, Health, Help, + Tui, } struct Options { @@ -523,6 +527,17 @@ fn main() { fn run() -> Result<(), String> { let options = parse_args(env::args())?; + + if options.mode == OutputMode::Tui { + #[cfg(feature = "tui")] + { + let runtime = Runtime::from_env(); + return tui::run(&runtime).map_err(|e| format!("TUI error: {e}")); + } + #[cfg(not(feature = "tui"))] + return Err("TUI support not compiled (enable 'tui' feature)".to_string()); + } + if options.mode == OutputMode::Help { print_help(); return Ok(()); @@ -1539,6 +1554,7 @@ where while let Some(arg) = args.next() { match arg.as_str() { "-v" | "--verbose" => verbose = true, + "-i" | "--interactive" => set_output_mode(&mut mode, OutputMode::Tui, "-i")?, "--json" => set_output_mode(&mut mode, OutputMode::Json, "--json")?, "--test" => set_output_mode(&mut mode, OutputMode::Test, "--test")?, "--quirks" => set_output_mode(&mut mode, OutputMode::Quirks, "--quirks")?, diff --git a/local/recipes/system/redbear-info/source/src/tui.rs b/local/recipes/system/redbear-info/source/src/tui.rs new file mode 100644 index 000000000..32eb1b5e6 --- /dev/null +++ b/local/recipes/system/redbear-info/source/src/tui.rs @@ -0,0 +1,165 @@ +use std::io; + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::prelude::TermionBackend; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Paragraph, Tabs, Wrap}; +use ratatui::Terminal; +use termion::event::Key; +use termion::input::TermRead; + +use super::{collect_report, IntegrationStatus, OutputMode, ProbeState, Report, Runtime}; + +pub fn run(runtime: &Runtime) -> io::Result<()> { + let mut terminal = Terminal::new(TermionBackend::new(io::stdout())) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + terminal.clear().map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + let mut app = TuiApp::new(runtime); + let events = termion::async_stdin().keys(); + + let _ = terminal.draw(|f| app.draw(f)); + for key in events { + match key.map_err(|e| io::Error::new(io::ErrorKind::Other, e))? { + Key::Char('q') | Key::Esc => break, + Key::Char('\t') => app.next_tab(), + Key::BackTab => app.prev_tab(), + Key::Char('r') => app.refresh(runtime), + Key::Down | Key::Char('j') => app.scroll_down(), + Key::Up | Key::Char('k') => app.scroll_up(), + _ => {} + } + let _ = terminal.draw(|f| app.draw(f)); + } + + let _ = terminal.show_cursor(); + Ok(()) +} + +struct TuiApp<'a> { + tab: usize, + scroll: usize, + report: Report<'a>, +} + +impl<'a> TuiApp<'a> { + fn new(runtime: &Runtime) -> Self { + Self { tab: 0, scroll: 0, report: collect_report(runtime) } + } + + fn refresh(&mut self, runtime: &Runtime) { self.report = collect_report(runtime); self.scroll = 0; } + fn next_tab(&mut self) { self.tab = (self.tab + 1) % 5; self.scroll = 0; } + fn prev_tab(&mut self) { self.tab = (self.tab + 4) % 5; self.scroll = 0; } + fn scroll_down(&mut self) { self.scroll = self.scroll.saturating_add(1); } + fn scroll_up(&mut self) { self.scroll = self.scroll.saturating_sub(1); } + + fn draw(&self, frame: &mut ratatui::Frame<'_>) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)]) + .split(area); + + let titles = [" System ", " Hardware ", " Network ", " Integrations ", " Health "]; + let tabs = Tabs::new(titles.iter().map(|t| Line::from(*t)).collect::>()) + .block(Block::default().borders(Borders::ALL).title(" redbear-info ")) + .style(Style::default().fg(Color::White)) + .highlight_style(Style::default().fg(Color::Red).bg(Color::DarkGray)) + .select(self.tab) + .divider("|"); + frame.render_widget(tabs, chunks[0]); + + let content = match self.tab { + 0 => self.system_view(), + 1 => self.hardware_view(), + 2 => self.network_view(), + 3 => self.integrations_view(), + 4 => self.health_view(), + _ => vec!["Unknown tab".into()], + }; + + let skipped = self.scroll.min(content.len().saturating_sub(chunks[1].height as usize - 2)); + let visible: Vec = content.into_iter().skip(skipped).take(chunks[1].height as usize - 2).collect(); + + let p = Paragraph::new(Text::from(visible)) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: false }); + frame.render_widget(p, chunks[1]); + + let help = Line::from(Span::styled( + " q/esc quit | Tab next | r refresh | j/k scroll ", + Style::default().fg(Color::DarkGray), + )); + frame.render_widget(Paragraph::new(help), chunks[2]); + } + + fn system_view(&self) -> Vec> { + let mut lines = vec![ + Line::from(Span::styled("System Information", Style::default().fg(Color::Red))), + Line::from(""), + ]; + if let Some(name) = &self.report.identity.pretty_name { lines.push(Line::from(format!("OS: {name}"))); } + if let Some(ver) = &self.report.identity.version_id { lines.push(Line::from(format!("Version: {ver}"))); } + if let Some(host) = &self.report.identity.hostname { lines.push(Line::from(format!("Host: {host}"))); } + lines + } + + fn hardware_view(&self) -> Vec> { + let h = &self.report.hardware; + let mut lines = vec![ + Line::from(Span::styled("Hardware", Style::default().fg(Color::Red))), Line::from(""), + Line::from(format!("PCI devices: {}", h.pci_devices)), + Line::from(format!(" IRQ: none={} legacy={} MSI={} MSI-X={}", h.pci_irq_none, h.pci_irq_legacy, h.pci_irq_msi, h.pci_irq_msix)), + Line::from(format!("USB controllers: {}", h.usb_controllers)), + Line::from(format!("DRM cards: {}", h.drm_cards)), + Line::from(format!("ACPI power: {}", if h.acpi_power_surface_present { "yes" } else { "no" })), + ]; + for irq in &h.runtime_irq_reports { + lines.push(Line::from(format!(" IRQ: {} pid={} {} mode={} reason={}", irq.driver, irq.pid, irq.device, irq.mode, irq.reason))); + } + lines + } + + fn network_view(&self) -> Vec> { + let n = &self.report.network; + let mut lines = vec![ + Line::from(Span::styled("Network", Style::default().fg(Color::Red))), Line::from(""), + Line::from(format!("State: {:?}", n.state)), + Line::from(format!("Connected: {}", if n.connected { "yes" } else { "no" })), + ]; + if let Some(iface) = &n.interface { lines.push(Line::from(format!("Interface: {iface}"))); } + if let Some(mac) = &n.mac { lines.push(Line::from(format!("MAC: {mac}"))); } + if let Some(addr) = &n.address { lines.push(Line::from(format!("Address: {addr}"))); } + if let Some(dns) = &n.dns { lines.push(Line::from(format!("DNS: {dns}"))); } + if let Some(route) = &n.default_route { lines.push(Line::from(format!("Route: {route}"))); } + if let Some(profile) = &n.active_profile { lines.push(Line::from(format!("Profile: {profile}"))); } + lines.push(Line::from(format!("WiFi state: {:?} interfaces: {:?}", n.wifi_control_state, n.wifi_interfaces))); + lines.push(Line::from(format!("BT state: {:?} adapters: {:?}", n.bluetooth_control_state, n.bluetooth_adapters))); + lines + } + + fn integrations_view(&self) -> Vec> { + let mut lines = vec![Line::from(Span::styled("Integrations", Style::default().fg(Color::Red))), Line::from("")]; + for s in &self.report.integrations { + let state = format!("{:?}", s.state); + let color = match s.state { ProbeState::Functional => Color::Green, ProbeState::Active => Color::Yellow, _ => Color::Red }; + lines.push(Line::from(vec![ + Span::styled(format!("[{state:>12}] ",), Style::default().fg(color)), + Span::raw(format!("{} ({})", s.check.name, s.check.category)), + ])); + } + lines + } + + fn health_view(&self) -> Vec> { + vec![ + Line::from(Span::styled("Health Dashboard", Style::default().fg(Color::Red))), Line::from(""), + Line::from(format!("PCI devices: {}", self.report.hardware.pci_devices)), + Line::from(format!("Network state: {:?}", self.report.network.state)), + Line::from(format!("Connected: {}", if self.report.network.connected { "✓" } else { "✗" })), + Line::from(""), + Line::from("Run with --health for detailed health check."), + ] + } +} diff --git a/local/recipes/system/redbear-netctl/source/Cargo.toml b/local/recipes/system/redbear-netctl/source/Cargo.toml index c4a0091c4..703e5ed5b 100644 --- a/local/recipes/system/redbear-netctl/source/Cargo.toml +++ b/local/recipes/system/redbear-netctl/source/Cargo.toml @@ -7,5 +7,12 @@ edition = "2024" name = "redbear-netctl" path = "src/main.rs" +[dependencies] +redbear-netctl-console = { path = "../../redbear-netctl-console/source", optional = true } + [dev-dependencies] redbear-netctl-console = { path = "../../redbear-netctl-console/source" } + +[features] +default = [] +tui = ["redbear-netctl-console"] diff --git a/local/recipes/system/redbear-netctl/source/src/main.rs b/local/recipes/system/redbear-netctl/source/src/main.rs index 437daaa77..c1283ae2f 100644 --- a/local/recipes/system/redbear-netctl/source/src/main.rs +++ b/local/recipes/system/redbear-netctl/source/src/main.rs @@ -88,6 +88,14 @@ fn run() -> Result<(), String> { println!("{}", usage()); Ok(()) } + "-i" | "--interactive" => { + #[cfg(feature = "tui")] + { + redbear_netctl_console::run().map_err(|e| format!("TUI error: {e}")) + } + #[cfg(not(feature = "tui"))] + Err("TUI support not compiled (enable 'tui' feature)".to_string()) + } _ => Err(usage()), } }