Skip to content

Commit 64d95af

Browse files
authored
Merge pull request #5 from dev-dami/feat/tui-dashboard
feat: implement interactive TUI dashboard with ratatui
2 parents 5f5565c + 14323ef commit 64d95af

1 file changed

Lines changed: 375 additions & 1 deletion

File tree

src/tui/mod.rs

Lines changed: 375 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,375 @@
1-
// placeholder for TUI functionality
1+
use std::io;
2+
use std::time::{Duration, Instant};
3+
4+
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
5+
use crossterm::execute;
6+
use crossterm::terminal::{
7+
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
8+
};
9+
use ratatui::backend::CrosstermBackend;
10+
use ratatui::layout::{Constraint, Direction, Layout, Rect};
11+
use ratatui::style::{Modifier, Style};
12+
use ratatui::text::{Line, Span};
13+
use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
14+
use ratatui::Terminal;
15+
16+
use crate::config::{parse_color, BorderStyle, Config, ModuleTheme};
17+
use crate::core::MetricValue;
18+
use crate::engine::{Engine, MetricsSnapshot};
19+
use crate::error::Result;
20+
21+
pub struct App {
22+
engine: Engine,
23+
config: Config,
24+
snapshot: Option<MetricsSnapshot>,
25+
selected_tab: usize,
26+
should_quit: bool,
27+
}
28+
29+
impl App {
30+
pub fn new(engine: Engine, config: Config) -> Self {
31+
Self {
32+
engine,
33+
config,
34+
snapshot: None,
35+
selected_tab: 0,
36+
should_quit: false,
37+
}
38+
}
39+
40+
fn refresh(&mut self) {
41+
self.snapshot = Some(self.engine.collect_once());
42+
}
43+
44+
fn tab_count(&self) -> usize {
45+
self.snapshot
46+
.as_ref()
47+
.map(|s| s.modules.len())
48+
.unwrap_or(0)
49+
}
50+
}
51+
52+
pub fn run_tui(engine: Engine, config: Config) -> Result<()> {
53+
enable_raw_mode().map_err(|e| crate::error::GimError::Tui(e.to_string()))?;
54+
let mut stdout = io::stdout();
55+
execute!(stdout, EnterAlternateScreen)
56+
.map_err(|e| crate::error::GimError::Tui(e.to_string()))?;
57+
58+
let backend = CrosstermBackend::new(stdout);
59+
let mut terminal =
60+
Terminal::new(backend).map_err(|e| crate::error::GimError::Tui(e.to_string()))?;
61+
62+
let mut app = App::new(engine, config.clone());
63+
app.refresh();
64+
65+
let refresh_dur = Duration::from_millis(config.tui_refresh_ms());
66+
let mut last_refresh = Instant::now();
67+
68+
loop {
69+
terminal
70+
.draw(|frame| draw_ui(frame, &app))
71+
.map_err(|e| crate::error::GimError::Tui(e.to_string()))?;
72+
73+
let timeout = refresh_dur.saturating_sub(last_refresh.elapsed());
74+
if event::poll(timeout).map_err(|e| crate::error::GimError::Tui(e.to_string()))? {
75+
if let Event::Key(key) = event::read().map_err(|e| crate::error::GimError::Tui(e.to_string()))? {
76+
if key.kind == KeyEventKind::Press {
77+
match key.code {
78+
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
79+
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
80+
let count = app.tab_count();
81+
if count > 0 {
82+
app.selected_tab = (app.selected_tab + 1) % count;
83+
}
84+
}
85+
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
86+
let count = app.tab_count();
87+
if count > 0 {
88+
app.selected_tab =
89+
app.selected_tab.checked_sub(1).unwrap_or(count - 1);
90+
}
91+
}
92+
_ => {}
93+
}
94+
}
95+
}
96+
}
97+
98+
if app.should_quit {
99+
break;
100+
}
101+
102+
if last_refresh.elapsed() >= refresh_dur {
103+
app.refresh();
104+
last_refresh = Instant::now();
105+
}
106+
}
107+
108+
disable_raw_mode().map_err(|e| crate::error::GimError::Tui(e.to_string()))?;
109+
execute!(terminal.backend_mut(), LeaveAlternateScreen)
110+
.map_err(|e| crate::error::GimError::Tui(e.to_string()))?;
111+
terminal
112+
.show_cursor()
113+
.map_err(|e| crate::error::GimError::Tui(e.to_string()))?;
114+
115+
Ok(())
116+
}
117+
118+
fn draw_ui(frame: &mut ratatui::Frame, app: &App) {
119+
let area = frame.area();
120+
let theme = &app.config.theme;
121+
122+
let chrome_border = parse_color(&theme.chrome.border);
123+
let chrome_title = parse_color(&theme.chrome.title);
124+
125+
let border_type = match app.config.tui.borders {
126+
BorderStyle::None => ratatui::widgets::BorderType::Plain,
127+
BorderStyle::Plain => ratatui::widgets::BorderType::Plain,
128+
BorderStyle::Rounded => ratatui::widgets::BorderType::Rounded,
129+
};
130+
131+
let chunks = Layout::default()
132+
.direction(Direction::Vertical)
133+
.constraints([
134+
Constraint::Length(3),
135+
Constraint::Min(10),
136+
Constraint::Length(3),
137+
])
138+
.split(area);
139+
140+
draw_header(frame, chunks[0], app, chrome_border, chrome_title, border_type);
141+
draw_modules(frame, chunks[1], app, border_type);
142+
draw_footer(frame, chunks[2], app, chrome_border, border_type);
143+
}
144+
145+
fn draw_header(
146+
frame: &mut ratatui::Frame,
147+
area: Rect,
148+
app: &App,
149+
border_color: ratatui::style::Color,
150+
title_color: ratatui::style::Color,
151+
border_type: ratatui::widgets::BorderType,
152+
) {
153+
let mut tabs: Vec<Span> = Vec::new();
154+
if let Some(snapshot) = &app.snapshot {
155+
for (i, (name, _)) in snapshot.modules.iter().enumerate() {
156+
let module_color = module_fg_color(&app.config, name);
157+
if i == app.selected_tab {
158+
tabs.push(Span::styled(
159+
format!(" [{}] ", name.to_uppercase()),
160+
Style::default()
161+
.fg(module_color)
162+
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
163+
));
164+
} else {
165+
tabs.push(Span::styled(
166+
format!(" {} ", name.to_uppercase()),
167+
Style::default().fg(module_color),
168+
));
169+
}
170+
}
171+
}
172+
173+
let header = Paragraph::new(Line::from(tabs)).block(
174+
Block::default()
175+
.borders(Borders::ALL)
176+
.border_type(border_type)
177+
.border_style(Style::default().fg(border_color))
178+
.title(Span::styled(
179+
" gim ",
180+
Style::default()
181+
.fg(title_color)
182+
.add_modifier(Modifier::BOLD),
183+
)),
184+
);
185+
frame.render_widget(header, area);
186+
}
187+
188+
fn draw_footer(
189+
frame: &mut ratatui::Frame,
190+
area: Rect,
191+
app: &App,
192+
border_color: ratatui::style::Color,
193+
border_type: ratatui::widgets::BorderType,
194+
) {
195+
if !app.config.tui.show_help {
196+
return;
197+
}
198+
199+
let help = Paragraph::new(Line::from(vec![
200+
Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
201+
Span::raw(" quit "),
202+
Span::styled("←/→", Style::default().add_modifier(Modifier::BOLD)),
203+
Span::raw(" switch tab "),
204+
Span::styled("Tab", Style::default().add_modifier(Modifier::BOLD)),
205+
Span::raw(" next "),
206+
]))
207+
.block(
208+
Block::default()
209+
.borders(Borders::ALL)
210+
.border_type(border_type)
211+
.border_style(Style::default().fg(border_color)),
212+
);
213+
frame.render_widget(help, area);
214+
}
215+
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+
348+
fn module_theme<'a>(config: &'a Config, name: &str) -> &'a ModuleTheme {
349+
match name {
350+
"cpu" => &config.theme.cpu,
351+
"memory" => &config.theme.memory,
352+
"disk" => &config.theme.disk,
353+
"network" => &config.theme.network,
354+
"process" => &config.theme.process,
355+
"system" => &config.theme.system,
356+
_ => &config.theme.cpu,
357+
}
358+
}
359+
360+
fn module_fg_color(config: &Config, name: &str) -> ratatui::style::Color {
361+
parse_color(&module_theme(config, name).fg)
362+
}
363+
364+
fn module_accent_color(config: &Config, name: &str) -> ratatui::style::Color {
365+
parse_color(&module_theme(config, name).accent)
366+
}
367+
368+
fn module_label(config: &Config, name: &str) -> String {
369+
let label = &module_theme(config, name).label;
370+
if label.is_empty() {
371+
name.to_uppercase()
372+
} else {
373+
label.clone()
374+
}
375+
}

0 commit comments

Comments
 (0)