Skip to main content

max / mountaineer-sysop

Add sysop-tui primitives: palette, layout, footer, selection, keys Six small modules implementing the AESTHETICS.md ratatui conventions: flatwhite RGB constants, body/footer split, accent-keyed footer hints, selection marker + warm-bg style, reserved global key classification, and hue-as-state span helpers (ok/warn/fault/inactive). Exercised by crates/sysop-tui/examples/smoketest.rs — a fake `sysop services` screen runnable on macOS via `cargo run --example smoketest -p sysop-tui`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-18 01:12 UTC
Commit: 37dc921330fd9d3765abe5c478928bfe150bb63b
Parent: 3815bcb
9 files changed, +247 insertions, -11 deletions
@@ -0,0 +1,106 @@
1 + use crossterm::event::{self, Event, KeyCode};
2 + use ratatui::style::Style;
3 + use ratatui::text::{Line, Span};
4 + use ratatui::widgets::Paragraph;
5 + use std::io;
6 + use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
7 +
8 + enum State {
9 + Ok,
10 + Warn,
11 + Fault,
12 + Inactive,
13 + }
14 +
15 + struct Service {
16 + name: &'static str,
17 + state: State,
18 + }
19 +
20 + fn state_span(s: &State) -> Span<'static> {
21 + match s {
22 + State::Ok => style::ok("ok"),
23 + State::Warn => style::warn("syncing"),
24 + State::Fault => style::fault("down"),
25 + State::Inactive => style::inactive("stopped"),
26 + }
27 + }
28 +
29 + fn services() -> Vec<Service> {
30 + vec![
31 + Service { name: "sshd", state: State::Ok },
32 + Service { name: "unbound", state: State::Ok },
33 + Service { name: "chronyd", state: State::Ok },
34 + Service { name: "caddy", state: State::Warn },
35 + Service { name: "nebula", state: State::Fault },
36 + Service { name: "tlp", state: State::Inactive },
37 + ]
38 + }
39 +
40 + fn body(items: &[Service], selected: usize) -> Paragraph<'_> {
41 + let lines: Vec<Line> = items
42 + .iter()
43 + .enumerate()
44 + .map(|(i, s)| {
45 + let is_sel = i == selected;
46 + let marker = if is_sel { selection::MARKER } else { " " };
47 + let line = Line::from(vec![
48 + Span::raw(format!(" {} ", marker)),
49 + Span::raw(format!("{:<12}", s.name)),
50 + Span::raw(" "),
51 + state_span(&s.state),
52 + ]);
53 + if is_sel {
54 + line.style(selection::selected_style())
55 + } else {
56 + line
57 + }
58 + })
59 + .collect();
60 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
61 + }
62 +
63 + fn main() -> io::Result<()> {
64 + let mut terminal = ratatui::init();
65 + let services = services();
66 + let mut idx: usize = 0;
67 +
68 + let footer = Footer::new([
69 + hint("j/k", "move"),
70 + hint("Enter", "details"),
71 + hint("?", "help"),
72 + hint("q", "quit"),
73 + ]);
74 +
75 + let result: io::Result<()> = loop {
76 + let draw = terminal.draw(|frame| {
77 + let [body_area, foot_area] = layout::split(frame.area());
78 + frame.render_widget(body(&services, idx), body_area);
79 + frame.render_widget(&footer, foot_area);
80 + });
81 + if let Err(e) = draw {
82 + break Err(e);
83 + }
84 +
85 + match event::read() {
86 + Ok(Event::Key(key)) => match classify(key) {
87 + GlobalAction::Quit => break Ok(()),
88 + GlobalAction::Passthrough => match key.code {
89 + KeyCode::Char('j') | KeyCode::Down if idx + 1 < services.len() => {
90 + idx += 1;
91 + }
92 + KeyCode::Char('k') | KeyCode::Up => {
93 + idx = idx.saturating_sub(1);
94 + }
95 + _ => {}
96 + },
97 + _ => {}
98 + },
99 + Ok(_) => {}
100 + Err(e) => break Err(e),
101 + }
102 + };
103 +
104 + ratatui::restore();
105 + result
106 + }
@@ -0,0 +1,44 @@
1 + use crate::palette;
2 + use ratatui::buffer::Buffer;
3 + use ratatui::layout::Rect;
4 + use ratatui::style::Style;
5 + use ratatui::text::{Line, Span};
6 + use ratatui::widgets::{Paragraph, Widget};
7 +
8 + pub struct Hint {
9 + pub key: &'static str,
10 + pub label: &'static str,
11 + }
12 +
13 + pub fn hint(key: &'static str, label: &'static str) -> Hint {
14 + Hint { key, label }
15 + }
16 +
17 + pub struct Footer {
18 + hints: Vec<Hint>,
19 + }
20 +
21 + impl Footer {
22 + pub fn new(hints: impl IntoIterator<Item = Hint>) -> Self {
23 + Self {
24 + hints: hints.into_iter().collect(),
25 + }
26 + }
27 + }
28 +
29 + impl Widget for &Footer {
30 + fn render(self, area: Rect, buf: &mut Buffer) {
31 + let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 4);
32 + for (i, h) in self.hints.iter().enumerate() {
33 + if i > 0 {
34 + spans.push(Span::raw(" "));
35 + }
36 + spans.push(Span::styled(h.key, Style::default().fg(palette::ACCENT)));
37 + spans.push(Span::raw(" "));
38 + spans.push(Span::styled(h.label, Style::default().fg(palette::DIM)));
39 + }
40 + Paragraph::new(Line::from(spans))
41 + .style(Style::default().bg(palette::BG))
42 + .render(area, buf);
43 + }
44 + }
@@ -0,0 +1,22 @@
1 + use crossterm::event::{KeyCode, KeyEvent};
2 +
3 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4 + pub enum GlobalAction {
5 + Help,
6 + Quit,
7 + Back,
8 + Filter,
9 + Command,
10 + Passthrough,
11 + }
12 +
13 + pub fn classify(key: KeyEvent) -> GlobalAction {
14 + match key.code {
15 + KeyCode::Char('?') => GlobalAction::Help,
16 + KeyCode::Char('q') => GlobalAction::Quit,
17 + KeyCode::Esc => GlobalAction::Back,
18 + KeyCode::Char('/') => GlobalAction::Filter,
19 + KeyCode::Char(':') => GlobalAction::Command,
20 + _ => GlobalAction::Passthrough,
21 + }
22 + }
@@ -0,0 +1,5 @@
1 + use ratatui::layout::{Constraint, Layout, Rect};
2 +
3 + pub fn split(area: Rect) -> [Rect; 2] {
4 + Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area)
5 + }
@@ -0,0 +1,10 @@
1 + pub mod footer;
2 + pub mod keys;
3 + pub mod layout;
4 + pub mod palette;
5 + pub mod selection;
6 + pub mod style;
7 +
8 + pub use footer::{Footer, Hint, hint};
9 + pub use keys::{GlobalAction, classify};
10 + pub use selection::{MARKER, selected_style};
@@ -0,0 +1,10 @@
1 + use ratatui::style::Color;
2 +
3 + pub const BG: Color = Color::Rgb(0xf7, 0xf3, 0xee);
4 + pub const FG: Color = Color::Rgb(0x60, 0x5a, 0x52);
5 + pub const DIM: Color = Color::Rgb(0x8c, 0x86, 0x7e);
6 + pub const SELECTION_BG: Color = Color::Rgb(0xdd, 0xd2, 0xbc);
7 + pub const ACCENT: Color = Color::Rgb(0x65, 0x87, 0xbf);
8 + pub const STATE_OK: Color = Color::Rgb(0x5d, 0x7a, 0x55);
9 + pub const STATE_WARN: Color = Color::Rgb(0xa7, 0x8a, 0x5e);
10 + pub const STATE_FAULT: Color = Color::Rgb(0xc4, 0x6a, 0x6a);
@@ -0,0 +1,11 @@
1 + use crate::palette;
2 + use ratatui::style::{Modifier, Style};
3 +
4 + pub const MARKER: &str = "▶";
5 +
6 + pub fn selected_style() -> Style {
7 + Style::default()
8 + .fg(palette::FG)
9 + .bg(palette::SELECTION_BG)
10 + .add_modifier(Modifier::BOLD)
11 + }
@@ -0,0 +1,34 @@
1 + use crate::palette;
2 + use ratatui::style::{Modifier, Style};
3 + use ratatui::text::Span;
4 +
5 + pub fn fg(s: impl Into<String>) -> Span<'static> {
6 + Span::styled(s.into(), Style::default().fg(palette::FG))
7 + }
8 +
9 + pub fn dim(s: impl Into<String>) -> Span<'static> {
10 + Span::styled(s.into(), Style::default().fg(palette::DIM))
11 + }
12 +
13 + pub fn bold(s: impl Into<String>) -> Span<'static> {
14 + Span::styled(
15 + s.into(),
16 + Style::default().fg(palette::FG).add_modifier(Modifier::BOLD),
17 + )
18 + }
19 +
20 + pub fn ok(s: impl Into<String>) -> Span<'static> {
21 + Span::styled(s.into(), Style::default().fg(palette::STATE_OK))
22 + }
23 +
24 + pub fn warn(s: impl Into<String>) -> Span<'static> {
25 + Span::styled(s.into(), Style::default().fg(palette::STATE_WARN))
26 + }
27 +
28 + pub fn fault(s: impl Into<String>) -> Span<'static> {
29 + Span::styled(s.into(), Style::default().fg(palette::STATE_FAULT))
30 + }
31 +
32 + pub fn inactive(s: impl Into<String>) -> Span<'static> {
33 + dim(s)
34 + }
M docs/todo.md +5 -11
@@ -1,18 +1,12 @@
1 1 # mountaineer-sysop — todo
2 2
3 - Status: Pre-v0. Repo just initialized. Active: sysop-tui primitives. Next: first subcommand (`sysop info <role>`) as the dogfood target.
3 + Status: Pre-v0. Active: `sysop info <role>` as first subcommand. Next: `sysop motd on/off`. Primitives crate has palette, layout, footer, selection, keys; exercised by `cargo run --example smoketest -p sysop-tui`.
4 4
5 - ## Phase 1 — sysop-tui primitives
5 + ## Phase 1 — sysop-tui primitives (open items)
6 6
7 - Per `AESTHETICS.md`, this lands before any subcommand code.
8 -
9 - - [ ] Color constants for flatwhite palette (fg, bg, dim, accent, state-ok, state-warn, state-fault)
10 - - [ ] Frame: footer-only chrome, no top title bar, no internal borders, whitespace separators
11 - - [ ] Footer keybind renderer (label/key pairs, right-aligned wrap)
12 - - [ ] Selection widget: leading `▶` + inverse, two-cue
13 - - [ ] Reserved global keys handler: `?` `q` `Esc` `/` `:`
14 - - [ ] Hue-is-state helpers (`ok()`, `warn()`, `fault()`, `inactive()`); no hue for hierarchy
15 - - [ ] Smoke test binary in `examples/` rendering one screen using every primitive
7 + - [ ] Visually verify the smoketest on a flatwhite-friendly terminal (Berkeley Mono / Iosevka, true-color) and tune `SELECTION_BG` if the row contrast is too soft
8 + - [ ] Decide whether `Footer` should right-align when there's a status segment on the left (deferred until first subcommand needs it)
9 + - [ ] Help overlay primitive (`?` action currently just classifies; no overlay renderer yet)
16 10
17 11 ## Phase 2 — first subcommand: `sysop info <role>`
18 12