Skip to content

Commit 5b3a31a

Browse files
jakeyrgclaude
andcommitted
Improve TUI: line chart + dual latency sparklines + bigger chart
- Switch chart to GraphType::Line so points connect into a smooth line instead of isolated dots (still uses Braille subpixels — far more visible at small sizes). - Track unloaded and loaded latency samples in separate buffers and render them as side-by-side sparklines so both are visible simultaneously, including after measurement completes. - Shrink the latency row (5→4) and stats panel (7→6) so the chart gets three more vertical lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 056581e commit 5b3a31a

1 file changed

Lines changed: 76 additions & 30 deletions

File tree

src/tui.rs

Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ pub struct App {
1111
pub phase_started: Option<Instant>,
1212
pub download_samples: Vec<(f64, f64)>, // (elapsed_secs, mbps)
1313
pub upload_samples: Vec<(f64, f64)>,
14-
pub latency_samples: Vec<f64>, // ms, bounded to SPARKLINE_LEN
14+
pub unloaded_latency_samples: Vec<f64>, // ms, bounded to SPARKLINE_LEN
15+
pub loaded_latency_samples: Vec<f64>, // ms, bounded to SPARKLINE_LEN
1516
pub current_dl_mbps: f64,
1617
pub current_ul_mbps: f64,
1718
pub peak_dl_mbps: f64,
@@ -30,7 +31,8 @@ impl App {
3031
phase_started: None,
3132
download_samples: Vec::new(),
3233
upload_samples: Vec::new(),
33-
latency_samples: Vec::new(),
34+
unloaded_latency_samples: Vec::new(),
35+
loaded_latency_samples: Vec::new(),
3436
current_dl_mbps: 0.0,
3537
current_ul_mbps: 0.0,
3638
peak_dl_mbps: 0.0,
@@ -49,17 +51,14 @@ impl App {
4951
Progress::PhaseStart(phase) => {
5052
self.current_phase = Some(phase);
5153
self.phase_started = Some(Instant::now());
52-
if matches!(phase, Phase::UnloadedLatency | Phase::LoadedLatency) {
53-
self.latency_samples.clear();
54-
}
5554
}
5655
Progress::PhaseEnd(phase) => {
5756
match phase {
58-
Phase::UnloadedLatency if !self.latency_samples.is_empty() => {
59-
self.unloaded_latency_ms = Some(min_f64(&self.latency_samples));
57+
Phase::UnloadedLatency if !self.unloaded_latency_samples.is_empty() => {
58+
self.unloaded_latency_ms = Some(min_f64(&self.unloaded_latency_samples));
6059
}
61-
Phase::LoadedLatency if !self.latency_samples.is_empty() => {
62-
self.loaded_latency_ms = Some(min_f64(&self.latency_samples));
60+
Phase::LoadedLatency if !self.loaded_latency_samples.is_empty() => {
61+
self.loaded_latency_ms = Some(min_f64(&self.loaded_latency_samples));
6362
}
6463
_ => {}
6564
}
@@ -85,10 +84,17 @@ impl App {
8584
_ => {}
8685
},
8786
Progress::Latency { ms } => {
88-
self.latency_samples.push(ms);
89-
if self.latency_samples.len() > SPARKLINE_LEN {
90-
let drop = self.latency_samples.len() - SPARKLINE_LEN;
91-
self.latency_samples.drain(0..drop);
87+
let buf = match self.current_phase {
88+
Some(Phase::UnloadedLatency) => Some(&mut self.unloaded_latency_samples),
89+
Some(Phase::LoadedLatency) => Some(&mut self.loaded_latency_samples),
90+
_ => None,
91+
};
92+
if let Some(buf) = buf {
93+
buf.push(ms);
94+
if buf.len() > SPARKLINE_LEN {
95+
let drop = buf.len() - SPARKLINE_LEN;
96+
buf.drain(0..drop);
97+
}
9298
}
9399
}
94100
}
@@ -116,7 +122,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
116122
use ratatui::style::{Color, Modifier, Style};
117123
use ratatui::symbols;
118124
use ratatui::text::{Line, Span};
119-
use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Paragraph, Sparkline};
125+
use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Sparkline};
120126
use ratatui::Frame;
121127

122128
impl App {
@@ -135,9 +141,9 @@ impl App {
135141
let chunks = Layout::default()
136142
.direction(Direction::Vertical)
137143
.constraints([
138-
Constraint::Min(8), // chart
139-
Constraint::Length(5), // latency
140-
Constraint::Length(7), // stats + footer
144+
Constraint::Min(8), // chart (gets all leftover space)
145+
Constraint::Length(4), // latency row (two sparklines side-by-side)
146+
Constraint::Length(6), // stats + footer
141147
])
142148
.split(f.area());
143149

@@ -168,11 +174,13 @@ impl App {
168174
Dataset::default()
169175
.name("download")
170176
.marker(symbols::Marker::Braille)
177+
.graph_type(GraphType::Line)
171178
.style(Style::default().fg(Color::Cyan))
172179
.data(&dl),
173180
Dataset::default()
174181
.name("upload")
175182
.marker(symbols::Marker::Braille)
183+
.graph_type(GraphType::Line)
176184
.style(Style::default().fg(Color::Magenta))
177185
.data(&ul),
178186
];
@@ -205,21 +213,46 @@ impl App {
205213
}
206214

207215
fn render_latency(&self, f: &mut Frame, area: Rect) {
208-
let data: Vec<u64> = self.latency_samples.iter().map(|x| *x as u64).collect();
209-
let label = format!(
210-
"Latency unloaded {} loaded {}",
216+
let cols = Layout::default()
217+
.direction(Direction::Horizontal)
218+
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
219+
.split(area);
220+
221+
let unloaded_data: Vec<u64> = self
222+
.unloaded_latency_samples
223+
.iter()
224+
.map(|x| *x as u64)
225+
.collect();
226+
let loaded_data: Vec<u64> = self
227+
.loaded_latency_samples
228+
.iter()
229+
.map(|x| *x as u64)
230+
.collect();
231+
232+
let unloaded_label = format!(
233+
"Unloaded latency {}",
211234
self.unloaded_latency_ms
212235
.map(|x| format!("{x:.0} ms"))
213236
.unwrap_or_else(|| "—".into()),
237+
);
238+
let loaded_label = format!(
239+
"Loaded latency {}",
214240
self.loaded_latency_ms
215241
.map(|x| format!("{x:.0} ms"))
216242
.unwrap_or_else(|| "—".into()),
217243
);
218-
let sparkline = Sparkline::default()
219-
.block(Block::default().borders(Borders::ALL).title(label))
220-
.data(&data)
244+
245+
let unloaded = Sparkline::default()
246+
.block(Block::default().borders(Borders::ALL).title(unloaded_label))
247+
.data(&unloaded_data)
221248
.style(Style::default().fg(Color::Yellow));
222-
f.render_widget(sparkline, area);
249+
let loaded = Sparkline::default()
250+
.block(Block::default().borders(Borders::ALL).title(loaded_label))
251+
.data(&loaded_data)
252+
.style(Style::default().fg(Color::Red));
253+
254+
f.render_widget(unloaded, cols[0]);
255+
f.render_widget(loaded, cols[1]);
223256
}
224257

225258
fn render_stats(&self, f: &mut Frame, area: Rect) {
@@ -420,31 +453,44 @@ mod tests {
420453
}
421454

422455
#[test]
423-
fn loaded_latency_clears_unloaded_buffer_then_records_its_own_min() {
456+
fn loaded_and_unloaded_buffers_are_independent() {
424457
let mut app = App::new();
425458
app.apply(Progress::PhaseStart(Phase::UnloadedLatency));
426459
app.apply(Progress::Latency { ms: 10.0 });
427460
app.apply(Progress::PhaseEnd(Phase::UnloadedLatency));
428461
app.apply(Progress::PhaseStart(Phase::LoadedLatency));
429-
assert!(app.latency_samples.is_empty());
462+
// Unloaded samples are preserved across the loaded phase so the UI
463+
// can render both sparklines simultaneously.
464+
assert_eq!(app.unloaded_latency_samples, vec![10.0]);
465+
assert!(app.loaded_latency_samples.is_empty());
430466
app.apply(Progress::Latency { ms: 35.0 });
431467
app.apply(Progress::Latency { ms: 38.0 });
432468
app.apply(Progress::PhaseEnd(Phase::LoadedLatency));
433469
assert_eq!(app.unloaded_latency_ms, Some(10.0));
434470
assert_eq!(app.loaded_latency_ms, Some(35.0));
471+
assert_eq!(app.unloaded_latency_samples, vec![10.0]);
472+
assert_eq!(app.loaded_latency_samples, vec![35.0, 38.0]);
435473
}
436474

437475
#[test]
438-
fn latency_buffer_is_bounded() {
476+
fn latency_outside_latency_phase_is_ignored() {
439477
let mut app = App::new();
440478
app.apply(Progress::PhaseStart(Phase::Download));
479+
app.apply(Progress::Latency { ms: 99.0 });
480+
assert!(app.unloaded_latency_samples.is_empty());
481+
assert!(app.loaded_latency_samples.is_empty());
482+
}
483+
484+
#[test]
485+
fn latency_buffer_is_bounded() {
486+
let mut app = App::new();
487+
app.apply(Progress::PhaseStart(Phase::UnloadedLatency));
441488
for i in 0..(SPARKLINE_LEN + 10) {
442489
app.apply(Progress::Latency { ms: i as f64 });
443490
}
444-
assert_eq!(app.latency_samples.len(), SPARKLINE_LEN);
445-
// Most-recent value should be the last we pushed.
491+
assert_eq!(app.unloaded_latency_samples.len(), SPARKLINE_LEN);
446492
assert_eq!(
447-
*app.latency_samples.last().unwrap(),
493+
*app.unloaded_latency_samples.last().unwrap(),
448494
(SPARKLINE_LEN + 10 - 1) as f64
449495
);
450496
}

0 commit comments

Comments
 (0)