use std::collections::VecDeque;
use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use sysinfo::{MemoryRefreshKind, RefreshKind, System};
use crate::chart::{BrailleGraph, meter_spans, percent};
use crate::config::MemoryConfig;
use crate::frame::Binding;
use crate::keymap::{KeysConfig, Meta, PanelKeymap};
use crate::panel::{Panel, RenderContext};
pub struct MemoryPanel {
config: MemoryConfig,
keys: PanelKeymap<MemoryAction>,
system: System,
history: VecDeque<u64>,
reading: Reading,
last_sample: Option<Instant>,
graph_cells: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct Reading {
used: u64,
total: u64,
swap_used: u64,
swap_total: u64,
}
impl Reading {
fn used_pct(self) -> u64 {
u64::from(percent(self.used, self.total))
}
}
fn gib(bytes: u64) -> String {
format!("{:.1}", bytes as f64 / 1_073_741_824.0)
}
fn whole_gib(bytes: u64) -> u64 {
(bytes as f64 / 1_073_741_824.0).round() as u64
}
impl std::fmt::Debug for MemoryPanel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MemoryPanel")
.field("reading", &self.reading)
.field("samples", &self.history.len())
.finish_non_exhaustive()
}
}
impl MemoryPanel {
pub fn new(config: MemoryConfig) -> Self {
let mut system = System::new_with_specifics(
RefreshKind::nothing().with_memory(MemoryRefreshKind::everything()),
);
system.refresh_memory();
let reading = Self::read(&system);
Self {
history: VecDeque::with_capacity(config.history.max(1)),
keys: PanelKeymap::or_defaults("memory", ACTIONS, &config.keys),
config,
system,
reading,
last_sample: None,
graph_cells: 0,
}
}
#[cfg(test)]
pub(crate) fn with_reading(config: MemoryConfig, used: u64, total: u64) -> Self {
let mut panel = Self::new(config);
panel.reading = Reading {
used,
total,
swap_used: 0,
swap_total: 0,
};
panel.history.clear();
panel.history.push_back(panel.reading.used_pct());
panel
}
fn read(system: &System) -> Reading {
Reading {
used: system.used_memory(),
total: system.total_memory(),
swap_used: system.used_swap(),
swap_total: system.total_swap(),
}
}
fn sample(&mut self) -> bool {
let interval = Duration::from_secs(self.config.sample_secs.max(1));
if self.last_sample.is_some_and(|at| at.elapsed() < interval) {
return false;
}
self.system.refresh_memory();
self.reading = Self::read(&self.system);
self.last_sample = Some(Instant::now());
let capacity = crate::samples::capacity(self.config.history, self.graph_cells);
crate::samples::push_bounded(&mut self.history, self.reading.used_pct(), capacity);
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryAction {
Swap,
}
pub const ACTIONS: &[Meta<MemoryAction>] = &[Meta {
action: MemoryAction::Swap,
name: "swap",
defaults: &[(KeyCode::Char('s'), KeyModifiers::NONE)],
label: "swap",
primary: true,
joins: false,
about: "show or hide the swap row",
}];
pub fn keymap(keys: &KeysConfig) -> Result<PanelKeymap<MemoryAction>, String> {
PanelKeymap::new("memory", ACTIONS, keys)
}
impl Panel for MemoryPanel {
fn title(&self) -> String {
"Memory".to_string()
}
fn counter(&self) -> Option<String> {
(self.reading.total > 0).then(|| format!("{} GB", whole_gib(self.reading.total)))
}
fn bindings(&self) -> &[Binding] {
self.keys.bindings()
}
fn set_keys(&mut self, config: &crate::config::Config) {
self.keys = PanelKeymap::or_defaults("memory", ACTIONS, &config.memory.keys);
}
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 {
if self.keys.action(key) == Some(MemoryAction::Swap) {
self.config.show_swap = !self.config.show_swap;
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_swap = self.config.show_swap && self.reading.swap_total > 0 && area.height >= 5;
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(u16::from(show_swap) * 2), ])
.split(area);
let pct = self.reading.used_pct();
let colour = gradient.at(i64::try_from(pct).unwrap_or(100));
frame.render_widget(
Paragraph::new(crate::grid::assemble(
vec![
vec![
Span::styled(
format!("{pct:>3}"),
Style::default().fg(colour).add_modifier(Modifier::BOLD),
),
Span::styled("%", Style::default().fg(theme.muted)),
],
vec![Span::styled(
format!(" {}", crate::glyphs::utility("used")),
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
)],
vec![Span::styled(
format!(
" {} / {} GB",
gib(self.reading.used),
gib(self.reading.total)
),
Style::default().fg(theme.muted),
)],
],
rows[0].width,
)),
rows[0],
);
self.graph_cells = rows[1].width as usize;
if rows[1].height > 0 {
BrailleGraph::of_history(&self.history, 100, gradient)
.track_style(track)
.render(rows[1], frame.buffer_mut());
}
if show_swap && rows[2].height >= 2 {
let swap_pct = percent(self.reading.swap_used, self.reading.swap_total);
frame.render_widget(
Paragraph::new(crate::grid::assemble(
vec![
vec![Span::styled(
crate::glyphs::utility("swap"),
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
)],
vec![Span::styled(
format!(
" {} / {} GB",
gib(self.reading.swap_used),
gib(self.reading.swap_total)
),
Style::default().fg(theme.muted),
)],
],
rows[2].width,
)),
Rect::new(rows[2].x, rows[2].y, rows[2].width, 1),
);
frame.render_widget(
Paragraph::new(Line::from(meter_spans(
u64::from(swap_pct),
100,
rows[2].width,
gradient,
track,
))),
Rect::new(rows[2].x, rows[2].y + 1, rows[2].width, 1),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::testing::screen;
#[test]
fn gibibytes_are_stated_to_one_decimal() {
assert_eq!(gib(0), "0.0");
assert_eq!(gib(1_073_741_824), "1.0");
assert_eq!(gib(17_179_869_184), "16.0");
assert_eq!(gib(6_657_199_308), "6.2");
}
#[test]
fn the_readout_drops_whole_values_and_never_a_fragment() {
let mut panel =
MemoryPanel::with_reading(MemoryConfig::default(), 6_657_199_308, 17_179_869_184);
for width in 1..=40u16 {
let readout = screen(&mut panel, width, 6).swap_remove(0);
let readout = readout.trim();
assert!(
readout.is_empty()
|| readout.ends_with('\u{2026}')
|| readout == "38%"
|| readout == "38% USED"
|| readout == "38% USED 6.2 / 16.0 GB",
"at width {width} the readout was {readout:?}"
);
}
}
#[test]
fn the_border_carries_the_total_and_nothing_when_there_is_none() {
let panel = MemoryPanel::with_reading(MemoryConfig::default(), 1, 17_179_869_184);
assert_eq!(panel.counter().as_deref(), Some("16 GB"));
let odd = MemoryPanel::with_reading(MemoryConfig::default(), 1, 8_267_366_400);
assert_eq!(
odd.counter().as_deref(),
Some("8 GB"),
"7.7 GB rounds to the nearest whole"
);
let none = MemoryPanel::with_reading(MemoryConfig::default(), 0, 0);
assert_eq!(none.counter(), None);
}
#[test]
fn s_toggles_the_swap_row_and_is_documented() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut panel = MemoryPanel::with_reading(MemoryConfig::default(), 1, 2);
let before = panel.config.show_swap;
let outcome = panel.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
assert_eq!(outcome, crate::panel::KeyOutcome::Consumed);
assert_ne!(panel.config.show_swap, before);
assert!(
panel.bindings().iter().any(|b| b.key == "s"),
"the key the panel takes is the key it advertises"
);
}
#[test]
fn the_history_is_bounded_by_what_the_panel_can_draw() {
let mut panel = MemoryPanel::with_reading(
MemoryConfig {
history: 4,
..MemoryConfig::default()
},
1,
2,
);
panel.graph_cells = 0;
panel.history.extend(1000..1003);
for _ in 0..3 {
panel.last_sample = None;
assert!(panel.sample(), "a sample is due");
}
assert_eq!(panel.history.front(), Some(&1002), "the oldest go first");
for _ in 0..50 {
panel.last_sample = None;
panel.sample();
}
assert!(panel.history.len() <= 8, "bounded: {}", panel.history.len());
assert_eq!(panel.history.len(), 4, "the configured four, at no width");
}
}