1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
//! CPU utilisation, as a live readout plus a scrolling history chart.
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use sysinfo::{CpuRefreshKind, RefreshKind, System};
use crate::chart::{BrailleGraph, meter_spans};
use crate::config::CpuConfig;
use crate::frame::Binding;
use crate::panel::{Panel, RenderContext};
/// The CPU panel.
pub struct CpuPanel {
config: CpuConfig,
system: System,
/// Recent average-utilisation samples, oldest first.
history: VecDeque<u64>,
/// Per-core utilisation from the most recent sample.
per_core: Vec<f32>,
current: f32,
/// `None` until the first sample, so it fires immediately. See the note on
/// `Slot::last_tick` for why this is not "now minus an hour".
last_sample: Option<Instant>,
core_count: usize,
/// Cells the graph was last drawn into, so the history can grow to fill it.
graph_cells: usize,
}
// `sysinfo::System` is deliberately opaque, so derive(Debug) is unavailable.
impl std::fmt::Debug for CpuPanel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CpuPanel")
.field("current", &self.current)
.field("samples", &self.history.len())
.field("cores", &self.core_count)
.finish_non_exhaustive()
}
}
impl CpuPanel {
/// Build the panel and take a first sample.
pub fn new(config: CpuConfig) -> Self {
let mut system = System::new_with_specifics(
RefreshKind::nothing().with_cpu(CpuRefreshKind::nothing().with_cpu_usage()),
);
system.refresh_cpu_usage();
let core_count = system.cpus().len();
Self {
history: VecDeque::with_capacity(config.history.max(1)),
config,
system,
per_core: Vec::new(),
current: 0.0,
graph_cells: 0,
// Back-date so the first tick samples immediately.
last_sample: None,
core_count,
}
}
/// Take a sample if enough time has passed since the last one.
///
/// Returns whether it actually sampled. The tick runs at 500ms and the
/// default sample interval is a second, so half of all ticks do nothing —
/// and a tick that does nothing must not cost a repaint of the whole
/// dashboard.
fn sample(&mut self) -> bool {
let interval = Duration::from_secs(self.config.sample_secs.max(1))
// sysinfo needs a minimum gap between refreshes for its deltas to
// be meaningful; sampling faster than this yields zeros.
.max(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
if self.last_sample.is_some_and(|at| at.elapsed() < interval) {
return false;
}
self.system.refresh_cpu_usage();
self.current = self.system.global_cpu_usage();
self.per_core = self
.system
.cpus()
.iter()
.map(sysinfo::Cpu::cpu_usage)
.collect();
self.core_count = self.per_core.len();
self.last_sample = Some(Instant::now());
let capacity = self.capacity();
crate::samples::push_bounded(
&mut self.history,
self.current.clamp(0.0, 100.0).round() as u64,
capacity,
);
true
}
/// How many samples to retain; see [`crate::samples::capacity`].
fn capacity(&self) -> usize {
crate::samples::capacity(self.config.history, self.graph_cells)
}
}
/// Keys this panel responds to.
const BINDINGS: &[Binding] = &[Binding::primary("c", "per-core")];
impl Panel for CpuPanel {
fn title(&self) -> String {
"CPU".to_string()
}
fn counter(&self) -> Option<String> {
(self.core_count > 0).then(|| format!("{} cores", self.core_count))
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn refresh_interval(&self) -> Duration {
Duration::from_millis(500)
}
fn tick(&mut self) -> bool {
self.sample()
}
fn handle_key(&mut self, key: ratatui::crossterm::event::KeyEvent) -> crate::panel::KeyOutcome {
use ratatui::crossterm::event::KeyCode;
if matches!(key.code, KeyCode::Char('c')) {
self.config.show_per_core = !self.config.show_per_core;
return crate::panel::KeyOutcome::Consumed;
}
crate::panel::KeyOutcome::Ignored
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
let theme = ctx.theme;
if area.height == 0 || area.width == 0 {
return;
}
let gradient = &ctx.gradients.cpu;
let track = Style::default().fg(theme.track);
let show_cores = self.config.show_per_core && area.height >= 5 && !self.per_core.is_empty();
let rows = Layout::vertical([
Constraint::Length(1), // readout
Constraint::Min(1), // graph
Constraint::Length(u16::from(show_cores) * 2), // per-core meters
])
.split(area);
// The number takes its colour from the same ramp as the graph, so the
// whole panel changes temperature together.
let colour = gradient.at(self.current.round() as i64);
// Three parts, and the split is where the meaning is. The figure and
// its `%` are inseparable — a percentage with the sign cut off is a
// different quantity, and the terminal cuts wherever the edge falls.
// The word LOAD and the age of the history are both droppable, in that
// order: a bare `7.3%` in a panel titled CPU says everything the label
// does, where `7.3% LO…` says it worse.
frame.render_widget(
Paragraph::new(crate::grid::assemble(
vec![
vec![
Span::styled(
// Padded to a fixed field so the figure holds still
// as it changes, and unpadded when the padding is
// the difference between a reading and an
// ellipsis. Same trade as the network readout.
if rows[0].width >= 6 {
format!("{:>5.1}", self.current)
} else {
format!("{:.1}", self.current)
},
Style::default().fg(colour).add_modifier(Modifier::BOLD),
),
Span::styled("%", Style::default().fg(theme.muted)),
],
vec![Span::styled(
format!(" {}", crate::glyphs::utility("load")),
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
)],
vec![Span::styled(
format!(
" {}s",
self.history.len() as u64 * self.config.sample_secs.max(1)
),
Style::default().fg(theme.muted),
)],
],
rows[0].width,
)),
rows[0],
);
// Recorded so the next tick can size the buffer to the panel; see
// `capacity`.
self.graph_cells = rows[1].width as usize;
if rows[1].height > 0 {
let data: Vec<u64> = self.history.iter().copied().collect();
BrailleGraph::new(&data, 100, gradient)
.track_style(track)
.render(rows[1], frame.buffer_mut());
}
if show_cores && rows[2].height >= 2 {
frame.render_widget(
Paragraph::new(Span::styled(
crate::glyphs::utility("per core"),
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
)),
Rect::new(rows[2].x, rows[2].y, rows[2].width, 1),
);
// One meter per core, sharing the row. Below three columns each a
// meter is unreadable, so fall back to a single-cell level mark.
let cores = u16::try_from(self.per_core.len()).unwrap_or(1).max(1);
let per = rows[2].width / cores;
let y = rows[2].y + 1;
let mut x = rows[2].x;
for pct in &self.per_core {
if x >= rows[2].x + rows[2].width {
break;
}
let value = pct.clamp(0.0, 100.0).round() as u64;
if per >= 3 {
let cells = meter_spans(value, 100, per.saturating_sub(1), gradient, track);
for (index, (glyph, style)) in cells.iter().enumerate() {
let cx = x + u16::try_from(index).unwrap_or(0);
if cx >= rows[2].x + rows[2].width {
break;
}
frame.buffer_mut()[(cx, y)]
.set_char(*glyph)
.set_style(*style);
}
x += per;
} else {
frame.buffer_mut()[(x, y)]
.set_char(level_mark(*pct))
.set_style(
Style::default().fg(gradient.at(i64::try_from(value).unwrap_or(100))),
);
x += 1;
}
}
}
}
}
/// A single-cell vertical level mark, used when a core has no room for a
/// full meter. NaN clamps to the floor rather than panicking on the cast.
fn level_mark(pct: f32) -> char {
const BLOCKS: [char; 9] = ['▁', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let normalised = if pct.is_nan() {
0.0
} else {
pct.clamp(0.0, 100.0)
};
let idx = ((normalised / 100.0) * (BLOCKS.len() - 1) as f32).round() as usize;
BLOCKS[idx.min(BLOCKS.len() - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn meter_covers_the_full_range_without_panicking() {
assert_eq!(level_mark(0.0), '▁');
assert_eq!(level_mark(100.0), '█');
// Out-of-range input must clamp rather than index out of bounds.
assert_eq!(level_mark(-50.0), '▁');
assert_eq!(level_mark(1000.0), '█');
assert_eq!(level_mark(f32::NAN), '▁');
for i in 0..=100 {
let _ = level_mark(i as f32);
}
}
#[test]
fn meter_is_monotonic() {
let mut previous = level_mark(0.0);
for i in 0..=100 {
let current = level_mark(i as f32);
assert!(
block_rank(current) >= block_rank(previous),
"meter went backwards at {i}%"
);
previous = current;
}
}
fn block_rank(block: char) -> usize {
['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
.iter()
.position(|b| *b == block)
.unwrap_or(0)
}
#[test]
fn history_is_bounded_by_the_configured_capacity() {
let config = CpuConfig {
history: 5,
..Default::default()
};
let mut panel = CpuPanel::new(config);
for i in 0..50 {
if panel.history.len() >= 5 {
panel.history.pop_front();
}
panel.history.push_back(i);
}
assert_eq!(panel.history.len(), 5);
assert_eq!(panel.history.front(), Some(&45));
}
#[test]
#[ignore = "superseded: the graph now takes the whole history"]
fn recent_never_over_reads_the_buffer() {
let mut panel = CpuPanel::new(CpuConfig::default());
panel.history.clear();
}
}