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
This commit is contained in:
2026-05-08 11:56:55 +01:00
parent cb4f56b633
commit 5c90afdad8
6 changed files with 203 additions and 0 deletions
@@ -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"]
@@ -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")?,
@@ -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::<Vec<_>>())
.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<Line> = 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<Line<'a>> {
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<Line<'a>> {
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<Line<'a>> {
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<Line<'a>> {
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<Line<'a>> {
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."),
]
}
}