Skip to content

Commit 14323ef

Browse files
committed
feat(tui): add module panels with gauge bars and metric details
Each module gets a column panel with its themed border and title. Modules with a percentage metric (cpu, memory, disk) show a colored gauge bar at the top. Below it, all metrics are listed as key-value pairs sorted alphabetically. Selected tab highlights its panel border in the module's theme color. Large integer values auto-format as KB/MB/GB.
1 parent e8369ae commit 14323ef

1 file changed

Lines changed: 134 additions & 1 deletion

File tree

src/tui/mod.rs

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use ratatui::backend::CrosstermBackend;
1010
use ratatui::layout::{Constraint, Direction, Layout, Rect};
1111
use ratatui::style::{Modifier, Style};
1212
use ratatui::text::{Line, Span};
13-
use ratatui::widgets::{Block, Borders, Paragraph};
13+
use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
1414
use ratatui::Terminal;
1515

1616
use crate::config::{parse_color, BorderStyle, Config, ModuleTheme};
@@ -138,6 +138,7 @@ fn draw_ui(frame: &mut ratatui::Frame, app: &App) {
138138
.split(area);
139139

140140
draw_header(frame, chunks[0], app, chrome_border, chrome_title, border_type);
141+
draw_modules(frame, chunks[1], app, border_type);
141142
draw_footer(frame, chunks[2], app, chrome_border, border_type);
142143
}
143144

@@ -212,6 +213,138 @@ fn draw_footer(
212213
frame.render_widget(help, area);
213214
}
214215

216+
fn draw_modules(
217+
frame: &mut ratatui::Frame,
218+
area: Rect,
219+
app: &App,
220+
border_type: ratatui::widgets::BorderType,
221+
) {
222+
let snapshot = match &app.snapshot {
223+
Some(s) => s,
224+
None => return,
225+
};
226+
227+
if snapshot.modules.is_empty() {
228+
return;
229+
}
230+
231+
let module_constraints: Vec<Constraint> = snapshot
232+
.modules
233+
.iter()
234+
.map(|_| Constraint::Ratio(1, snapshot.modules.len() as u32))
235+
.collect();
236+
237+
let module_chunks = Layout::default()
238+
.direction(Direction::Horizontal)
239+
.constraints(module_constraints)
240+
.split(area);
241+
242+
for (i, (name, data)) in snapshot.modules.iter().enumerate() {
243+
let fg = module_fg_color(&app.config, name);
244+
let accent = module_accent_color(&app.config, name);
245+
let border_color = if i == app.selected_tab {
246+
fg
247+
} else {
248+
parse_color(&app.config.theme.chrome.border)
249+
};
250+
251+
let block = Block::default()
252+
.borders(Borders::ALL)
253+
.border_type(border_type)
254+
.border_style(Style::default().fg(border_color))
255+
.title(Span::styled(
256+
format!(" {} ", module_label(&app.config, name)),
257+
Style::default().fg(fg).add_modifier(Modifier::BOLD),
258+
));
259+
260+
let inner = block.inner(module_chunks[i]);
261+
frame.render_widget(block, module_chunks[i]);
262+
263+
let inner_chunks = Layout::default()
264+
.direction(Direction::Vertical)
265+
.constraints([Constraint::Length(3), Constraint::Min(1)])
266+
.split(inner);
267+
268+
if let Some(gauge_data) = extract_gauge(name, data) {
269+
let gauge = Gauge::default()
270+
.gauge_style(Style::default().fg(accent))
271+
.ratio(gauge_data.ratio.clamp(0.0, 1.0))
272+
.label(format!(
273+
"{}: {:.1}%",
274+
gauge_data.label,
275+
gauge_data.ratio * 100.0
276+
));
277+
frame.render_widget(gauge, inner_chunks[0]);
278+
}
279+
280+
let mut lines: Vec<Line> = Vec::new();
281+
let mut entries: Vec<_> = data.metrics.iter().collect();
282+
entries.sort_by_key(|(k, _)| k.clone());
283+
284+
for (key, value) in entries {
285+
lines.push(Line::from(vec![
286+
Span::styled(
287+
format!("{}: ", key),
288+
Style::default().fg(fg).add_modifier(Modifier::BOLD),
289+
),
290+
Span::raw(metric_display(value)),
291+
]));
292+
}
293+
294+
let detail = Paragraph::new(lines).wrap(Wrap { trim: true });
295+
frame.render_widget(detail, inner_chunks[1]);
296+
}
297+
}
298+
299+
struct GaugeData {
300+
label: String,
301+
ratio: f64,
302+
}
303+
304+
fn extract_gauge(module_name: &str, data: &crate::core::MetricData) -> Option<GaugeData> {
305+
let key = match module_name {
306+
"cpu" => "cpu_usage_percent",
307+
"memory" => "memory_usage_percent",
308+
"disk" => "usage_percent",
309+
_ => return None,
310+
};
311+
312+
data.metrics.get(key).and_then(|v| match v {
313+
MetricValue::Float(f) => Some(GaugeData {
314+
label: module_name.to_uppercase(),
315+
ratio: *f / 100.0,
316+
}),
317+
_ => None,
318+
})
319+
}
320+
321+
fn metric_display(value: &MetricValue) -> String {
322+
match value {
323+
MetricValue::Integer(i) => format_bytes_smart(*i),
324+
MetricValue::Float(f) => format!("{:.2}", f),
325+
MetricValue::String(s) => s.clone(),
326+
MetricValue::Boolean(b) => b.to_string(),
327+
MetricValue::List(items) => items
328+
.iter()
329+
.map(metric_display)
330+
.collect::<Vec<_>>()
331+
.join(", "),
332+
}
333+
}
334+
335+
fn format_bytes_smart(value: i64) -> String {
336+
let abs = value.unsigned_abs();
337+
if abs >= 1_073_741_824 {
338+
format!("{:.2} GB", abs as f64 / 1_073_741_824.0)
339+
} else if abs >= 1_048_576 {
340+
format!("{:.2} MB", abs as f64 / 1_048_576.0)
341+
} else if abs >= 1024 {
342+
format!("{:.2} KB", abs as f64 / 1024.0)
343+
} else {
344+
value.to_string()
345+
}
346+
}
347+
215348
fn module_theme<'a>(config: &'a Config, name: &str) -> &'a ModuleTheme {
216349
match name {
217350
"cpu" => &config.theme.cpu,

0 commit comments

Comments
 (0)