max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 files changed,
+1804 insertions,
-4 deletions
| @@ -17,6 +17,17 @@ | |||
| 17 | 17 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | 18 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 19 | 19 | ||
| 20 | + | [[package]] | |
| 21 | + | name = "alloy" | |
| 22 | + | version = "0.0.0" | |
| 23 | + | dependencies = [ | |
| 24 | + | "alloy_tui", | |
| 25 | + | "anyhow", | |
| 26 | + | "clap", | |
| 27 | + | "ratatui", | |
| 28 | + | "theme-common", | |
| 29 | + | ] | |
| 30 | + | ||
| 20 | 31 | [[package]] | |
| 21 | 32 | name = "alloy_tui" | |
| 22 | 33 | version = "0.0.0" | |
| @@ -25,6 +36,56 @@ | |||
| 25 | 36 | "theme-common", | |
| 26 | 37 | ] | |
| 27 | 38 | ||
| 39 | + | [[package]] | |
| 40 | + | name = "anstream" | |
| 41 | + | version = "1.0.0" | |
| 42 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 43 | + | checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" | |
| 44 | + | dependencies = [ | |
| 45 | + | "anstyle", | |
| 46 | + | "anstyle-parse", | |
| 47 | + | "anstyle-query", | |
| 48 | + | "anstyle-wincon", | |
| 49 | + | "colorchoice", | |
| 50 | + | "is_terminal_polyfill", | |
| 51 | + | "utf8parse", | |
| 52 | + | ] | |
| 53 | + | ||
| 54 | + | [[package]] | |
| 55 | + | name = "anstyle" | |
| 56 | + | version = "1.0.14" | |
| 57 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 58 | + | checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | |
| 59 | + | ||
| 60 | + | [[package]] | |
| 61 | + | name = "anstyle-parse" | |
| 62 | + | version = "1.0.0" | |
| 63 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 64 | + | checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" | |
| 65 | + | dependencies = [ | |
| 66 | + | "utf8parse", | |
| 67 | + | ] | |
| 68 | + | ||
| 69 | + | [[package]] | |
| 70 | + | name = "anstyle-query" | |
| 71 | + | version = "1.1.5" | |
| 72 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 73 | + | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | |
| 74 | + | dependencies = [ | |
| 75 | + | "windows-sys", | |
| 76 | + | ] | |
| 77 | + | ||
| 78 | + | [[package]] | |
| 79 | + | name = "anstyle-wincon" | |
| 80 | + | version = "3.0.11" | |
| 81 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 82 | + | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |
| 83 | + | dependencies = [ | |
| 84 | + | "anstyle", | |
| 85 | + | "once_cell_polyfill", | |
| 86 | + | "windows-sys", | |
| 87 | + | ] | |
| 88 | + | ||
| 28 | 89 | [[package]] | |
| 29 | 90 | name = "anyhow" | |
| 30 | 91 | version = "1.0.103" | |
| @@ -121,6 +182,52 @@ | |||
| 121 | 182 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 122 | 183 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | |
| 123 | 184 | ||
| 185 | + | [[package]] | |
| 186 | + | name = "clap" | |
| 187 | + | version = "4.6.2" | |
| 188 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 189 | + | checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" | |
| 190 | + | dependencies = [ | |
| 191 | + | "clap_builder", | |
| 192 | + | "clap_derive", | |
| 193 | + | ] | |
| 194 | + | ||
| 195 | + | [[package]] | |
| 196 | + | name = "clap_builder" | |
| 197 | + | version = "4.6.2" | |
| 198 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 199 | + | checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" | |
| 200 | + | dependencies = [ | |
| 201 | + | "anstream", | |
| 202 | + | "anstyle", | |
| 203 | + | "clap_lex", | |
| 204 | + | "strsim", | |
| 205 | + | ] | |
| 206 | + | ||
| 207 | + | [[package]] | |
| 208 | + | name = "clap_derive" | |
| 209 | + | version = "4.6.1" | |
| 210 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 211 | + | checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" | |
| 212 | + | dependencies = [ | |
| 213 | + | "heck", | |
| 214 | + | "proc-macro2", | |
| 215 | + | "quote", | |
| 216 | + | "syn 2.0.118", | |
| 217 | + | ] | |
| 218 | + | ||
| 219 | + | [[package]] | |
| 220 | + | name = "clap_lex" | |
| 221 | + | version = "1.1.0" | |
| 222 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 223 | + | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" | |
| 224 | + | ||
| 225 | + | [[package]] | |
| 226 | + | name = "colorchoice" | |
| 227 | + | version = "1.0.5" | |
| 228 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 229 | + | checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" | |
| 230 | + | ||
| 124 | 231 | [[package]] | |
| 125 | 232 | name = "compact_str" | |
| 126 | 233 | version = "0.9.1" | |
| @@ -491,6 +598,12 @@ | |||
| 491 | 598 | "syn 2.0.118", | |
| 492 | 599 | ] | |
| 493 | 600 | ||
| 601 | + | [[package]] | |
| 602 | + | name = "is_terminal_polyfill" | |
| 603 | + | version = "1.70.2" | |
| 604 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 605 | + | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | |
| 606 | + | ||
| 494 | 607 | [[package]] | |
| 495 | 608 | name = "itertools" | |
| 496 | 609 | version = "0.14.0" | |
| @@ -704,6 +817,12 @@ | |||
| 704 | 817 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 705 | 818 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 706 | 819 | ||
| 820 | + | [[package]] | |
| 821 | + | name = "once_cell_polyfill" | |
| 822 | + | version = "1.70.2" | |
| 823 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 824 | + | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | |
| 825 | + | ||
| 707 | 826 | [[package]] | |
| 708 | 827 | name = "ordered-float" | |
| 709 | 828 | version = "4.6.0" |
| @@ -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::*; |
| @@ -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 @@ | |||
| 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 | + | } |
| @@ -1,0 +1,20 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "alloy" | |
| 3 | + | version = "0.0.0" | |
| 4 | + | description = "The Alloy Console: a ratatui control surface fronting the system CLIs." | |
| 5 | + | edition.workspace = true | |
| 6 | + | rust-version.workspace = true | |
| 7 | + | license.workspace = true | |
| 8 | + | repository.workspace = true | |
| 9 | + | authors.workspace = true | |
| 10 | + | ||
| 11 | + | [[bin]] | |
| 12 | + | name = "alloy" | |
| 13 | + | path = "src/main.rs" | |
| 14 | + | ||
| 15 | + | [dependencies] | |
| 16 | + | alloy_tui = { path = "../alloy_tui" } | |
| 17 | + | anyhow = "1" | |
| 18 | + | clap = { version = "4", features = ["derive"] } | |
| 19 | + | ratatui.workspace = true | |
| 20 | + | theme-common.workspace = true |
| @@ -1,0 +1,194 @@ | |||
| 1 | + | //! Invoking the CLIs the console fronts, and recording what was invoked. | |
| 2 | + | //! | |
| 3 | + | //! Every command the console runs passes through here, which is what makes the | |
| 4 | + | //! log pane's promise honest: the pane cannot show a command the console did | |
| 5 | + | //! not run, and it cannot run one it does not show. docs/CONSOLE.md — "the | |
| 6 | + | //! console is not trying to hide the CLI, it's trying to make the CLI | |
| 7 | + | //! approachable" — is enforced structurally rather than by remembering to log. | |
| 8 | + | ||
| 9 | + | use std::collections::VecDeque; | |
| 10 | + | use std::process::Command; | |
| 11 | + | ||
| 12 | + | use alloy_tui::{LogEntry, Severity}; | |
| 13 | + | use anyhow::{Context, Result, bail}; | |
| 14 | + | ||
| 15 | + | /// How many invocations the log keeps. The pane shows a couple of rows; the | |
| 16 | + | /// rest is scrollback for a future `alloy log` or a scroll binding. | |
| 17 | + | const LOG_CAPACITY: usize = 256; | |
| 18 | + | ||
| 19 | + | /// The command log — the console's transcript of what it actually ran. | |
| 20 | + | #[derive(Debug, Default)] | |
| 21 | + | pub struct CommandLog { | |
| 22 | + | entries: VecDeque<LogEntry>, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | impl CommandLog { | |
| 26 | + | pub fn new() -> Self { | |
| 27 | + | Self::default() | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | pub fn record(&mut self, command: impl Into<String>, outcome: Severity) { | |
| 31 | + | if self.entries.len() == LOG_CAPACITY { | |
| 32 | + | self.entries.pop_front(); | |
| 33 | + | } | |
| 34 | + | self.entries.push_back(LogEntry::new(command, outcome)); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Entries oldest-first, for [`AlloyLog`](alloy_tui::AlloyLog). | |
| 38 | + | /// | |
| 39 | + | /// `VecDeque` is not contiguous, so the slice view needs the ring | |
| 40 | + | /// straightened first; this is called once per frame, and after the first | |
| 41 | + | /// call the deque is already contiguous. | |
| 42 | + | pub fn entries(&mut self) -> &[LogEntry] { | |
| 43 | + | self.entries.make_contiguous(); | |
| 44 | + | self.entries.as_slices().0 | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// A command line, held as argv rather than a string so it is executed exactly | |
| 49 | + | /// as displayed — no shell, no quoting round-trip, no injection surface. | |
| 50 | + | #[derive(Debug, Clone)] | |
| 51 | + | pub struct Invocation { | |
| 52 | + | program: String, | |
| 53 | + | args: Vec<String>, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | impl Invocation { | |
| 57 | + | pub fn new(program: impl Into<String>) -> Self { | |
| 58 | + | Self { | |
| 59 | + | program: program.into(), | |
| 60 | + | args: Vec::new(), | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | pub fn arg(mut self, arg: impl Into<String>) -> Self { | |
| 65 | + | self.args.push(arg.into()); | |
| 66 | + | self | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | pub fn args<I, S>(mut self, args: I) -> Self | |
| 70 | + | where | |
| 71 | + | I: IntoIterator<Item = S>, | |
| 72 | + | S: Into<String>, | |
| 73 | + | { | |
| 74 | + | self.args.extend(args.into_iter().map(Into::into)); | |
| 75 | + | self | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// The command as a user would type it. Arguments containing whitespace are | |
| 79 | + | /// quoted so the displayed line is copy-pasteable into a shell and means | |
| 80 | + | /// the same thing there as it did here. | |
| 81 | + | pub fn display(&self) -> String { | |
| 82 | + | let mut out = String::from(&self.program); | |
| 83 | + | for arg in &self.args { | |
| 84 | + | out.push(' '); | |
| 85 | + | if arg.contains(char::is_whitespace) { | |
| 86 | + | out.push('\''); | |
| 87 | + | out.push_str(arg); | |
| 88 | + | out.push('\''); | |
| 89 | + | } else { | |
| 90 | + | out.push_str(arg); | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | out | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Run the command and return its stdout, recording the invocation and its | |
| 97 | + | /// outcome in `log`. | |
| 98 | + | pub fn run(&self, log: &mut CommandLog) -> Result<String> { | |
| 99 | + | let result = self.capture(); | |
| 100 | + | log.record( | |
| 101 | + | self.display(), | |
| 102 | + | if result.is_ok() { Severity::Healthy } else { Severity::Error }, | |
| 103 | + | ); | |
| 104 | + | result | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// Run without logging — for probes, which run before the user has asked | |
| 108 | + | /// for anything and would otherwise fill the pane with noise the user did | |
| 109 | + | /// not trigger. | |
| 110 | + | pub fn probe(&self) -> bool { | |
| 111 | + | self.capture().is_ok() | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | fn capture(&self) -> Result<String> { | |
| 115 | + | let output = Command::new(&self.program) | |
| 116 | + | .args(&self.args) | |
| 117 | + | .output() | |
| 118 | + | .with_context(|| format!("failed to invoke `{}`", self.display()))?; | |
| 119 | + | ||
| 120 | + | if !output.status.success() { | |
| 121 | + | let stderr = String::from_utf8_lossy(&output.stderr); | |
| 122 | + | let detail = stderr.trim(); | |
| 123 | + | // A nonzero exit with nothing on stderr is common enough (nmcli | |
| 124 | + | // does it for "no such device") that reporting an empty message | |
| 125 | + | // would leave the user with no idea what happened. | |
| 126 | + | if detail.is_empty() { | |
| 127 | + | bail!("`{}` exited with {}", self.display(), output.status); | |
| 128 | + | } | |
| 129 | + | bail!("`{}`: {detail}", self.display()); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | String::from_utf8(output.stdout) | |
| 133 | + | .with_context(|| format!("`{}` emitted non-UTF-8 output", self.display())) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | #[cfg(test)] | |
| 138 | + | mod tests { | |
| 139 | + | use super::*; | |
| 140 | + | ||
| 141 | + | #[test] | |
| 142 | + | fn display_round_trips_a_plain_command() { | |
| 143 | + | let inv = Invocation::new("nmcli").args(["-t", "-f", "DEVICE,TYPE", "device", "status"]); | |
| 144 | + | assert_eq!(inv.display(), "nmcli -t -f DEVICE,TYPE device status"); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // An SSID with a space is the common case that breaks a naive join. The | |
| 148 | + | // displayed line is advertised as copy-pasteable, so it has to survive one. | |
| 149 | + | #[test] | |
| 150 | + | fn display_quotes_arguments_containing_whitespace() { | |
| 151 | + | let inv = Invocation::new("nmcli").args(["connection", "up", "Coffee Shop Wifi"]); | |
| 152 | + | assert_eq!(inv.display(), "nmcli connection up 'Coffee Shop Wifi'"); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn log_keeps_insertion_order() { | |
| 157 | + | let mut log = CommandLog::new(); | |
| 158 | + | log.record("first", Severity::Healthy); | |
| 159 | + | log.record("second", Severity::Error); | |
| 160 | + | let entries = log.entries(); | |
| 161 | + | assert_eq!(entries[0].command, "first"); | |
| 162 | + | assert_eq!(entries[1].command, "second"); | |
| 163 | + | assert_eq!(entries[1].outcome, Severity::Error); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // The ring must drop the oldest rather than grow without bound or, worse, | |
| 167 | + | // silently stop recording once it is full. | |
| 168 | + | #[test] | |
| 169 | + | fn log_evicts_oldest_at_capacity() { | |
| 170 | + | let mut log = CommandLog::new(); | |
| 171 | + | for i in 0..LOG_CAPACITY + 10 { | |
| 172 | + | log.record(format!("cmd {i}"), Severity::Healthy); | |
| 173 | + | } | |
| 174 | + | let entries = log.entries(); | |
| 175 | + | assert_eq!(entries.len(), LOG_CAPACITY); | |
| 176 | + | assert_eq!(entries[0].command, "cmd 10", "oldest entries were evicted"); | |
| 177 | + | assert_eq!(entries[LOG_CAPACITY - 1].command, format!("cmd {}", LOG_CAPACITY + 9)); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // `entries()` straightens the deque; a wrapped ring must still read back in | |
| 181 | + | // order, or the pane shows the transcript spliced at the wrap point. | |
| 182 | + | #[test] | |
| 183 | + | fn entries_are_contiguous_after_wrapping() { | |
| 184 | + | let mut log = CommandLog::new(); | |
| 185 | + | for i in 0..LOG_CAPACITY * 2 { | |
| 186 | + | log.record(format!("cmd {i}"), Severity::Healthy); | |
| 187 | + | } | |
| 188 | + | let entries = log.entries(); | |
| 189 | + | assert_eq!(entries.len(), LOG_CAPACITY, "no entries lost to the wrap"); | |
| 190 | + | for (offset, entry) in entries.iter().enumerate() { | |
| 191 | + | assert_eq!(entry.command, format!("cmd {}", LOG_CAPACITY + offset)); | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | } |
| @@ -1,0 +1,47 @@ | |||
| 1 | + | //! `alloy` — the Alloy Console. | |
| 2 | + | //! | |
| 3 | + | //! One binary, one subcommand per system surface, all sharing the `alloy_tui` | |
| 4 | + | //! design system and the shell in [`shell`]. See docs/CONSOLE.md for the | |
| 5 | + | //! subcommand roster and the roadmap; `net` is the first of them. | |
| 6 | + | //! | |
| 7 | + | //! <!-- wiki: alloy-console --> | |
| 8 | + | ||
| 9 | + | mod cli; | |
| 10 | + | mod net; | |
| 11 | + | mod shell; | |
| 12 | + | mod theme; | |
| 13 | + | ||
| 14 | + | use anyhow::Result; | |
| 15 | + | use clap::{Parser, Subcommand}; | |
| 16 | + | ||
| 17 | + | use crate::cli::CommandLog; | |
| 18 | + | ||
| 19 | + | #[derive(Parser)] | |
| 20 | + | #[command(name = "alloy", about = "Alloy Console", version)] | |
| 21 | + | struct Cli { | |
| 22 | + | /// Theme id to render in (default: Akari, matched to the terminal background) | |
| 23 | + | #[arg(long, global = true)] | |
| 24 | + | theme: Option<String>, | |
| 25 | + | ||
| 26 | + | #[command(subcommand)] | |
| 27 | + | command: Command, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[derive(Subcommand)] | |
| 31 | + | enum Command { | |
| 32 | + | /// Network interfaces and connections | |
| 33 | + | Net, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | fn main() -> Result<()> { | |
| 37 | + | let cli = Cli::parse(); | |
| 38 | + | let theme = theme::load(cli.theme.as_deref())?; | |
| 39 | + | let mut log = CommandLog::new(); | |
| 40 | + | ||
| 41 | + | match cli.command { | |
| 42 | + | Command::Net => { | |
| 43 | + | let mut view = net::NetView::new(&mut log); | |
| 44 | + | shell::run(&theme, &mut view, &mut log) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } |
| @@ -1,0 +1,539 @@ | |||
| 1 | + | //! `alloy net` — a NetworkManager front. | |
| 2 | + | //! | |
| 3 | + | //! Descended from sysop's `net.rs`, which fronted `ip` on Alpine. Alloy is | |
| 4 | + | //! Fedora, so the backend is `nmcli`; the mock-or-real detection pattern is | |
| 5 | + | //! carried over unchanged, because it is what lets the console be developed | |
| 6 | + | //! and demoed on a machine whose real network state you would rather not | |
| 7 | + | //! touch. | |
| 8 | + | ||
| 9 | + | use alloy_tui::{AlloyBlock, AlloyList, Hint, Severity, Theme, hint, text}; | |
| 10 | + | use anyhow::Result; | |
| 11 | + | use ratatui::Frame; | |
| 12 | + | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 13 | + | use ratatui::layout::Rect; | |
| 14 | + | use ratatui::text::{Line, Span}; | |
| 15 | + | ||
| 16 | + | use crate::cli::{CommandLog, Invocation}; | |
| 17 | + | use crate::shell::{Flow, View, block_title}; | |
| 18 | + | ||
| 19 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 20 | + | pub enum Kind { | |
| 21 | + | Wired, | |
| 22 | + | Wireless, | |
| 23 | + | Loopback, | |
| 24 | + | Other, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | impl Kind { | |
| 28 | + | /// Map NetworkManager's device type. NM's vocabulary is open-ended | |
| 29 | + | /// (`bridge`, `tun`, `wireguard`, `bond`, ...); everything Alloy does not | |
| 30 | + | /// name specifically is `Other` and still listed, because hiding an | |
| 31 | + | /// interface the user can see in `nmcli` would make the console look | |
| 32 | + | /// broken. | |
| 33 | + | fn from_nm(raw: &str) -> Self { | |
| 34 | + | match raw { | |
| 35 | + | "ethernet" => Kind::Wired, | |
| 36 | + | "wifi" => Kind::Wireless, | |
| 37 | + | "loopback" => Kind::Loopback, | |
| 38 | + | _ => Kind::Other, | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | const fn label(self) -> &'static str { | |
| 43 | + | match self { | |
| 44 | + | Kind::Wired => "wired", | |
| 45 | + | Kind::Wireless => "wireless", | |
| 46 | + | Kind::Loopback => "loopback", | |
| 47 | + | Kind::Other => "other", | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 53 | + | pub enum State { | |
| 54 | + | Connected, | |
| 55 | + | Disconnected, | |
| 56 | + | Unavailable, | |
| 57 | + | Unmanaged, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | impl State { | |
| 61 | + | /// NM reports `GENERAL.STATE` as `"100 (connected)"`. The numeric code is | |
| 62 | + | /// the stable part — the parenthesized text is localized — so parse the | |
| 63 | + | /// number and ignore the rest. | |
| 64 | + | fn from_nm(raw: &str) -> Self { | |
| 65 | + | let code = raw | |
| 66 | + | .split_whitespace() | |
| 67 | + | .next() | |
| 68 | + | .and_then(|n| n.parse::<u16>().ok()) | |
| 69 | + | .unwrap_or(0); | |
| 70 | + | match code { | |
| 71 | + | 100 => State::Connected, | |
| 72 | + | 30 => State::Disconnected, | |
| 73 | + | 20 => State::Unavailable, | |
| 74 | + | _ => State::Unmanaged, | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | const fn label(self) -> &'static str { | |
| 79 | + | match self { | |
| 80 | + | State::Connected => "connected", | |
| 81 | + | State::Disconnected => "disconnected", | |
| 82 | + | State::Unavailable => "unavailable", | |
| 83 | + | State::Unmanaged => "unmanaged", | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | const fn severity(self) -> Severity { | |
| 88 | + | match self { | |
| 89 | + | State::Connected => Severity::Healthy, | |
| 90 | + | State::Disconnected => Severity::Warn, | |
| 91 | + | State::Unavailable | State::Unmanaged => Severity::Info, | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | #[derive(Debug, Clone)] | |
| 97 | + | pub struct Interface { | |
| 98 | + | pub name: String, | |
| 99 | + | pub kind: Kind, | |
| 100 | + | pub state: State, | |
| 101 | + | pub connection: Option<String>, | |
| 102 | + | pub addresses: Vec<String>, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// A source of interface state. | |
| 106 | + | pub trait Backend { | |
| 107 | + | fn name(&self) -> &'static str; | |
| 108 | + | fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>>; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Pick a backend: the real one when `nmcli` answers, the mock otherwise. | |
| 112 | + | /// | |
| 113 | + | /// The probe is a real invocation rather than a `which` check — an `nmcli` | |
| 114 | + | /// binary that cannot reach a NetworkManager daemon (a container, a live ISO | |
| 115 | + | /// mid-boot) is worse than no `nmcli` at all, and only running it reveals that. | |
| 116 | + | pub fn detect() -> Box<dyn Backend> { | |
| 117 | + | if Invocation::new("nmcli").arg("--version").probe() { | |
| 118 | + | Box::new(NmCli) | |
| 119 | + | } else { | |
| 120 | + | Box::new(Mock) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | pub struct NmCli; | |
| 125 | + | ||
| 126 | + | impl NmCli { | |
| 127 | + | /// One invocation for the whole device table. `nmcli device show` with no | |
| 128 | + | /// device dumps every device, which keeps the log pane to a single | |
| 129 | + | /// copy-pasteable line instead of one per interface. | |
| 130 | + | fn invocation() -> Invocation { | |
| 131 | + | Invocation::new("nmcli").args([ | |
| 132 | + | "-t", | |
| 133 | + | "-f", | |
| 134 | + | "GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS", | |
| 135 | + | "device", | |
| 136 | + | "show", | |
| 137 | + | ]) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | impl Backend for NmCli { | |
| 142 | + | fn name(&self) -> &'static str { | |
| 143 | + | "nmcli" | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> { | |
| 147 | + | Ok(parse_device_show(&Self::invocation().run(log)?)) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Fixed sample state, for machines without NetworkManager. | |
| 152 | + | pub struct Mock; | |
| 153 | + | ||
| 154 | + | impl Backend for Mock { | |
| 155 | + | fn name(&self) -> &'static str { | |
| 156 | + | "mock" | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> { | |
| 160 | + | // Logged as a comment rather than a command: the pane's contract is | |
| 161 | + | // that every line is something you could run, and there is nothing to | |
| 162 | + | // run here. The `#` marks it as commentary in the same way a shell | |
| 163 | + | // would. | |
| 164 | + | log.record("# no NetworkManager; showing mock interfaces", Severity::Warn); | |
| 165 | + | Ok(vec![ | |
| 166 | + | Interface { | |
| 167 | + | name: "wlp1s0".into(), | |
| 168 | + | kind: Kind::Wireless, | |
| 169 | + | state: State::Connected, | |
| 170 | + | connection: Some("Example Network".into()), | |
| 171 | + | addresses: vec!["192.168.1.42/24".into()], | |
| 172 | + | }, | |
| 173 | + | Interface { | |
| 174 | + | name: "enp2s0".into(), | |
| 175 | + | kind: Kind::Wired, | |
| 176 | + | state: State::Disconnected, | |
| 177 | + | connection: None, | |
| 178 | + | addresses: vec![], | |
| 179 | + | }, | |
| 180 | + | Interface { | |
| 181 | + | name: "lo".into(), | |
| 182 | + | kind: Kind::Loopback, | |
| 183 | + | state: State::Unmanaged, | |
| 184 | + | connection: None, | |
| 185 | + | addresses: vec!["127.0.0.1/8".into()], | |
| 186 | + | }, | |
| 187 | + | ]) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// Parse `nmcli -t -f ... device show` output. | |
| 192 | + | /// | |
| 193 | + | /// Terse mode emits `KEY:value` per line with devices separated by blank | |
| 194 | + | /// lines. Keys never contain a colon, so splitting on the first one is | |
| 195 | + | /// unambiguous and values are taken verbatim. | |
| 196 | + | /// | |
| 197 | + | /// That verbatim part is worth stating, because nmcli's terse *tabular* output | |
| 198 | + | /// (`device status`) does escape colons as `\:` — it has to, since its fields | |
| 199 | + | /// are colon-separated. Multiline output does not, and an IPv6 address here | |
| 200 | + | /// arrives as plain `fe80::1`. Unescaping it anyway would corrupt any value | |
| 201 | + | /// containing a legitimate backslash. | |
| 202 | + | fn parse_device_show(raw: &str) -> Vec<Interface> { | |
| 203 | + | let mut interfaces = Vec::new(); | |
| 204 | + | let mut current: Option<Interface> = None; | |
| 205 | + | ||
| 206 | + | for line in raw.lines() { | |
| 207 | + | let line = line.trim_end(); | |
| 208 | + | if line.is_empty() { | |
| 209 | + | continue; | |
| 210 | + | } | |
| 211 | + | let Some((key, value)) = line.split_once(':') else { | |
| 212 | + | continue; | |
| 213 | + | }; | |
| 214 | + | let value = value.to_string(); | |
| 215 | + | ||
| 216 | + | // A device block starts at GENERAL.DEVICE. Keying off that rather than | |
| 217 | + | // the blank-line separator means a missing separator merges nothing: | |
| 218 | + | // the next DEVICE always opens a new record. | |
| 219 | + | if key == "GENERAL.DEVICE" { | |
| 220 | + | if let Some(iface) = current.take() { | |
| 221 | + | interfaces.push(iface); | |
| 222 | + | } | |
| 223 | + | current = Some(Interface { | |
| 224 | + | name: value, | |
| 225 | + | kind: Kind::Other, | |
| 226 | + | state: State::Unmanaged, | |
| 227 | + | connection: None, | |
| 228 | + | addresses: Vec::new(), | |
| 229 | + | }); | |
| 230 | + | continue; | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | let Some(iface) = current.as_mut() else { | |
| 234 | + | continue; | |
| 235 | + | }; | |
| 236 | + | ||
| 237 | + | match key { | |
| 238 | + | "GENERAL.TYPE" => iface.kind = Kind::from_nm(&value), | |
| 239 | + | "GENERAL.STATE" => iface.state = State::from_nm(&value), | |
| 240 | + | // NM writes `--` for an absent connection, which would otherwise | |
| 241 | + | // render as a connection literally named "--". | |
| 242 | + | "GENERAL.CONNECTION" if value != "--" && !value.is_empty() => { | |
| 243 | + | iface.connection = Some(value); | |
| 244 | + | } | |
| 245 | + | // Address keys are indexed: IP4.ADDRESS[1], IP6.ADDRESS[2], ... | |
| 246 | + | _ if !value.is_empty() | |
| 247 | + | && (key.starts_with("IP4.ADDRESS") || key.starts_with("IP6.ADDRESS")) => | |
| 248 | + | { | |
| 249 | + | iface.addresses.push(value); | |
| 250 | + | } | |
| 251 | + | _ => {} | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | interfaces.extend(current); | |
| 256 | + | interfaces | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// The `alloy net` screen. | |
| 260 | + | pub struct NetView { | |
| 261 | + | backend: Box<dyn Backend>, | |
| 262 | + | interfaces: Vec<Interface>, | |
| 263 | + | selected: usize, | |
| 264 | + | error: Option<String>, | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | impl NetView { | |
| 268 | + | pub fn new(log: &mut CommandLog) -> Self { | |
| 269 | + | let mut view = Self { | |
| 270 | + | backend: detect(), | |
| 271 | + | interfaces: Vec::new(), | |
| 272 | + | selected: 0, | |
| 273 | + | error: None, | |
| 274 | + | }; | |
| 275 | + | view.refresh(log); | |
| 276 | + | view | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | fn refresh(&mut self, log: &mut CommandLog) { | |
| 280 | + | match self.backend.list(log) { | |
| 281 | + | Ok(interfaces) => { | |
| 282 | + | self.interfaces = interfaces; | |
| 283 | + | // Refresh can shrink the list (an interface went away); keep | |
| 284 | + | // the cursor on a row that exists. | |
| 285 | + | self.selected = self.selected.min(self.interfaces.len().saturating_sub(1)); | |
| 286 | + | self.error = None; | |
| 287 | + | } | |
| 288 | + | Err(err) => self.error = Some(err.to_string()), | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | fn move_selection(&mut self, delta: isize) { | |
| 293 | + | if self.interfaces.is_empty() { | |
| 294 | + | return; | |
| 295 | + | } | |
| 296 | + | let last = self.interfaces.len() - 1; | |
| 297 | + | self.selected = match delta { | |
| 298 | + | d if d < 0 => self.selected.saturating_sub(d.unsigned_abs()), | |
| 299 | + | d => (self.selected + d as usize).min(last), | |
| 300 | + | }; | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | fn row<'a>(&self, theme: &Theme, iface: &'a Interface) -> Line<'a> { | |
| 304 | + | let address = iface | |
| 305 | + | .addresses | |
| 306 | + | .first() | |
| 307 | + | .cloned() | |
| 308 | + | .or_else(|| iface.connection.clone()) | |
| 309 | + | .unwrap_or_default(); | |
| 310 | + | ||
| 311 | + | Line::from(vec![ | |
| 312 | + | text::bold(theme, format!("{:<12}", iface.name)), | |
| 313 | + | text::muted(theme, format!("{:<10}", iface.kind.label())), | |
| 314 | + | Span::styled( | |
| 315 | + | format!("{:<14}", iface.state.label()), | |
| 316 | + | iface.state.severity().style(theme), | |
| 317 | + | ), | |
| 318 | + | text::secondary(theme, address), | |
| 319 | + | ]) | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | impl View for NetView { | |
| 324 | + | fn title(&self) -> String { | |
| 325 | + | format!("network ({})", self.backend.name()) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | fn hints(&self) -> Vec<Hint> { | |
| 329 | + | vec![hint("j/k", "select"), hint("r", "refresh")] | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | fn status(&self) -> Option<(Severity, String)> { | |
| 333 | + | self.error | |
| 334 | + | .as_ref() | |
| 335 | + | .map(|message| (Severity::Error, message.clone())) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 339 | + | let block = AlloyBlock::new(theme) | |
| 340 | + | .focused(true) | |
| 341 | + | .build() | |
| 342 | + | .title(block_title(&self.title())); | |
| 343 | + | let inner = block.inner(area); | |
| 344 | + | frame.render_widget(block, area); | |
| 345 | + | ||
| 346 | + | if self.interfaces.is_empty() { | |
| 347 | + | frame.render_widget( | |
| 348 | + | Line::from(text::muted(theme, "no interfaces")), | |
| 349 | + | inner, | |
| 350 | + | ); | |
| 351 | + | return; | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | let rows: Vec<Line> = self | |
| 355 | + | .interfaces | |
| 356 | + | .iter() | |
| 357 | + | .map(|iface| self.row(theme, iface)) | |
| 358 | + | .collect(); | |
| 359 | + | frame.render_widget(AlloyList::new(theme, rows).selected(Some(self.selected)), inner); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow { | |
| 363 | + | match key.code { | |
| 364 | + | KeyCode::Char('j') | KeyCode::Down => self.move_selection(1), | |
| 365 | + | KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1), | |
| 366 | + | KeyCode::Char('r') => self.refresh(log), | |
| 367 | + | _ => {} | |
| 368 | + | } | |
| 369 | + | Flow::Continue | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | #[cfg(test)] | |
| 374 | + | mod tests { | |
| 375 | + | use super::*; | |
| 376 | + | ||
| 377 | + | // Captured verbatim from `nmcli -t -f GENERAL.DEVICE,GENERAL.TYPE, | |
| 378 | + | // GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS device show` on | |
| 379 | + | // a NetworkManager 1.5x box, hostname and SSID aside. Kept real rather | |
| 380 | + | // than tidied: the awkward parts below (nested parens in the state, an | |
| 381 | + | // empty trailing connection, a bare `::1`) are all things nmcli actually | |
| 382 | + | // emits, and a hand-written fixture is where a parser goes to pass tests | |
| 383 | + | // it would fail in production. | |
| 384 | + | const SAMPLE: &str = "\ | |
| 385 | + | GENERAL.DEVICE:wlp192s0 | |
| 386 | + | GENERAL.TYPE:wifi | |
| 387 | + | GENERAL.STATE:100 (connected) | |
| 388 | + | GENERAL.CONNECTION:Example Network | |
| 389 | + | IP4.ADDRESS[1]:192.168.0.16/24 | |
| 390 | + | IP6.ADDRESS[1]:fe80::59a3:bc22:d95f:c06b/64 | |
| 391 | + | ||
| 392 | + | GENERAL.DEVICE:tailscale0 | |
| 393 | + | GENERAL.TYPE:tun | |
| 394 | + | GENERAL.STATE:100 (connected (externally)) | |
| 395 | + | GENERAL.CONNECTION:tailscale0 | |
| 396 | + | IP4.ADDRESS[1]:100.103.89.95/32 | |
| 397 | + | IP6.ADDRESS[1]:fd7a:115c:a1e0::af3b:595f/128 | |
| 398 | + | IP6.ADDRESS[2]:fe80::ccae:60fc:a1c5:3b13/64 | |
| 399 | + | ||
| 400 | + | GENERAL.DEVICE:lo | |
| 401 | + | GENERAL.TYPE:loopback | |
| 402 | + | GENERAL.STATE:100 (connected (externally)) | |
| 403 | + | GENERAL.CONNECTION:lo | |
| 404 | + | IP4.ADDRESS[1]:127.0.0.1/8 | |
| 405 | + | IP6.ADDRESS[1]:::1/128 | |
| 406 | + | ||
| 407 | + | GENERAL.DEVICE:p2p-dev-wlp192s0 | |
| 408 | + | GENERAL.TYPE:wifi-p2p | |
| 409 | + | GENERAL.STATE:30 (disconnected) | |
| 410 | + | GENERAL.CONNECTION: | |
| 411 | + | "; | |
| 412 | + | ||
| 413 | + | #[test] | |
| 414 | + | fn parses_every_device_block() { | |
| 415 | + | let ifaces = parse_device_show(SAMPLE); | |
| 416 | + | assert_eq!(ifaces.len(), 4); | |
| 417 | + | assert_eq!(ifaces[0].name, "wlp192s0"); | |
| 418 | + | assert_eq!(ifaces[0].kind, Kind::Wireless); | |
| 419 | + | assert_eq!(ifaces[0].state, State::Connected); | |
| 420 | + | assert_eq!(ifaces[0].connection.as_deref(), Some("Example Network")); | |
| 421 | + | assert_eq!( | |
| 422 | + | ifaces[3].name, "p2p-dev-wlp192s0", | |
| 423 | + | "the last block is not dropped for want of a trailing blank line" | |
| 424 | + | ); | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | // IPv6 values carry colons and arrive unescaped, so the split has to be on | |
| 428 | + | // the *first* colon only. Splitting on every colon shows `fe80` as the | |
| 429 | + | // address; `::1/128` is the case that breaks a naive rsplit as well. | |
| 430 | + | #[test] | |
| 431 | + | fn ipv6_addresses_survive_the_key_value_split() { | |
| 432 | + | let ifaces = parse_device_show(SAMPLE); | |
| 433 | + | assert_eq!( | |
| 434 | + | ifaces[0].addresses, | |
| 435 | + | vec!["192.168.0.16/24", "fe80::59a3:bc22:d95f:c06b/64"] | |
| 436 | + | ); | |
| 437 | + | assert_eq!( | |
| 438 | + | ifaces[1].addresses, | |
| 439 | + | vec![ | |
| 440 | + | "100.103.89.95/32", | |
| 441 | + | "fd7a:115c:a1e0::af3b:595f/128", | |
| 442 | + | "fe80::ccae:60fc:a1c5:3b13/64", | |
| 443 | + | ], | |
| 444 | + | "every indexed address is collected, not just the first" | |
| 445 | + | ); | |
| 446 | + | assert_eq!(ifaces[2].addresses[1], "::1/128"); | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | // NM leaves the connection field empty for a device with no active | |
| 450 | + | // connection. Empty must read as absent, not as a connection named "". | |
| 451 | + | #[test] | |
| 452 | + | fn treats_an_empty_connection_as_absent() { | |
| 453 | + | let ifaces = parse_device_show(SAMPLE); | |
| 454 | + | assert_eq!(ifaces[3].connection, None); | |
| 455 | + | assert!(ifaces[3].addresses.is_empty()); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // `--` is NM's other placeholder for "none", used where a field is | |
| 459 | + | // tabulated rather than left blank. | |
| 460 | + | #[test] | |
| 461 | + | fn treats_double_dash_connection_as_absent() { | |
| 462 | + | let raw = "GENERAL.DEVICE:enp2s0\nGENERAL.TYPE:ethernet\nGENERAL.CONNECTION:--\n"; | |
| 463 | + | assert_eq!(parse_device_show(raw)[0].connection, None); | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | // The state field nests parentheses: "100 (connected (externally))". Only | |
| 467 | + | // the leading numeric code is stable across locales, so that is what is | |
| 468 | + | // parsed; anything reading the text would misclassify this as unmanaged. | |
| 469 | + | #[test] | |
| 470 | + | fn parses_state_from_the_numeric_code_not_the_text() { | |
| 471 | + | let ifaces = parse_device_show(SAMPLE); | |
| 472 | + | assert_eq!(ifaces[1].state, State::Connected); | |
| 473 | + | assert_eq!(ifaces[3].state, State::Disconnected); | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | #[test] | |
| 477 | + | fn empty_output_yields_no_interfaces() { | |
| 478 | + | assert!(parse_device_show("").is_empty()); | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | // NM's device-type vocabulary is open-ended; an unknown type must still | |
| 482 | + | // list rather than vanish. | |
| 483 | + | #[test] | |
| 484 | + | fn unknown_device_types_are_listed_as_other() { | |
| 485 | + | let raw = "GENERAL.DEVICE:wg0\nGENERAL.TYPE:wireguard\nGENERAL.STATE:100 (connected)\n"; | |
| 486 | + | let ifaces = parse_device_show(raw); | |
| 487 | + | assert_eq!(ifaces.len(), 1); | |
| 488 | + | assert_eq!(ifaces[0].kind, Kind::Other); | |
| 489 | + | assert_eq!(ifaces[0].state, State::Connected); | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | // Selection must survive the list shrinking under it — an interface going | |
| 493 | + | // away while the cursor sits on the last row. | |
| 494 | + | #[test] | |
| 495 | + | fn selection_clamps_when_the_list_shrinks() { | |
| 496 | + | let mut view = NetView { | |
| 497 | + | backend: Box::new(Mock), | |
| 498 | + | interfaces: Vec::new(), | |
| 499 | + | selected: 0, | |
| 500 | + | error: None, |
Lines truncated
| @@ -1,0 +1,106 @@ | |||
| 1 | + | //! The console shell: the frame, the event loop, and the reserved-key handling | |
| 2 | + | //! every `alloy` subcommand shares. | |
| 3 | + | //! | |
| 4 | + | //! docs/CONSOLE.md commits each subcommand to the same navigation model, the | |
| 5 | + | //! same status area, and the same command-log pane. Those live here rather | |
| 6 | + | //! than in each view, so a new subcommand supplies only its body and its | |
| 7 | + | //! hints and inherits the rest. | |
| 8 | + | ||
| 9 | + | use alloy_tui::keys::{Action, classify}; | |
| 10 | + | use alloy_tui::{AlloyLog, AlloyStatusBar, Hint, Severity, Theme, hint, layout}; | |
| 11 | + | use anyhow::Result; | |
| 12 | + | use ratatui::Frame; | |
| 13 | + | use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind}; | |
| 14 | + | use ratatui::layout::Rect; | |
| 15 | + | ||
| 16 | + | use crate::cli::CommandLog; | |
| 17 | + | ||
| 18 | + | /// What a view wants the shell to do after handling a key. | |
| 19 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 20 | + | pub enum Flow { | |
| 21 | + | Continue, | |
| 22 | + | Exit, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// A console screen. Views own their data and their body; the shell owns the | |
| 26 | + | /// frame around it. | |
| 27 | + | pub trait View { | |
| 28 | + | /// Title for the body block. | |
| 29 | + | fn title(&self) -> String; | |
| 30 | + | ||
| 31 | + | /// Key hints for the footer. The shell appends the reserved global hints, | |
| 32 | + | /// so a view lists only its own keys. | |
| 33 | + | fn hints(&self) -> Vec<Hint>; | |
| 34 | + | ||
| 35 | + | /// Transient status for the right end of the footer, if any. | |
| 36 | + | fn status(&self) -> Option<(Severity, String)> { | |
| 37 | + | None | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Draw the body into `area`. | |
| 41 | + | fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme); | |
| 42 | + | ||
| 43 | + | /// Handle a key the shell did not claim. | |
| 44 | + | fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Run a view to completion: set up the terminal, loop, and restore. | |
| 48 | + | /// | |
| 49 | + | /// The terminal is restored even when the loop fails, so a backend error does | |
| 50 | + | /// not strand the user in raw mode with no echo. | |
| 51 | + | pub fn run(theme: &Theme, view: &mut dyn View, log: &mut CommandLog) -> Result<()> { | |
| 52 | + | let mut terminal = ratatui::init(); | |
| 53 | + | let result = event_loop(&mut terminal, theme, view, log); | |
| 54 | + | ratatui::restore(); | |
| 55 | + | result | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | fn event_loop( | |
| 59 | + | terminal: &mut ratatui::DefaultTerminal, | |
| 60 | + | theme: &Theme, | |
| 61 | + | view: &mut dyn View, | |
| 62 | + | log: &mut CommandLog, | |
| 63 | + | ) -> Result<()> { | |
| 64 | + | loop { | |
| 65 | + | terminal.draw(|frame| draw(frame, theme, view, log))?; | |
| 66 | + | ||
| 67 | + | let Event::Key(key) = event::read()? else { | |
| 68 | + | continue; | |
| 69 | + | }; | |
| 70 | + | // Windows terminals report press *and* release for every key; acting on | |
| 71 | + | // both runs each action twice. | |
| 72 | + | if key.kind != KeyEventKind::Press { | |
| 73 | + | continue; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | match classify(key) { | |
| 77 | + | Action::Quit | Action::Cancel => return Ok(()), | |
| 78 | + | _ => { | |
| 79 | + | if view.handle(key, log) == Flow::Exit { | |
| 80 | + | return Ok(()); | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | fn draw(frame: &mut Frame, theme: &Theme, view: &dyn View, log: &mut CommandLog) { | |
| 88 | + | let areas = layout::console(frame.area()); | |
| 89 | + | ||
| 90 | + | view.render(frame, areas.body, theme); | |
| 91 | + | frame.render_widget(AlloyLog::new(theme, log.entries()), areas.log); | |
| 92 | + | ||
| 93 | + | let mut hints = view.hints(); | |
| 94 | + | hints.push(hint("q", "quit")); | |
| 95 | + | let mut status_bar = AlloyStatusBar::new(theme, hints); | |
| 96 | + | if let Some((severity, message)) = view.status() { | |
| 97 | + | status_bar = status_bar.status(severity, message); | |
| 98 | + | } | |
| 99 | + | frame.render_widget(status_bar, areas.footer); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Title text for a view's body block, padded so it does not sit flush against | |
| 103 | + | /// the border corner. | |
| 104 | + | pub fn block_title(title: &str) -> String { | |
| 105 | + | format!(" {title} ") | |
| 106 | + | } |
| @@ -1,0 +1,109 @@ | |||
| 1 | + | //! Resolving which theme the console renders in, and where it loads from. | |
| 2 | + | //! | |
| 3 | + | //! docs/TOKENS.md: no hex values are hard-coded in Rust. There is deliberately | |
| 4 | + | //! no built-in fallback palette here — a missing or malformed theme is an | |
| 5 | + | //! error the user sees, not something the console papers over by rendering in | |
| 6 | + | //! colors that exist nowhere in the theme files. | |
| 7 | + | ||
| 8 | + | use std::path::PathBuf; | |
| 9 | + | ||
| 10 | + | use alloy_tui::Theme; | |
| 11 | + | use anyhow::{Context, Result}; | |
| 12 | + | ||
| 13 | + | /// Default light theme (docs/TOKENS.md). | |
| 14 | + | pub const DEFAULT_LIGHT: &str = "akari-dawn"; | |
| 15 | + | ||
| 16 | + | /// Default dark theme (docs/TOKENS.md). | |
| 17 | + | pub const DEFAULT_DARK: &str = "akari-night"; | |
| 18 | + | ||
| 19 | + | /// Theme search path, highest precedence first: the user's own themes, then | |
| 20 | + | /// the ones the image ships, then the in-repo checkout when running from a dev | |
| 21 | + | /// tree. The `bool` is theme-common's is-custom flag. | |
| 22 | + | fn search_path() -> Vec<(PathBuf, bool)> { | |
| 23 | + | let mut dirs = Vec::new(); | |
| 24 | + | ||
| 25 | + | if let Some(config) = dirs_config_home() { | |
| 26 | + | dirs.push((config.join("alloy").join("themes"), true)); | |
| 27 | + | } | |
| 28 | + | dirs.push((PathBuf::from("/usr/share/alloy/themes"), false)); | |
| 29 | + | ||
| 30 | + | // Dev fallback: crates/alloy -> alloy -> Code, then MNW/shared/themes. | |
| 31 | + | // Only resolves when that tree exists, so it is inert on an installed | |
| 32 | + | // system. | |
| 33 | + | if let Some(dev) = theme_common::dev_themes_dir(env!("CARGO_MANIFEST_DIR").as_ref(), 3) { | |
| 34 | + | dirs.push((dev, false)); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | dirs | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | fn dirs_config_home() -> Option<PathBuf> { | |
| 41 | + | // XDG_CONFIG_HOME wins when set and absolute; the spec says a relative | |
| 42 | + | // value is invalid and must be ignored rather than resolved against cwd. | |
| 43 | + | if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { | |
| 44 | + | let path = PathBuf::from(xdg); | |
| 45 | + | if path.is_absolute() { | |
| 46 | + | return Some(path); | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Load a theme by id, or the mode-appropriate default when `id` is `None`. | |
| 53 | + | pub fn load(id: Option<&str>) -> Result<Theme> { | |
| 54 | + | let id = id.map(str::to_string).unwrap_or_else(default_theme_id); | |
| 55 | + | let dirs = search_path(); | |
| 56 | + | ||
| 57 | + | let colors = theme_common::load_theme(&dirs, &id) | |
| 58 | + | .map_err(anyhow::Error::msg) | |
| 59 | + | .with_context(|| { | |
| 60 | + | let searched: Vec<String> = dirs | |
| 61 | + | .iter() | |
| 62 | + | .map(|(path, _)| path.display().to_string()) | |
| 63 | + | .collect(); | |
| 64 | + | format!("loading theme `{id}` (searched: {})", searched.join(", ")) | |
| 65 | + | })?; | |
| 66 | + | ||
| 67 | + | Theme::from_theme(&colors).with_context(|| format!("theme `{id}` is incomplete")) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Guess whether the terminal is dark, and pick the matching Akari default. | |
| 71 | + | /// | |
| 72 | + | /// `COLORFGBG` is the only signal available without writing an OSC query to | |
| 73 | + | /// the terminal and waiting on a reply, which is not worth doing before the | |
| 74 | + | /// first frame. Its background field is a color index: 0-6 and 8 are the dark | |
| 75 | + | /// ones. When the variable is absent or unparseable, light is the documented | |
| 76 | + | /// default. | |
| 77 | + | fn default_theme_id() -> String { | |
| 78 | + | let dark = std::env::var("COLORFGBG") | |
| 79 | + | .ok() | |
| 80 | + | .and_then(|value| { | |
| 81 | + | value | |
| 82 | + | .rsplit(';') | |
| 83 | + | .next() | |
| 84 | + | .and_then(|bg| bg.trim().parse::<u8>().ok()) | |
| 85 | + | }) | |
| 86 | + | .is_some_and(|bg| bg <= 6 || bg == 8); | |
| 87 | + | ||
| 88 | + | if dark { DEFAULT_DARK.into() } else { DEFAULT_LIGHT.into() } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | #[cfg(test)] | |
| 92 | + | mod tests { | |
| 93 | + | use super::*; | |
| 94 | + | ||
| 95 | + | // The console ships against these two ids; a rename in MNW/shared/themes | |
| 96 | + | // that misses this crate should fail here rather than at first launch. | |
| 97 | + | #[test] | |
| 98 | + | fn shipped_defaults_load_and_resolve() { | |
| 99 | + | for id in [DEFAULT_LIGHT, DEFAULT_DARK] { | |
| 100 | + | let theme = load(Some(id)); | |
| 101 | + | assert!(theme.is_ok(), "default theme `{id}` failed to load: {theme:?}"); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | #[test] | |
| 106 | + | fn a_missing_theme_is_an_error_not_a_fallback() { | |
| 107 | + | assert!(load(Some("no-such-theme")).is_err()); | |
| 108 | + | } | |
| 109 | + | } |
| @@ -1,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 | + | } |
| @@ -1,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 | + | } |
| @@ -1,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 | + | } |
| @@ -1,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 | + | } |
| @@ -1,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 | + | } |