max / alloy_tui
7 files changed,
+709 insertions,
-4 deletions
| @@ -0,0 +1,103 @@ | |||
| 1 | + | //! The focus ring — the one piece ratatui does not provide. | |
| 2 | + | //! | |
| 3 | + | //! Rendering is immediate-mode, but input is event-driven, so something has to | |
| 4 | + | //! remember which pane the next keystroke belongs to. Per | |
| 5 | + | //! docs/COMPONENT-LIBRARY.md that model lives here, and it is deliberately | |
| 6 | + | //! minimal: an index into a fixed number of focusable slots, wrapping in both | |
| 7 | + | //! directions. Apps map their own pane enum onto the index. | |
| 8 | + | ||
| 9 | + | /// A wrapping cursor over `len` focusable slots. | |
| 10 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 11 | + | pub struct FocusRing { | |
| 12 | + | len: usize, | |
| 13 | + | index: usize, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | impl FocusRing { | |
| 17 | + | /// A ring over `len` slots, focused on the first. | |
| 18 | + | /// | |
| 19 | + | /// A zero-length ring is legal and inert: `current()` reports 0 and the | |
| 20 | + | /// movers do nothing, so a view that has not yet loaded its panes does not | |
| 21 | + | /// have to special-case navigation. | |
| 22 | + | pub const fn new(len: usize) -> Self { | |
| 23 | + | Self { len, index: 0 } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | pub const fn current(&self) -> usize { | |
| 27 | + | self.index | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | pub const fn is_focused(&self, slot: usize) -> bool { | |
| 31 | + | self.len > 0 && self.index == slot | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | pub const fn next(&mut self) { | |
| 35 | + | if self.len > 0 { | |
| 36 | + | self.index = (self.index + 1) % self.len; | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | pub const fn prev(&mut self) { | |
| 41 | + | if self.len > 0 { | |
| 42 | + | self.index = (self.index + self.len - 1) % self.len; | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Focus a specific slot. Out-of-range indices are ignored rather than | |
| 47 | + | /// clamped — silently landing on a neighbouring pane is worse than not | |
| 48 | + | /// moving. | |
| 49 | + | pub const fn focus(&mut self, slot: usize) { | |
| 50 | + | if slot < self.len { | |
| 51 | + | self.index = slot; | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Resize the ring, keeping focus in range. Used when a view's pane count | |
| 56 | + | /// changes (a detail pane appearing, a section collapsing). | |
| 57 | + | pub const fn resize(&mut self, len: usize) { | |
| 58 | + | self.len = len; | |
| 59 | + | if self.index >= len { | |
| 60 | + | self.index = if len == 0 { 0 } else { len - 1 }; | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | #[cfg(test)] | |
| 66 | + | mod tests { | |
| 67 | + | use super::*; | |
| 68 | + | ||
| 69 | + | #[test] | |
| 70 | + | fn wraps_in_both_directions() { | |
| 71 | + | let mut ring = FocusRing::new(3); | |
| 72 | + | ring.prev(); | |
| 73 | + | assert_eq!(ring.current(), 2, "prev from the first slot wraps to the last"); | |
| 74 | + | ring.next(); | |
| 75 | + | assert_eq!(ring.current(), 0, "next from the last slot wraps to the first"); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // An empty ring is what a view has before its panes load. The movers must be | |
| 79 | + | // no-ops rather than panicking on a modulo by zero. | |
| 80 | + | #[test] | |
| 81 | + | fn empty_ring_is_inert() { | |
| 82 | + | let mut ring = FocusRing::new(0); | |
| 83 | + | ring.next(); | |
| 84 | + | ring.prev(); | |
| 85 | + | assert_eq!(ring.current(), 0); | |
| 86 | + | assert!(!ring.is_focused(0), "nothing is focused when there are no slots"); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | #[test] | |
| 90 | + | fn resize_pulls_focus_back_into_range() { | |
| 91 | + | let mut ring = FocusRing::new(4); | |
| 92 | + | ring.focus(3); | |
| 93 | + | ring.resize(2); | |
| 94 | + | assert_eq!(ring.current(), 1, "focus clamps to the new last slot"); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | #[test] | |
| 98 | + | fn focus_ignores_out_of_range_slots() { | |
| 99 | + | let mut ring = FocusRing::new(2); | |
| 100 | + | ring.focus(5); | |
| 101 | + | assert_eq!(ring.current(), 0, "an out-of-range focus leaves the ring where it was"); | |
| 102 | + | } | |
| 103 | + | } |
| @@ -0,0 +1,93 @@ | |||
| 1 | + | //! Reserved keymap: the constants every Alloy TUI navigates by, and the | |
| 2 | + | //! classifier that turns a raw key event into one of them. | |
| 3 | + | //! | |
| 4 | + | //! Per docs/COMPONENT-LIBRARY.md the reserved keys live in exactly one place so | |
| 5 | + | //! apps match against `Action` rather than hardcoding keycodes: `Tab` / | |
| 6 | + | //! `Shift-Tab` move focus, `Enter` activates, `Esc` cancels, `Ctrl-S` saves, | |
| 7 | + | //! `q` quits, `?` opens help, `/` filters, `:` opens the command line. | |
| 8 | + | //! | |
| 9 | + | //! Descended from sysop-tui's `keys.rs`, widened from that crate's six actions | |
| 10 | + | //! to the full reserved set the console's form surfaces need. | |
| 11 | + | ||
| 12 | + | use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | |
| 13 | + | ||
| 14 | + | /// A reserved key's meaning. `Passthrough` means the key is not reserved and | |
| 15 | + | /// belongs to whatever view currently holds focus. | |
| 16 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 17 | + | pub enum Action { | |
| 18 | + | NextFocus, | |
| 19 | + | PrevFocus, | |
| 20 | + | Activate, | |
| 21 | + | Cancel, | |
| 22 | + | Save, | |
| 23 | + | Help, | |
| 24 | + | Quit, | |
| 25 | + | Filter, | |
| 26 | + | Command, | |
| 27 | + | Passthrough, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// Classify a key event against the reserved keymap. | |
| 31 | + | /// | |
| 32 | + | /// This is a pure classifier with no notion of mode: it reports what a key | |
| 33 | + | /// *means* in the reserved map, not whether the app should honor it. Two | |
| 34 | + | /// caller obligations follow from that: | |
| 35 | + | /// | |
| 36 | + | /// - **Filter to `KeyEventKind::Press` first.** Windows terminals deliver both | |
| 37 | + | /// press and release for every key, so an unfiltered event loop performs | |
| 38 | + | /// each action twice. | |
| 39 | + | /// - **Ignore the character actions while text entry has focus.** `q`, `/`, | |
| 40 | + | /// and `:` are literal characters a user types into a field; a view holding | |
| 41 | + | /// an active text input should route keys to the input and consult this | |
| 42 | + | /// classifier only for `Cancel`, `Save`, and the focus movers. | |
| 43 | + | pub fn classify(key: KeyEvent) -> Action { | |
| 44 | + | let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); | |
| 45 | + | let shift = key.modifiers.contains(KeyModifiers::SHIFT); | |
| 46 | + | ||
| 47 | + | match key.code { | |
| 48 | + | KeyCode::Char('s') | KeyCode::Char('S') if ctrl => Action::Save, | |
| 49 | + | KeyCode::BackTab => Action::PrevFocus, | |
| 50 | + | KeyCode::Tab if shift => Action::PrevFocus, | |
| 51 | + | KeyCode::Tab => Action::NextFocus, | |
| 52 | + | KeyCode::Enter => Action::Activate, | |
| 53 | + | KeyCode::Esc => Action::Cancel, | |
| 54 | + | KeyCode::Char('?') => Action::Help, | |
| 55 | + | KeyCode::Char('q') => Action::Quit, | |
| 56 | + | KeyCode::Char('/') => Action::Filter, | |
| 57 | + | KeyCode::Char(':') => Action::Command, | |
| 58 | + | _ => Action::Passthrough, | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[cfg(test)] | |
| 63 | + | mod tests { | |
| 64 | + | use super::*; | |
| 65 | + | ||
| 66 | + | fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { | |
| 67 | + | KeyEvent::new(code, mods) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[test] | |
| 71 | + | fn ctrl_s_saves_but_bare_s_does_not() { | |
| 72 | + | assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)), Action::Save); | |
| 73 | + | assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::NONE)), Action::Passthrough); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Terminals disagree on how they report Shift-Tab: some send BackTab with no | |
| 77 | + | // modifier, others send Tab with SHIFT. Both must reach PrevFocus, or focus | |
| 78 | + | // navigation silently becomes one-directional on half the terminal emulators | |
| 79 | + | // in the stack. | |
| 80 | + | #[test] | |
| 81 | + | fn both_shift_tab_encodings_move_focus_backward() { | |
| 82 | + | assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus); | |
| 83 | + | assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)), Action::PrevFocus); | |
| 84 | + | assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::SHIFT)), Action::PrevFocus); | |
| 85 | + | assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | #[test] | |
| 89 | + | fn unreserved_keys_pass_through() { | |
| 90 | + | assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough); | |
| 91 | + | assert_eq!(classify(key(KeyCode::Down, KeyModifiers::NONE)), Action::Passthrough); | |
| 92 | + | } | |
| 93 | + | } |
| @@ -0,0 +1,95 @@ | |||
| 1 | + | //! The console frame: body, command-log pane, footer. | |
| 2 | + | //! | |
| 3 | + | //! sysop-tui split a screen into body plus a one-row footer. Alloy adds the | |
| 4 | + | //! command-log pane between them, which docs/CONSOLE.md settled as always-on — | |
| 5 | + | //! the "console teaches its own primitives" claim only lands if the log of | |
| 6 | + | //! underlying CLI invocations is visible without being asked for. | |
| 7 | + | ||
| 8 | + | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 9 | + | ||
| 10 | + | /// Default height of the command-log pane, borders included: a two-row window | |
| 11 | + | /// onto the log plus its box. Enough to show the command just run and the one | |
| 12 | + | /// before it, which is what makes the pane read as a running transcript rather | |
| 13 | + | /// than a status line. | |
| 14 | + | pub const LOG_HEIGHT: u16 = 4; | |
| 15 | + | ||
| 16 | + | /// Below this total height the log pane is dropped entirely. The body needs | |
| 17 | + | /// room to be worth drawing; on a short terminal the log is the first thing | |
| 18 | + | /// that should go, and it degrades to nothing rather than to a sliver of | |
| 19 | + | /// borders with no content between them. | |
| 20 | + | const MIN_HEIGHT_FOR_LOG: u16 = 12; | |
| 21 | + | ||
| 22 | + | /// The three regions of a console screen. `log` is empty (zero height) when | |
| 23 | + | /// the terminal is too short to carry it — callers can render into it | |
| 24 | + | /// unconditionally, since ratatui clips a zero-area render. | |
| 25 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 26 | + | pub struct ConsoleAreas { | |
| 27 | + | pub body: Rect, | |
| 28 | + | pub log: Rect, | |
| 29 | + | pub footer: Rect, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Split a full-screen area into body, log pane, and footer. | |
| 33 | + | pub fn console(area: Rect) -> ConsoleAreas { | |
| 34 | + | console_with_log_height(area, LOG_HEIGHT) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// As [`console`], with an explicit log-pane height. | |
| 38 | + | pub fn console_with_log_height(area: Rect, log_height: u16) -> ConsoleAreas { | |
| 39 | + | if area.height < MIN_HEIGHT_FOR_LOG || log_height == 0 { | |
| 40 | + | let [body, footer] = | |
| 41 | + | Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area); | |
| 42 | + | return ConsoleAreas { | |
| 43 | + | body, | |
| 44 | + | log: Rect { height: 0, ..body }, | |
| 45 | + | footer, | |
| 46 | + | }; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | let [body, log, footer] = Layout::vertical([ | |
| 50 | + | Constraint::Min(0), | |
| 51 | + | Constraint::Length(log_height), | |
| 52 | + | Constraint::Length(1), | |
| 53 | + | ]) | |
| 54 | + | .areas(area); | |
| 55 | + | ||
| 56 | + | ConsoleAreas { body, log, footer } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[cfg(test)] | |
| 60 | + | mod tests { | |
| 61 | + | use super::*; | |
| 62 | + | ||
| 63 | + | #[test] | |
| 64 | + | fn full_height_gets_all_three_regions() { | |
| 65 | + | let areas = console(Rect::new(0, 0, 80, 24)); | |
| 66 | + | assert_eq!(areas.body.height, 24 - LOG_HEIGHT - 1); | |
| 67 | + | assert_eq!(areas.log.height, LOG_HEIGHT); | |
| 68 | + | assert_eq!(areas.footer.height, 1); | |
| 69 | + | assert_eq!( | |
| 70 | + | areas.body.height + areas.log.height + areas.footer.height, | |
| 71 | + | 24, | |
| 72 | + | "the three regions must tile the screen exactly, with no dead row" | |
| 73 | + | ); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // A short terminal drops the log rather than starving the body. Without this | |
| 77 | + | // the layout hands the body 1-2 rows and spends the rest on log borders. | |
| 78 | + | #[test] | |
| 79 | + | fn short_terminal_drops_the_log_pane() { | |
| 80 | + | let areas = console(Rect::new(0, 0, 80, 10)); | |
| 81 | + | assert_eq!(areas.log.height, 0, "log is dropped below the minimum height"); | |
| 82 | + | assert_eq!(areas.body.height, 9); | |
| 83 | + | assert_eq!(areas.footer.height, 1); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // The degenerate case: a terminal so short there is only the footer. This | |
| 87 | + | // must not panic or produce a negative-height body. | |
| 88 | + | #[test] | |
| 89 | + | fn single_row_terminal_yields_footer_only() { | |
| 90 | + | let areas = console(Rect::new(0, 0, 80, 1)); | |
| 91 | + | assert_eq!(areas.body.height, 0); | |
| 92 | + | assert_eq!(areas.log.height, 0); | |
| 93 | + | assert_eq!(areas.footer.height, 1); | |
| 94 | + | } | |
| 95 | + | } |
| @@ -8,9 +8,25 @@ | |||
| 8 | 8 | //! targets, and `docs/DESIGN-LANGUAGE.md` for the color-is-information rule | |
| 9 | 9 | //! enforced here: chrome is tinted-greyscale, accents live on text via | |
| 10 | 10 | //! `Severity`. | |
| 11 | + | //! | |
| 12 | + | //! Beyond the widgets, the crate carries the two things ratatui leaves to the | |
| 13 | + | //! app and every Alloy TUI must agree on: the reserved keymap ([`keys`]) and | |
| 14 | + | //! the focus ring ([`focus`]). Both descend from mountaineer-sysop's | |
| 15 | + | //! `sysop-tui`, retinted from a const palette to the runtime [`Theme`]. | |
| 16 | + | //! | |
| 17 | + | //! <!-- wiki: alloy-console --> | |
| 11 | 18 | ||
| 19 | + | pub mod focus; | |
| 20 | + | pub mod keys; | |
| 21 | + | pub mod layout; | |
| 22 | + | pub mod selection; | |
| 23 | + | pub mod text; | |
| 12 | 24 | pub mod theme; | |
| 13 | 25 | pub mod widgets; | |
| 14 | 26 | ||
| 27 | + | pub use focus::FocusRing; | |
| 28 | + | pub use keys::{Action, classify}; | |
| 29 | + | pub use layout::{ConsoleAreas, console}; | |
| 30 | + | pub use selection::{MARKER, selected_style}; | |
| 15 | 31 | pub use theme::{Mode, Theme, ThemeError}; | |
| 16 | 32 | pub use widgets::*; |
| @@ -0,0 +1,36 @@ | |||
| 1 | + | //! Selection chrome: the marker glyph and the selected-row style. | |
| 2 | + | //! | |
| 3 | + | //! Ported from sysop-tui's `selection.rs`, retinted from the theme. The marker | |
| 4 | + | //! stays a plain triangle rather than a Nerd Font glyph so selection survives | |
| 5 | + | //! a console without the patched font — the TTY before the session starts, a | |
| 6 | + | //! remote shell, `alloy` over SSH. Per docs/ICONOGRAPHY.md, Nerd Font glyphs | |
| 7 | + | //! decorate; they never carry state on their own. | |
| 8 | + | ||
| 9 | + | use ratatui::style::{Modifier, Style}; | |
| 10 | + | ||
| 11 | + | use crate::theme::Theme; | |
| 12 | + | ||
| 13 | + | /// The selected-row marker. Rendered in the gutter, one cell plus a space. | |
| 14 | + | pub const MARKER: &str = "▶"; | |
| 15 | + | ||
| 16 | + | /// Blank gutter for unselected rows — the same width as [`MARKER`], so rows do | |
| 17 | + | /// not shift horizontally as selection moves. | |
| 18 | + | pub const MARKER_BLANK: &str = " "; | |
| 19 | + | ||
| 20 | + | /// Style for the selected row. | |
| 21 | + | /// | |
| 22 | + | /// Uses `surface.raised` as the selection field rather than an accent fill: | |
| 23 | + | /// DESIGN-LANGUAGE.md keeps color off chrome, so selection reads as a *raised* | |
| 24 | + | /// surface plus weight, and any `Severity` color already on the row survives | |
| 25 | + | /// unchanged instead of being drowned by an accent background. | |
| 26 | + | pub fn selected_style(theme: &Theme) -> Style { | |
| 27 | + | Style::default() | |
| 28 | + | .bg(theme.surface_raised) | |
| 29 | + | .fg(theme.content_primary) | |
| 30 | + | .add_modifier(Modifier::BOLD) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Style for an unselected row. | |
| 34 | + | pub fn unselected_style(theme: &Theme) -> Style { | |
| 35 | + | Style::default().fg(theme.content_secondary) | |
| 36 | + | } |
| @@ -0,0 +1,42 @@ | |||
| 1 | + | //! Themed span helpers. | |
| 2 | + | //! | |
| 3 | + | //! sysop-tui's `style.rs` offered the same set (`fg`, `dim`, `bold`, `ok`, | |
| 4 | + | //! `warn`, `fault`) against a const palette. Alloy's palette is loaded at | |
| 5 | + | //! runtime, so each helper takes the `&Theme` instead. The status-colored | |
| 6 | + | //! variants are gone: they collapse into [`Severity`](crate::Severity), which | |
| 7 | + | //! already owns the mapping from intent to `status.*` color. | |
| 8 | + | ||
| 9 | + | use ratatui::style::{Modifier, Style}; | |
| 10 | + | use ratatui::text::Span; | |
| 11 | + | ||
| 12 | + | use crate::theme::Theme; | |
| 13 | + | ||
| 14 | + | /// Body text in the primary content color. | |
| 15 | + | pub fn primary(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 16 | + | Span::styled(s.into(), Style::default().fg(theme.content_primary)) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// De-emphasized text — labels, units, inactive rows. | |
| 20 | + | pub fn muted(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 21 | + | Span::styled(s.into(), Style::default().fg(theme.content_muted)) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// Supporting text: dimmer than primary, louder than muted. | |
| 25 | + | pub fn secondary(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 26 | + | Span::styled(s.into(), Style::default().fg(theme.content_secondary)) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Emphasized body text. | |
| 30 | + | pub fn bold(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 31 | + | Span::styled( | |
| 32 | + | s.into(), | |
| 33 | + | Style::default().fg(theme.content_primary).add_modifier(Modifier::BOLD), | |
| 34 | + | ) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// A key hint or other interactive affordance. The one accent-on-text use that | |
| 38 | + | /// is not a `Severity` — DESIGN-LANGUAGE.md allows the action color here | |
| 39 | + | /// because a key hint *is* the actionable element, not decoration. | |
| 40 | + | pub fn action(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 41 | + | Span::styled(s.into(), Style::default().fg(theme.action_primary)) | |
| 42 | + | } |
| @@ -2,13 +2,19 @@ | |||
| 2 | 2 | //! | |
| 3 | 3 | //! v1 target per docs/CONSOLE.md: `AlloyBlock`, `AlloyList`, `AlloyForm`, | |
| 4 | 4 | //! `AlloyTable`, `AlloyStatusBar`, `AlloyLog`, plus form-field widgets driven | |
| 5 | - | //! by the config schema. This module seeds the crate with `AlloyBlock` and the | |
| 6 | - | //! `Severity` accent — the two pieces every other widget composes with — and | |
| 7 | - | //! grows from there. | |
| 5 | + | //! by the config schema. This module carries the four the console shell needs | |
| 6 | + | //! to render a screen end to end — block, list, log pane, status bar — plus | |
| 7 | + | //! the `Severity` accent they all compose with. `AlloyForm`, `AlloyTable`, and | |
| 8 | + | //! the schema-driven fields land with `alloy config`. | |
| 8 | 9 | ||
| 10 | + | use ratatui::buffer::Buffer; | |
| 11 | + | use ratatui::layout::Rect; | |
| 9 | 12 | use ratatui::style::{Color, Style}; | |
| 10 | - | use ratatui::widgets::{Block, Borders}; | |
| 13 | + | use ratatui::text::{Line, Span}; | |
| 14 | + | use ratatui::widgets::{Block, Borders, Paragraph, Widget}; | |
| 11 | 15 | ||
| 16 | + | use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style}; | |
| 17 | + | use crate::text; | |
| 12 | 18 | use crate::theme::Theme; | |
| 13 | 19 | ||
| 14 | 20 | /// Themed `Block`: default borders + palette chrome. Wraps `ratatui::widgets::Block` | |
| @@ -86,3 +92,317 @@ impl Severity { | |||
| 86 | 92 | Style::default().fg(self.color(theme)) | |
| 87 | 93 | } | |
| 88 | 94 | } | |
| 95 | + | ||
| 96 | + | /// A footer key hint: the key, and what it does. | |
| 97 | + | pub struct Hint { | |
| 98 | + | pub key: &'static str, | |
| 99 | + | pub label: &'static str, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Terse constructor for a [`Hint`], so hint lists read as data at the call | |
| 103 | + | /// site: `[hint("Tab", "focus"), hint("q", "quit")]`. | |
| 104 | + | pub const fn hint(key: &'static str, label: &'static str) -> Hint { | |
| 105 | + | Hint { key, label } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// The one-row footer: key hints on the left, transient status on the right. | |
| 109 | + | /// | |
| 110 | + | /// This is docs/CONSOLE.md's "common status area" and | |
| 111 | + | /// docs/COMPONENT-LIBRARY.md's footer chrome in one widget — they occupy the | |
| 112 | + | /// same row, and splitting them into two widgets would mean two things | |
| 113 | + | /// competing for it. Descended from sysop-tui's `Footer`, with the status | |
| 114 | + | /// slot added. | |
| 115 | + | pub struct AlloyStatusBar<'a> { | |
| 116 | + | theme: &'a Theme, | |
| 117 | + | hints: Vec<Hint>, | |
| 118 | + | status: Option<(Severity, String)>, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | impl<'a> AlloyStatusBar<'a> { | |
| 122 | + | pub fn new(theme: &'a Theme, hints: impl IntoIterator<Item = Hint>) -> Self { | |
| 123 | + | Self { | |
| 124 | + | theme, | |
| 125 | + | hints: hints.into_iter().collect(), | |
| 126 | + | status: None, | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// Attach a transient status message (busy, error, dirty) to the right end. | |
| 131 | + | pub fn status(mut self, severity: Severity, message: impl Into<String>) -> Self { | |
| 132 | + | self.status = Some((severity, message.into())); | |
| 133 | + | self | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | impl Widget for AlloyStatusBar<'_> { | |
| 138 | + | fn render(self, area: Rect, buf: &mut Buffer) { | |
| 139 | + | let base = Style::default().bg(self.theme.surface_sunken); | |
| 140 | + | Paragraph::new("").style(base).render(area, buf); | |
| 141 | + | ||
| 142 | + | let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 3); | |
| 143 | + | for (i, h) in self.hints.iter().enumerate() { | |
| 144 | + | if i > 0 { | |
| 145 | + | spans.push(Span::raw(" ")); | |
| 146 | + | } | |
| 147 | + | spans.push(text::action(self.theme, h.key)); | |
| 148 | + | spans.push(Span::raw(" ")); | |
| 149 | + | spans.push(text::muted(self.theme, h.label)); | |
| 150 | + | } | |
| 151 | + | Paragraph::new(Line::from(spans)) | |
| 152 | + | .style(base) | |
| 153 | + | .render(area, buf); | |
| 154 | + | ||
| 155 | + | // The status sits on the same row, right-aligned. Rendering it as a | |
| 156 | + | // second pass into a right-hand slice means a long hint list is | |
| 157 | + | // overwritten by the status rather than pushing it off-screen — the | |
| 158 | + | // status is the more urgent of the two. | |
| 159 | + | if let Some((severity, message)) = self.status { | |
| 160 | + | let text_width = message.chars().count() as u16 + 1; | |
| 161 | + | let width = text_width.min(area.width); | |
| 162 | + | let slot = Rect { | |
| 163 | + | x: area.x + area.width - width, | |
| 164 | + | width, | |
| 165 | + | ..area | |
| 166 | + | }; | |
| 167 | + | Paragraph::new(Line::from(Span::styled( | |
| 168 | + | message, | |
| 169 | + | severity.style(self.theme).patch(base), | |
| 170 | + | ))) | |
| 171 | + | .style(base) | |
| 172 | + | .right_aligned() | |
| 173 | + | .render(slot, buf); | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// Themed selectable list. | |
| 179 | + | /// | |
| 180 | + | /// Rows are pre-composed `Line`s so callers keep control of their own content | |
| 181 | + | /// styling (a `Severity` span in a row survives selection); this widget owns | |
| 182 | + | /// only the gutter marker, the row style, and scrolling. | |
| 183 | + | pub struct AlloyList<'a> { | |
| 184 | + | theme: &'a Theme, | |
| 185 | + | items: Vec<Line<'a>>, | |
| 186 | + | selected: Option<usize>, | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | impl<'a> AlloyList<'a> { | |
| 190 | + | pub fn new(theme: &'a Theme, items: impl IntoIterator<Item = Line<'a>>) -> Self { | |
| 191 | + | Self { | |
| 192 | + | theme, | |
| 193 | + | items: items.into_iter().collect(), | |
| 194 | + | selected: None, | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | pub fn selected(mut self, selected: Option<usize>) -> Self { | |
| 199 | + | self.selected = selected; | |
| 200 | + | self | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /// First visible row for a viewport of `height` rows. | |
| 204 | + | /// | |
| 205 | + | /// Stateless by design: the offset is derived from the selection each | |
| 206 | + | /// frame rather than carried between frames, which is what lets the whole | |
| 207 | + | /// widget stay immediate-mode. The cost is that scrolling centers the | |
| 208 | + | /// selection instead of scrolling by the minimum amount; the benefit is | |
| 209 | + | /// that no caller has to own and thread a `ListState`. | |
| 210 | + | fn offset(&self, height: usize) -> usize { | |
| 211 | + | let (Some(selected), true) = (self.selected, self.items.len() > height) else { | |
| 212 | + | return 0; | |
| 213 | + | }; | |
| 214 | + | let max_offset = self.items.len() - height; | |
| 215 | + | selected.saturating_sub(height / 2).min(max_offset) | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | impl Widget for AlloyList<'_> { | |
| 220 | + | fn render(self, area: Rect, buf: &mut Buffer) { | |
| 221 | + | if area.height == 0 || area.width == 0 { | |
| 222 | + | return; | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | let height = area.height as usize; | |
| 226 | + | let offset = self.offset(height); | |
| 227 | + | ||
| 228 | + | for (row, (index, item)) in self | |
| 229 | + | .items | |
| 230 | + | .iter() | |
| 231 | + | .enumerate() | |
| 232 | + | .skip(offset) | |
| 233 | + | .take(height) | |
| 234 | + | .enumerate() | |
| 235 | + | { | |
| 236 | + | let is_selected = self.selected == Some(index); | |
| 237 | + | let style = if is_selected { | |
| 238 | + | selected_style(self.theme) | |
| 239 | + | } else { | |
| 240 | + | unselected_style(self.theme) | |
| 241 | + | }; | |
| 242 | + | let marker = if is_selected { MARKER } else { MARKER_BLANK }; | |
| 243 | + | ||
| 244 | + | let mut spans = vec![Span::styled(format!("{marker} "), style)]; | |
| 245 | + | spans.extend(item.spans.iter().cloned()); | |
| 246 | + | ||
| 247 | + | let line_area = Rect { | |
| 248 | + | y: area.y + row as u16, | |
| 249 | + | height: 1, | |
| 250 | + | ..area | |
| 251 | + | }; | |
| 252 | + | Paragraph::new(Line::from(spans)) | |
| 253 | + | .style(style) | |
| 254 | + | .render(line_area, buf); | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// One line of the command log: the CLI invocation that was run, and how it | |
| 260 | + | /// went. | |
| 261 | + | /// | |
| 262 | + | /// The console fronts CLIs rather than hiding them (docs/CONSOLE.md), so | |
| 263 | + | /// `command` holds the actual argv the console executed — verbatim, so a user | |
| 264 | + | /// can copy it into a shell and get the same result. | |
| 265 | + | #[derive(Debug, Clone)] | |
| 266 | + | pub struct LogEntry { | |
| 267 | + | pub command: String, | |
| 268 | + | pub outcome: Severity, | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | impl LogEntry { | |
| 272 | + | pub fn new(command: impl Into<String>, outcome: Severity) -> Self { | |
| 273 | + | Self { | |
| 274 | + | command: command.into(), | |
| 275 | + | outcome, | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// The always-on command-log pane. | |
| 281 | + | /// | |
| 282 | + | /// Renders the tail of the log — the most recent invocation on the bottom row, | |
| 283 | + | /// terminal-transcript order, so the pane reads the way a shell scrollback | |
| 284 | + | /// does. | |
| 285 | + | pub struct AlloyLog<'a> { | |
| 286 | + | theme: &'a Theme, | |
| 287 | + | entries: &'a [LogEntry], | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | impl<'a> AlloyLog<'a> { | |
| 291 | + | pub fn new(theme: &'a Theme, entries: &'a [LogEntry]) -> Self { | |
| 292 | + | Self { theme, entries } | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | impl Widget for AlloyLog<'_> { | |
| 297 | + | fn render(self, area: Rect, buf: &mut Buffer) { | |
| 298 | + | if area.height == 0 || area.width == 0 { | |
| 299 | + | return; | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | let block = AlloyBlock::new(self.theme).build().title(" commands "); | |
| 303 | + | let inner = block.inner(area); | |
| 304 | + | block.render(area, buf); | |
| 305 | + | ||
| 306 | + | if inner.height == 0 { | |
| 307 | + | return; | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | let visible = inner.height as usize; | |
| 311 | + | let tail = self.entries.len().saturating_sub(visible); | |
| 312 | + | let lines: Vec<Line> = self.entries[tail..] | |
| 313 | + | .iter() | |
| 314 | + | .map(|entry| { | |
| 315 | + | Line::from(vec![ | |
| 316 | + | Span::styled("$ ", entry.outcome.style(self.theme)), | |
| 317 | + | text::secondary(self.theme, entry.command.clone()), | |
| 318 | + | ]) | |
| 319 | + | }) | |
| 320 | + | .collect(); | |
| 321 | + | ||
| 322 | + | Paragraph::new(lines) | |
| 323 | + | .style(Style::default().bg(self.theme.surface_page)) | |
| 324 | + | .render(inner, buf); | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | #[cfg(test)] | |
| 329 | + | mod tests { | |
| 330 | + | use super::*; | |
| 331 | + | use ratatui::style::Color; | |
| 332 | + | ||
| 333 | + | fn theme() -> Theme { | |
| 334 | + | Theme { | |
| 335 | + | mode: crate::theme::Mode::Dark, | |
| 336 | + | surface_page: Color::Rgb(0, 0, 0), | |
| 337 | + | surface_raised: Color::Rgb(1, 1, 1), | |
| 338 | + | surface_sunken: Color::Rgb(2, 2, 2), | |
| 339 | + | surface_overlay: Color::Rgb(3, 3, 3), | |
| 340 | + | content_primary: Color::Rgb(4, 4, 4), | |
| 341 | + | content_secondary: Color::Rgb(5, 5, 5), | |
| 342 | + | content_muted: Color::Rgb(6, 6, 6), | |
| 343 | + | action_primary: Color::Rgb(7, 7, 7), | |
| 344 | + | status_danger: Color::Rgb(8, 8, 8), | |
| 345 | + | status_success: Color::Rgb(9, 9, 9), | |
| 346 | + | status_warning: Color::Rgb(10, 10, 10), | |
| 347 | + | status_info: Color::Rgb(11, 11, 11), | |
| 348 | + | line_border: Color::Rgb(12, 12, 12), | |
| 349 | + | border_subtle: Color::Rgb(13, 13, 13), | |
| 350 | + | border_strong: Color::Rgb(14, 14, 14), | |
| 351 | + | category: [Color::Rgb(15, 15, 15); 6], | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> { | |
| 356 | + | // Leaked so the test list can hold a 'static theme reference; the | |
| 357 | + | // widget borrows rather than owns, and these are per-test one-offs. | |
| 358 | + | let theme: &'static Theme = Box::leak(Box::new(theme())); | |
| 359 | + | let items: Vec<Line<'static>> = (0..n).map(|i| Line::from(format!("row {i}"))).collect(); | |
| 360 | + | AlloyList::new(theme, items).selected(selected) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[test] | |
| 364 | + | fn short_list_never_scrolls() { | |
| 365 | + | assert_eq!(list_of(3, Some(2)).offset(10), 0); | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // Selection near the top must not scroll past the start of the list — a | |
| 369 | + | // naive `selected - height/2` underflows or shows blank rows above row 0. | |
| 370 | + | #[test] | |
| 371 | + | fn offset_clamps_at_the_top() { | |
| 372 | + | assert_eq!(list_of(50, Some(0)).offset(10), 0); | |
| 373 | + | assert_eq!(list_of(50, Some(2)).offset(10), 0); | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // Selection at the end must land the last row on the last visible line, | |
| 377 | + | // not scroll into empty space past the end of the list. | |
| 378 | + | #[test] | |
| 379 | + | fn offset_clamps_at_the_bottom() { | |
| 380 | + | assert_eq!(list_of(50, Some(49)).offset(10), 40); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | #[test] | |
| 384 | + | fn offset_centers_a_midlist_selection() { | |
| 385 | + | assert_eq!(list_of(50, Some(25)).offset(10), 20); | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // A log longer than its pane shows the newest entries. Showing the head | |
| 389 | + | // instead would freeze the pane on startup noise and never display the | |
| 390 | + | // command the user just triggered. | |
| 391 | + | #[test] | |
| 392 | + | fn log_renders_the_newest_entries() { | |
| 393 | + | let theme = theme(); | |
| 394 | + | let entries: Vec<LogEntry> = (0..10) | |
| 395 | + | .map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy)) | |
| 396 | + | .collect(); | |
| 397 | + | let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4)); | |
| 398 | + | AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf); | |
| 399 | + | ||
| 400 | + | let rendered = buf | |
| 401 | + | .content() | |
| 402 | + | .iter() | |
| 403 | + | .map(|cell| cell.symbol()) | |
| 404 | + | .collect::<String>(); | |
| 405 | + | assert!(rendered.contains("nmcli run 9"), "newest entry must be visible"); | |
| 406 | + | assert!(!rendered.contains("nmcli run 0"), "oldest entry must have scrolled off"); | |
| 407 | + | } | |
| 408 | + | } |