use ratatui::{
layout::{Alignment, Rect},
style::{Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::ui::palette as p;
struct Row {
keys: &'static str,
label: &'static str,
}
struct Group {
title: &'static str,
rows: &'static [Row],
}
const GROUPS: &[Group] = &[
Group {
title: "Global",
rows: &[
Row {
keys: "1-9 0 - +",
label: "Switch tab",
},
Row {
keys: "Tab / Shift-Tab",
label: "Next / previous tab",
},
Row {
keys: "p",
label: "Pause / resume sampling",
},
Row {
keys: "g",
label: "Cycle graph style (bars / dots)",
},
Row {
keys: "t",
label: "Cycle theme",
},
Row {
keys: ",",
label: "Open settings popup",
},
Row {
keys: "S",
label: "Snapshot current sample → JSON file",
},
Row {
keys: "R",
label: "Toggle session recording → .swr file",
},
Row {
keys: "?",
label: "Toggle this help",
},
Row {
keys: "q / Ctrl-C",
label: "Quit",
},
],
},
Group {
title: "Timeline / scrub",
rows: &[
Row {
keys: "← / →",
label: "Step one tick back / forward",
},
Row {
keys: "Home",
label: "Jump to oldest tick",
},
Row {
keys: "End",
label: "Return to live",
},
],
},
Group {
title: "Procs tab",
rows: &[
Row {
keys: "↑ / ↓",
label: "Move selection",
},
Row {
keys: "s",
label: "Cycle sort (cpu / rss / io / start / name / gpu / net)",
},
Row {
keys: "/",
label: "Filter procs (Esc cancel, Enter apply)",
},
],
},
Group {
title: "Services tab",
rows: &[
Row {
keys: "↑ / ↓",
label: "Move selection",
},
Row {
keys: "s",
label: "Cycle sort (name / status / pid)",
},
],
},
Group {
title: "Settings popup",
rows: &[
Row {
keys: "↑ / ↓",
label: "Move cursor",
},
Row {
keys: "← / →",
label: "Cycle enum value",
},
Row {
keys: "Enter",
label: "Edit numeric value",
},
Row {
keys: "S",
label: "Save to disk",
},
Row {
keys: "Esc",
label: "Close",
},
],
},
];
pub fn render(f: &mut Frame, area: Rect) {
let mut wanted_h: u16 = 4;
for g in GROUPS {
wanted_h = wanted_h.saturating_add(1 + g.rows.len() as u16 + 1);
}
let popup_w = (area.width * 70 / 100)
.max(60)
.min(area.width.saturating_sub(4));
let popup_h = wanted_h.min(area.height.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup = Rect::new(x, y, popup_w, popup_h);
f.render_widget(Clear, popup);
let block = Block::default()
.title(" Help ")
.borders(Borders::ALL)
.border_style(Style::default().fg(p::brand()))
.style(Style::default().bg(p::bg()));
let inner = block.inner(popup);
f.render_widget(block, popup);
let mut lines: Vec<Line> = Vec::new();
for group in GROUPS {
lines.push(Line::from(Span::styled(
format!(" {} ", group.title),
Style::default().fg(p::active_tab()).bold(),
)));
for row in group.rows {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{:<18}", row.keys),
Style::default().fg(p::key_hint()).bold(),
),
Span::styled(
row.label.to_string(),
Style::default().fg(p::text_primary()),
),
]));
}
lines.push(Line::raw(""));
}
let footer_h: u16 = 1;
let body_h = inner.height.saturating_sub(footer_h);
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(p::bg())),
Rect::new(inner.x, inner.y, inner.width, body_h),
);
let footer_area = Rect::new(inner.x, inner.y + body_h, inner.width, footer_h);
let footer = Line::from(vec![
Span::styled("Esc / ?", Style::default().fg(p::key_hint()).bold()),
Span::raw(":Close"),
]);
f.render_widget(
Paragraph::new(footer)
.alignment(Alignment::Center)
.style(Style::default().bg(p::bg())),
footer_area,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_group_has_at_least_one_row() {
for g in GROUPS {
assert!(!g.rows.is_empty(), "group {:?} has no rows", g.title);
}
}
#[test]
fn at_least_the_global_essentials_are_documented() {
let global = GROUPS
.iter()
.find(|g| g.title == "Global")
.expect("Global group present");
let keys: String = global
.rows
.iter()
.map(|r| r.keys)
.collect::<Vec<_>>()
.join("|");
for needle in ["q", "p", "g", "t", "?", ","] {
assert!(
keys.contains(needle),
"Global help missing essential key {:?}",
needle
);
}
}
}