Skip to content

Commit 0766b0f

Browse files
committed
- added scrolling to history tab
- updated screenshots
1 parent f23c233 commit 0766b0f

File tree

4 files changed

+138
-36
lines changed

4 files changed

+138
-36
lines changed

README.md

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
A CLI tool that displays network speed test results from Cloudflare's speed test service in a TUI interface.
77

8-
![screenshot](./images/screenshot.png)
8+
![screenshot](./images/screenshot-dashboard.png)
9+
![screenshot](./images/screenshot-history.png)
910

1011
## Features
1112

@@ -61,25 +62,6 @@ To see all options:
6162
cloudflare-speed-cli --help
6263
```
6364

64-
### Interface Binding
65-
66-
Bind to a specific network interface or source IP address for testing Multi-WAN, Site-to-Site, Proxy, and WireGuard configurations:
67-
68-
```bash
69-
# Bind to a specific interface
70-
cloudflare-speed-cli --interface=ens18
71-
72-
# Bind to a specific source IP
73-
cloudflare-speed-cli --source=192.168.10.0
74-
```
75-
76-
This is useful for:
77-
78-
- **Multi-WAN**: Test throughput on specific WAN interfaces
79-
- **Site-to-Site VPNs**: Test performance through specific VPN tunnels
80-
- **Proxy configurations**: Test through specific proxy interfaces
81-
- **WireGuard**: Test performance on specific WireGuard interfaces
82-
8365
## Source
8466

8567
Uses endpoints from https://speed.cloudflare.com/

images/screenshot-history.png

370 KB
Loading

src/tui/mod.rs

Lines changed: 136 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,19 @@ struct UiState {
6464
last_result: Option<RunResult>,
6565
history: Vec<RunResult>,
6666
history_selected: usize, // Index of selected history item (0 = most recent)
67+
history_scroll_offset: usize,
68+
history_loaded_count: usize,
69+
initial_history_load_size: usize, // Initial load size based on terminal height
6770
ip: Option<String>,
6871
colo: Option<String>,
6972
server: Option<String>,
7073
asn: Option<String>,
7174
as_org: Option<String>,
7275
auto_save: bool,
73-
last_exported_path: Option<String>, // Full path of last exported file (for clipboard)
76+
last_exported_path: Option<String>,
7477
// Network interface information
7578
interface_name: Option<String>,
76-
network_name: Option<String>, // SSID for wireless, or network name
79+
network_name: Option<String>,
7780
is_wireless: Option<bool>,
7881
interface_mac: Option<String>,
7982
link_speed_mbps: Option<u64>,
@@ -117,6 +120,9 @@ impl Default for UiState {
117120
last_result: None,
118121
history: Vec::new(),
119122
history_selected: 0,
123+
history_scroll_offset: 0,
124+
history_loaded_count: 0,
125+
initial_history_load_size: 66, // Default initial load size
120126
ip: None,
121127
colo: None,
122128
server: None,
@@ -328,12 +334,21 @@ pub async fn run(args: Cli) -> Result<()> {
328334
let mut terminal = Terminal::new(backend).context("create terminal")?;
329335
terminal.clear().ok();
330336

337+
// Get terminal size to determine initial history load
338+
// Load 3x the visible height initially (for smooth scrolling)
339+
// Default to 24 rows if we can't get terminal size
340+
let initial_load = terminal.size()
341+
.map(|size| ((size.height as usize).saturating_sub(2) * 3).max(20))
342+
.unwrap_or(66); // Default: (24-2)*3 = 66 items
343+
331344
let mut state = UiState {
332345
phase: Phase::IdleLatency,
333346
auto_save: args.auto_save,
334347
..Default::default()
335348
};
336-
state.history = crate::storage::load_recent(20).unwrap_or_default();
349+
state.initial_history_load_size = initial_load;
350+
state.history = crate::storage::load_recent(initial_load).unwrap_or_default();
351+
state.history_loaded_count = state.history.len();
337352

338353
// Gather network interface information
339354
// If interface is specified via CLI, use that; otherwise auto-detect
@@ -503,6 +518,7 @@ pub async fn run(args: Cli) -> Result<()> {
503518
// Reset history selection when switching to history tab
504519
if new_tab == 1 {
505520
state.history_selected = 0;
521+
state.history_scroll_offset = 0;
506522
}
507523
}
508524
(_, KeyCode::Char('?')) => {
@@ -514,6 +530,10 @@ pub async fn run(args: Cli) -> Result<()> {
514530
// Up/k goes to newer items (lower index, towards 0)
515531
if state.history_selected > 0 {
516532
state.history_selected -= 1;
533+
// Auto-scroll: if selected item is above visible area, scroll up
534+
if state.history_selected < state.history_scroll_offset {
535+
state.history_scroll_offset = state.history_selected;
536+
}
517537
}
518538
}
519539
}
@@ -523,6 +543,35 @@ pub async fn run(args: Cli) -> Result<()> {
523543
// Allow navigation through all items; display will show what fits
524544
if state.history_selected < state.history.len().saturating_sub(1) {
525545
state.history_selected += 1;
546+
// Auto-scroll: keep selected item visible
547+
// Use a reasonable estimate for max_items (will be recalculated in draw)
548+
let estimated_max_items = 30; // reasonable default
549+
if state.history_selected >= state.history_scroll_offset + estimated_max_items {
550+
state.history_scroll_offset = state.history_selected.saturating_sub(estimated_max_items - 1);
551+
}
552+
553+
// Lazy load: if we're near the end of loaded items, load more
554+
let load_threshold = state.history_loaded_count.saturating_sub(10);
555+
if state.history_selected >= load_threshold && state.history_loaded_count == state.history.len() {
556+
// Load more items (another batch of the same size)
557+
let current_count = state.history.len();
558+
let load_more = current_count.max(20); // Load at least as many as we have, or 20
559+
if let Ok(more_history) = crate::storage::load_recent(load_more) {
560+
// Only add items we don't already have
561+
let existing_ids: std::collections::HashSet<_> = state.history
562+
.iter()
563+
.map(|r| &r.meas_id)
564+
.collect();
565+
let new_items: Vec<_> = more_history
566+
.into_iter()
567+
.filter(|r| !existing_ids.contains(&r.meas_id))
568+
.collect();
569+
if !new_items.is_empty() {
570+
state.history.extend(new_items);
571+
state.history_loaded_count = state.history.len();
572+
}
573+
}
574+
}
526575
}
527576
}
528577
}
@@ -535,11 +584,16 @@ pub async fn run(args: Cli) -> Result<()> {
535584
state.info = format!("Delete failed: {e:#}");
536585
} else {
537586
state.history.remove(state.history_selected);
587+
// Adjust scroll offset if needed
588+
if state.history_scroll_offset >= state.history.len() && !state.history.is_empty() {
589+
state.history_scroll_offset = state.history.len().saturating_sub(20).max(0);
590+
}
538591
// Adjust selection if needed
539592
if state.history_selected >= state.history.len() && !state.history.is_empty() {
540593
state.history_selected = state.history.len() - 1;
541594
} else if state.history.is_empty() {
542595
state.history_selected = 0;
596+
state.history_scroll_offset = 0;
543597
}
544598
state.info = "Deleted".into();
545599
}
@@ -592,7 +646,16 @@ pub async fn run(args: Cli) -> Result<()> {
592646
// Enrich result with network info before storing
593647
let enriched = enrich_result_with_network_info(&r, &state);
594648
state.last_result = Some(enriched);
595-
state.history = crate::storage::load_recent(20).unwrap_or_default();
649+
// Reload history to include the new test
650+
// Load at least one more than we had before to ensure the new test is included
651+
let reload_size = (state.history_loaded_count + 1).max(state.initial_history_load_size);
652+
state.history = crate::storage::load_recent(reload_size).unwrap_or_default();
653+
state.history_loaded_count = state.history.len();
654+
// Reset selection to show the new test (most recent) if on history tab
655+
if state.tab == 1 {
656+
state.history_selected = 0;
657+
state.history_scroll_offset = 0;
658+
}
596659
state.info = "Done. (r rerun, q quit)".into();
597660
}
598661
Ok(Err(e)) => state.info = format!("Run failed: {e:#}"),
@@ -1558,28 +1621,61 @@ fn copy_to_clipboard(text: &str) -> Result<()> {
15581621

15591622
fn draw_history(area: Rect, f: &mut ratatui::Frame, state: &UiState) {
15601623
let mut lines: Vec<Line> = Vec::new();
1624+
1625+
// Calculate how many items can fit in the available area
1626+
// Subtract 2 for header lines
1627+
let max_items = (area.height as usize).saturating_sub(2);
1628+
1629+
// Show total count and current position
1630+
let total_count = state.history.len();
1631+
let current_pos = if total_count > 0 {
1632+
state.history_selected + 1
1633+
} else {
1634+
0
1635+
};
1636+
15611637
lines.push(Line::from(vec![
1562-
Span::raw("Most recent runs ("),
1638+
Span::raw(format!("History ({}/{}", current_pos, total_count)),
1639+
if total_count > max_items {
1640+
Span::raw(format!(", showing {} items", max_items))
1641+
} else {
1642+
Span::raw("")
1643+
},
1644+
Span::raw(") - "),
15631645
Span::styled("↑/↓/j/k", Style::default().fg(Color::Magenta)),
15641646
Span::raw(": navigate, "),
15651647
Span::styled("d", Style::default().fg(Color::Magenta)),
15661648
Span::raw(": delete, "),
15671649
Span::styled("e", Style::default().fg(Color::Magenta)),
15681650
Span::raw(": export JSON, "),
15691651
Span::styled("c", Style::default().fg(Color::Magenta)),
1570-
Span::raw(": export CSV):"),
1652+
Span::raw(": export CSV"),
15711653
]));
15721654
lines.push(Line::from(""));
15731655

1574-
// Calculate how many items can fit in the available area
1575-
// Subtract 2 for header lines, and 1 more for the "No history" message if needed
1576-
let max_items = (area.height as usize).saturating_sub(2);
1577-
1578-
// History is already ordered newest first, so we display it directly
1579-
let history_display: Vec<_> = state.history.iter().take(max_items).collect();
1580-
for (i, r) in history_display.iter().enumerate() {
1581-
// i directly maps to history index since we're not reversing
1582-
let is_selected = state.tab == 1 && i == state.history_selected;
1656+
// Apply scroll offset and take only visible items
1657+
// Auto-adjust scroll to keep selected item visible (this should have been done in event handler, but handle edge cases here)
1658+
let scroll_offset = {
1659+
let mut offset = state.history_scroll_offset.min(state.history.len().saturating_sub(1));
1660+
// Ensure selected item is visible
1661+
if state.history_selected < offset {
1662+
offset = state.history_selected;
1663+
} else if state.history_selected >= offset + max_items {
1664+
offset = state.history_selected.saturating_sub(max_items - 1);
1665+
}
1666+
offset
1667+
};
1668+
1669+
let history_display: Vec<_> = state.history
1670+
.iter()
1671+
.skip(scroll_offset)
1672+
.take(max_items)
1673+
.collect();
1674+
1675+
for (display_idx, r) in history_display.iter().enumerate() {
1676+
// Calculate actual history index (accounting for scroll offset)
1677+
let history_idx = scroll_offset + display_idx;
1678+
let is_selected = state.tab == 1 && history_idx == state.history_selected;
15831679

15841680
// Parse and format timestamp to human-readable format in local timezone
15851681
let timestamp_str: String = {
@@ -1675,7 +1771,7 @@ fn draw_history(area: Rect, f: &mut ratatui::Frame, state: &UiState) {
16751771
};
16761772

16771773
// Line number (1-indexed, newest = 1)
1678-
let line_num = i + 1;
1774+
let line_num = history_idx + 1;
16791775

16801776
lines.push(Line::from(vec![
16811777
Span::styled(
@@ -1721,6 +1817,30 @@ fn draw_history(area: Rect, f: &mut ratatui::Frame, state: &UiState) {
17211817
),
17221818
if is_selected { style } else { Style::default() },
17231819
),
1820+
Span::raw(" "),
1821+
Span::styled(
1822+
format!(
1823+
"{}",
1824+
r.interface_name.as_deref().unwrap_or("-")
1825+
),
1826+
if is_selected {
1827+
style
1828+
} else {
1829+
Style::default().fg(Color::Blue)
1830+
},
1831+
),
1832+
Span::raw(" "),
1833+
Span::styled(
1834+
format!(
1835+
"{}",
1836+
r.network_name.as_deref().or_else(|| r.interface_name.as_deref()).unwrap_or("-")
1837+
),
1838+
if is_selected {
1839+
style
1840+
} else {
1841+
Style::default().fg(Color::Magenta)
1842+
},
1843+
),
17241844
]));
17251845
}
17261846

0 commit comments

Comments
 (0)