max / mountaineer
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 | + | } |
| @@ -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 |