max / alloy
17 files changed,
+8 insertions,
-1879 deletions
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | 7 | Alloy is licensed under the GNU General Public License v3.0 or later. See [LICENSE](LICENSE). Code contributed to this repository must be compatible with GPLv3+. Any future code outside this license (vendored dependencies, etc.) must carry compatible terms. | |
| 8 | 8 | ||
| 9 | - | One crate is deliberately exempt: `crates/alloy_tui` is MIT ([its own LICENSE](crates/alloy_tui/LICENSE)), because it is the reusable design system rather than the application. Publishing it permissively lets other terminal UIs adopt Alloy's look without inheriting copyleft, and lets non-GPL siblings link it. Contributions to that crate are MIT; everything else in the repo is GPLv3+. | |
| 9 | + | The design system is deliberately not in this repo: [`alloy_tui`](https://git.sr.ht/~maxmj/alloy_tui) is a separate MIT crate, consumed here from crates.io. It is the reusable library rather than the application, so publishing it permissively lets other terminal UIs adopt Alloy's look without inheriting copyleft, and lets non-GPL siblings link it. Everything in this repo is GPLv3+. | |
| 10 | 10 | ||
| 11 | 11 | ## Project state | |
| 12 | 12 |
| @@ -33,6 +33,8 @@ | |||
| 33 | 33 | [[package]] | |
| 34 | 34 | name = "alloy_tui" | |
| 35 | 35 | version = "0.1.0" | |
| 36 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 37 | + | checksum = "bb1ca159e66b17d1597702b240d0fa605e2591a52b79953832703690b6e190eb" | |
| 36 | 38 | dependencies = [ | |
| 37 | 39 | "makeover", | |
| 38 | 40 | "ratatui", |
| @@ -12,10 +12,10 @@ | |||
| 12 | 12 | ||
| 13 | 13 | ## License | |
| 14 | 14 | ||
| 15 | - | GPLv3-or-later, with one exception: the `alloy_tui` crate is MIT, so other | |
| 16 | - | projects can build terminal UIs on the design system without taking on | |
| 17 | - | copyleft. See [`LICENSE`](LICENSE), [`crates/alloy_tui/LICENSE`](crates/alloy_tui/LICENSE), | |
| 18 | - | and [`CONTRIBUTING.md`](CONTRIBUTING.md). | |
| 15 | + | GPLv3-or-later. The design system it renders through, | |
| 16 | + | [`alloy_tui`](https://git.sr.ht/~maxmj/alloy_tui), lives in its own repo under | |
| 17 | + | MIT, so other projects can build terminal UIs on it without taking on copyleft. | |
| 18 | + | See [`LICENSE`](LICENSE) and [`CONTRIBUTING.md`](CONTRIBUTING.md). | |
| 19 | 19 | ||
| 20 | 20 | ## Docs | |
| 21 | 21 |
| @@ -13,7 +13,7 @@ | |||
| 13 | 13 | path = "src/main.rs" | |
| 14 | 14 | ||
| 15 | 15 | [dependencies] | |
| 16 | - | alloy_tui = { path = "../alloy_tui" } | |
| 16 | + | alloy_tui = "0.1.0" | |
| 17 | 17 | anyhow = "1" | |
| 18 | 18 | clap = { version = "4", features = ["derive"] } | |
| 19 | 19 | ratatui.workspace = true |
| @@ -1,13 +1,0 @@ | |||
| 1 | - | [package] | |
| 2 | - | name = "alloy_tui" | |
| 3 | - | version = "0.1.0" | |
| 4 | - | description = "Alloy design system: makeover intents rendered as ratatui Color/Style, plus themed widgets for the alloy console and siblings." | |
| 5 | - | edition.workspace = true | |
| 6 | - | rust-version.workspace = true | |
| 7 | - | license = "MIT" | |
| 8 | - | repository.workspace = true | |
| 9 | - | authors.workspace = true | |
| 10 | - | ||
| 11 | - | [dependencies] | |
| 12 | - | ratatui.workspace = true | |
| 13 | - | makeover.workspace = true |
| @@ -1,21 +1,0 @@ | |||
| 1 | - | MIT License | |
| 2 | - | ||
| 3 | - | Copyright (c) 2026 Make Creative, LLC | |
| 4 | - | ||
| 5 | - | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | - | of this software and associated documentation files (the "Software"), to deal | |
| 7 | - | in the Software without restriction, including without limitation the rights | |
| 8 | - | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | - | copies of the Software, and to permit persons to whom the Software is | |
| 10 | - | furnished to do so, subject to the following conditions: | |
| 11 | - | ||
| 12 | - | The above copyright notice and this permission notice shall be included in all | |
| 13 | - | copies or substantial portions of the Software. | |
| 14 | - | ||
| 15 | - | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | - | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | - | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | - | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | - | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | - | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | - | SOFTWARE. |
| @@ -1,51 +1,0 @@ | |||
| 1 | - | # alloy_tui | |
| 2 | - | ||
| 3 | - | Alloy's design system for [ratatui](https://ratatui.rs): themed widgets, a | |
| 4 | - | reserved keymap, and a focus ring, driven by | |
| 5 | - | [makeover](https://crates.io/crates/makeover) themes. | |
| 6 | - | ||
| 7 | - | Extracted from the Alloy console so other terminal UIs can share one look | |
| 8 | - | rather than each re-deriving colors from a palette. | |
| 9 | - | ||
| 10 | - | ## What it gives you | |
| 11 | - | ||
| 12 | - | - **`Theme`** — a makeover `.toml` theme resolved into ratatui `Color`/`Style`. | |
| 13 | - | Two Alloy-specific tokens (`border-subtle`, `border-strong`) are derived | |
| 14 | - | locally so theme files stay minimal and cross-app compatible. | |
| 15 | - | - **Widgets** — `AlloyBlock`, `AlloyList`, `AlloyTabs`, `AlloyStatusBar`, | |
| 16 | - | `AlloyModal`, `AlloyLog`, each taking a `&Theme`. | |
| 17 | - | - **`keys`** — the reserved keymap every Alloy TUI agrees on, so the same keys | |
| 18 | - | mean the same thing across tools. | |
| 19 | - | - **`focus`** — a focus ring, the piece ratatui deliberately leaves to the app. | |
| 20 | - | ||
| 21 | - | ## Color is information | |
| 22 | - | ||
| 23 | - | Chrome is tinted greyscale. Accent color lives on text, carried by `Severity`, | |
| 24 | - | so a colored thing on screen means something rather than decorating. Widgets | |
| 25 | - | enforce this; you get it by using them. | |
| 26 | - | ||
| 27 | - | ## Usage | |
| 28 | - | ||
| 29 | - | ```rust | |
| 30 | - | use alloy_tui::{AlloyStatusBar, Theme, hint}; | |
| 31 | - | ||
| 32 | - | // Resolve a makeover theme into ratatui colors. | |
| 33 | - | let dirs = vec![(makeover::bundled_themes_dir().unwrap(), false)]; | |
| 34 | - | let colors = makeover::load_theme(&dirs, "akari-night").unwrap(); | |
| 35 | - | let theme = Theme::from_theme(&colors).unwrap(); | |
| 36 | - | ||
| 37 | - | // Widgets take &Theme. | |
| 38 | - | let footer = AlloyStatusBar::new(&theme, [hint("Tab", "focus"), hint("q", "quit")]); | |
| 39 | - | ``` | |
| 40 | - | ||
| 41 | - | There is deliberately no built-in fallback palette. A missing or malformed | |
| 42 | - | theme is an error you surface, not something papered over by rendering in | |
| 43 | - | colors that exist nowhere in the theme files. | |
| 44 | - | ||
| 45 | - | ## License | |
| 46 | - | ||
| 47 | - | MIT. | |
| 48 | - | ||
| 49 | - | The rest of the [Alloy](https://git.sr.ht/~maxmj/alloy) repository is | |
| 50 | - | GPLv3-or-later. This crate is permissive on purpose: it is the reusable design | |
| 51 | - | system, not the application. |
| @@ -1,182 +1,0 @@ | |||
| 1 | - | //! The link between two panes: an elbow drawn through the gutter, joining a | |
| 2 | - | //! row on the left to the row it is paired with on the right. | |
| 3 | - | //! | |
| 4 | - | //! One connector at a time, deliberately. Drawing every pairing at once is the | |
| 5 | - | //! obvious reading of the matching-quiz idea and it does not survive contact | |
| 6 | - | //! with a terminal: lines cross, a cell can only hold one glyph, and past | |
| 7 | - | //! three or four pairs the picture is unreadable. Lighting only the focused | |
| 8 | - | //! pairing gives the same "these two are joined" reading, never crosses | |
| 9 | - | //! anything, and needs no crossing-glyph logic. | |
| 10 | - | ||
| 11 | - | use ratatui::buffer::Buffer; | |
| 12 | - | use ratatui::layout::Rect; | |
| 13 | - | use ratatui::style::Style; | |
| 14 | - | use ratatui::widgets::Widget; | |
| 15 | - | ||
| 16 | - | use crate::theme::Theme; | |
| 17 | - | ||
| 18 | - | /// A connector between `from_y` on the left and `to_y` on the right, both in | |
| 19 | - | /// absolute buffer rows. | |
| 20 | - | pub struct AlloyConnector<'a> { | |
| 21 | - | theme: &'a Theme, | |
| 22 | - | from_y: u16, | |
| 23 | - | to_y: u16, | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | impl<'a> AlloyConnector<'a> { | |
| 27 | - | pub fn new(theme: &'a Theme, from_y: u16, to_y: u16) -> Self { | |
| 28 | - | Self { theme, from_y, to_y } | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | impl Widget for AlloyConnector<'_> { | |
| 33 | - | fn render(self, area: Rect, buf: &mut Buffer) { | |
| 34 | - | // Needs a column to leave, a column to run down, and a column to | |
| 35 | - | // arrive; narrower than that and there is nothing to draw. | |
| 36 | - | if area.width < 3 || area.height == 0 { | |
| 37 | - | return; | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | let top = area.y; | |
| 41 | - | let bottom = area.y + area.height - 1; | |
| 42 | - | // A row scrolled out of its pane has nothing to point at. Clamping | |
| 43 | - | // instead would draw a connector to a row the user cannot see, which | |
| 44 | - | // reads as a link to whatever happens to be at the edge. | |
| 45 | - | if !(top..=bottom).contains(&self.from_y) || !(top..=bottom).contains(&self.to_y) { | |
| 46 | - | return; | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | let style = Style::default() | |
| 50 | - | .fg(self.theme.border_strong) | |
| 51 | - | .bg(self.theme.surface_page); | |
| 52 | - | let mid = area.x + area.width / 2; | |
| 53 | - | let last = area.x + area.width - 1; | |
| 54 | - | ||
| 55 | - | let mut put = |x: u16, y: u16, glyph: &str| { | |
| 56 | - | buf[(x, y)].set_symbol(glyph).set_style(style); | |
| 57 | - | }; | |
| 58 | - | ||
| 59 | - | if self.from_y == self.to_y { | |
| 60 | - | for x in area.x..=last { | |
| 61 | - | put(x, self.from_y, "─"); | |
| 62 | - | } | |
| 63 | - | return; | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // Leave the left pane. | |
| 67 | - | for x in area.x..mid { | |
| 68 | - | put(x, self.from_y, "─"); | |
| 69 | - | } | |
| 70 | - | // Enter the right pane. | |
| 71 | - | for x in (mid + 1)..=last { | |
| 72 | - | put(x, self.to_y, "─"); | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | let (upper, lower) = if self.from_y < self.to_y { | |
| 76 | - | (self.from_y, self.to_y) | |
| 77 | - | } else { | |
| 78 | - | (self.to_y, self.from_y) | |
| 79 | - | }; | |
| 80 | - | for y in (upper + 1)..lower { | |
| 81 | - | put(mid, y, "│"); | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | // Corners: the glyph at each end depends on which way the run turns. | |
| 85 | - | let (from_corner, to_corner) = if self.from_y < self.to_y { | |
| 86 | - | ("┐", "└") | |
| 87 | - | } else { | |
| 88 | - | ("┘", "┌") | |
| 89 | - | }; | |
| 90 | - | put(mid, self.from_y, from_corner); | |
| 91 | - | put(mid, self.to_y, to_corner); | |
| 92 | - | } | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | #[cfg(test)] | |
| 96 | - | mod tests { | |
| 97 | - | use super::*; | |
| 98 | - | use crate::theme::Mode; | |
| 99 | - | use ratatui::style::Color; | |
| 100 | - | ||
| 101 | - | fn theme() -> Theme { | |
| 102 | - | Theme { | |
| 103 | - | mode: Mode::Dark, | |
| 104 | - | surface_page: Color::Rgb(0, 0, 0), | |
| 105 | - | surface_raised: Color::Rgb(1, 1, 1), | |
| 106 | - | surface_sunken: Color::Rgb(2, 2, 2), | |
| 107 | - | surface_overlay: Color::Rgb(3, 3, 3), | |
| 108 | - | content_primary: Color::Rgb(4, 4, 4), | |
| 109 | - | content_secondary: Color::Rgb(5, 5, 5), | |
| 110 | - | content_muted: Color::Rgb(6, 6, 6), | |
| 111 | - | action_primary: Color::Rgb(7, 7, 7), | |
| 112 | - | status_danger: Color::Rgb(8, 8, 8), | |
| 113 | - | status_success: Color::Rgb(9, 9, 9), | |
| 114 | - | status_warning: Color::Rgb(10, 10, 10), | |
| 115 | - | status_info: Color::Rgb(11, 11, 11), | |
| 116 | - | line_border: Color::Rgb(12, 12, 12), | |
| 117 | - | border_subtle: Color::Rgb(13, 13, 13), | |
| 118 | - | border_strong: Color::Rgb(14, 14, 14), | |
| 119 | - | category: [Color::Rgb(15, 15, 15); 6], | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | /// Render a connector into a `width`x`height` gutter and read it back as | |
| 124 | - | /// rows of text. | |
| 125 | - | fn render(from_y: u16, to_y: u16, width: u16, height: u16) -> Vec<String> { | |
| 126 | - | let theme = theme(); | |
| 127 | - | let area = Rect::new(0, 0, width, height); | |
| 128 | - | let mut buf = Buffer::empty(area); | |
| 129 | - | AlloyConnector::new(&theme, from_y, to_y).render(area, &mut buf); | |
| 130 | - | ||
| 131 | - | (0..height) | |
| 132 | - | .map(|y| { | |
| 133 | - | (0..width) | |
| 134 | - | .map(|x| buf[(x, y)].symbol()) | |
| 135 | - | .collect::<String>() | |
| 136 | - | }) | |
| 137 | - | .collect() | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | #[test] | |
| 141 | - | fn aligned_rows_draw_a_straight_run() { | |
| 142 | - | assert_eq!(render(1, 1, 3, 3), [" ", "───", " "]); | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // Descending: leave the left pane, turn down, arrive on the lower row. | |
| 146 | - | #[test] | |
| 147 | - | fn descending_link_turns_down() { | |
| 148 | - | assert_eq!(render(0, 2, 3, 3), ["─┐ ", " │ ", " └─"]); | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | // Ascending is the mirror image. Reusing the descending corners here would | |
| 152 | - | // draw an elbow pointing the wrong way, which is the kind of thing that | |
| 153 | - | // looks fine until you see it next to its opposite. | |
| 154 | - | #[test] | |
| 155 | - | fn ascending_link_turns_up() { | |
| 156 | - | assert_eq!(render(2, 0, 3, 3), [" ┌─", " │ ", "─┘ "]); | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | #[test] | |
| 160 | - | fn adjacent_rows_need_no_vertical_run() { | |
| 161 | - | assert_eq!(render(0, 1, 3, 2), ["─┐ ", " └─"]); | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // A row scrolled out of view must not be drawn to. Clamping would point | |
| 165 | - | // the connector at whatever sits at the pane edge. | |
| 166 | - | #[test] | |
| 167 | - | fn out_of_range_rows_draw_nothing() { | |
| 168 | - | assert_eq!(render(0, 9, 3, 3), [" ", " ", " "]); | |
| 169 | - | assert_eq!(render(9, 0, 3, 3), [" ", " ", " "]); | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | #[test] | |
| 173 | - | fn a_gutter_too_narrow_to_hold_an_elbow_draws_nothing() { | |
| 174 | - | assert_eq!(render(0, 1, 2, 2), [" ", " "]); | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // Wider gutters keep the vertical run centered and extend the horizontals. | |
| 178 | - | #[test] | |
| 179 | - | fn wider_gutters_extend_the_horizontal_runs() { | |
| 180 | - | assert_eq!(render(0, 2, 5, 3), ["──┐ ", " │ ", " └──"]); | |
| 181 | - | } | |
| 182 | - | } |
| @@ -1,149 +1,0 @@ | |||
| 1 | - | //! A list cursor: a bounded, clamping selection over a list whose length | |
| 2 | - | //! changes underneath it. | |
| 3 | - | //! | |
| 4 | - | //! Distinct from [`FocusRing`](crate::FocusRing), and deliberately so. A focus | |
| 5 | - | //! ring moves between a fixed set of panes and wraps, because wrapping past | |
| 6 | - | //! the last pane back to the first is what a user means by Tab. A list cursor | |
| 7 | - | //! moves over rows that appear and disappear on refresh, and it clamps: a user | |
| 8 | - | //! holding `j` at the bottom of a list expects to stay there, not to leap to | |
| 9 | - | //! the top. | |
| 10 | - | //! | |
| 11 | - | //! The clamp-on-resize case is the one worth centralizing. Every console view | |
| 12 | - | //! re-fetches its list and can find it shorter than it was, and a cursor left | |
| 13 | - | //! pointing past the end renders a selection nobody can see. | |
| 14 | - | ||
| 15 | - | /// A selection index over a list of `len` rows. | |
| 16 | - | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] | |
| 17 | - | pub struct Cursor { | |
| 18 | - | index: usize, | |
| 19 | - | len: usize, | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | impl Cursor { | |
| 23 | - | /// A cursor over an empty list. Views construct this before their first | |
| 24 | - | /// fetch and call [`resize`](Self::resize) once the data lands. | |
| 25 | - | pub const fn new() -> Self { | |
| 26 | - | Self { index: 0, len: 0 } | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | /// The selected row, or `None` when the list is empty. | |
| 30 | - | /// | |
| 31 | - | /// Returning `Option` rather than a bare index is what keeps an empty list | |
| 32 | - | /// from reporting row 0 as selected, which renders as a selection marker | |
| 33 | - | /// on a row that does not exist. | |
| 34 | - | pub const fn selected(&self) -> Option<usize> { | |
| 35 | - | if self.len == 0 { None } else { Some(self.index) } | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | pub const fn len(&self) -> usize { | |
| 39 | - | self.len | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | pub const fn is_empty(&self) -> bool { | |
| 43 | - | self.len == 0 | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | /// Tell the cursor how long the list is now, clamping the selection into | |
| 47 | - | /// range. Call this on every refresh, before rendering. | |
| 48 | - | pub const fn resize(&mut self, len: usize) { | |
| 49 | - | self.len = len; | |
| 50 | - | if len == 0 { | |
| 51 | - | self.index = 0; | |
| 52 | - | } else if self.index >= len { | |
| 53 | - | self.index = len - 1; | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | /// Move by `delta` rows, clamped at both ends. | |
| 58 | - | pub const fn move_by(&mut self, delta: isize) { | |
| 59 | - | if self.len == 0 { | |
| 60 | - | return; | |
| 61 | - | } | |
| 62 | - | let last = self.len - 1; | |
| 63 | - | if delta < 0 { | |
| 64 | - | // `unsigned_abs` rather than `-delta`: negating isize::MIN | |
| 65 | - | // overflows, and a held key that somehow accumulated one should | |
| 66 | - | // saturate at the top like any other large step. | |
| 67 | - | self.index = self.index.saturating_sub(delta.unsigned_abs()); | |
| 68 | - | } else { | |
| 69 | - | // `Ord::min` is not const yet, so this is spelled out. | |
| 70 | - | let target = self.index.saturating_add(delta as usize); | |
| 71 | - | self.index = if target > last { last } else { target }; | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | pub const fn next(&mut self) { | |
| 76 | - | self.move_by(1); | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | pub const fn prev(&mut self) { | |
| 80 | - | self.move_by(-1); | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | #[cfg(test)] | |
| 85 | - | mod tests { | |
| 86 | - | use super::*; | |
| 87 | - | ||
| 88 | - | #[test] | |
| 89 | - | fn empty_cursor_selects_nothing() { | |
| 90 | - | let mut cursor = Cursor::new(); | |
| 91 | - | assert_eq!(cursor.selected(), None); | |
| 92 | - | cursor.next(); | |
| 93 | - | cursor.prev(); | |
| 94 | - | assert_eq!(cursor.selected(), None, "movement on an empty list is inert"); | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | #[test] | |
| 98 | - | fn clamps_at_both_ends_instead_of_wrapping() { | |
| 99 | - | let mut cursor = Cursor::new(); | |
| 100 | - | cursor.resize(3); | |
| 101 | - | cursor.prev(); | |
| 102 | - | assert_eq!(cursor.selected(), Some(0), "no wrap to the end from the top"); | |
| 103 | - | cursor.move_by(99); | |
| 104 | - | assert_eq!(cursor.selected(), Some(2), "no wrap to the top from the end"); | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // The case this type exists for: a refresh returns fewer rows than the | |
| 108 | - | // cursor is sitting on. | |
| 109 | - | #[test] | |
| 110 | - | fn resize_pulls_the_selection_into_range() { | |
| 111 | - | let mut cursor = Cursor::new(); | |
| 112 | - | cursor.resize(5); | |
| 113 | - | cursor.move_by(4); | |
| 114 | - | cursor.resize(2); | |
| 115 | - | assert_eq!(cursor.selected(), Some(1), "clamped to the new last row"); | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // A list emptying entirely must report no selection rather than row 0. | |
| 119 | - | #[test] | |
| 120 | - | fn resize_to_empty_clears_the_selection() { | |
| 121 | - | let mut cursor = Cursor::new(); | |
| 122 | - | cursor.resize(4); | |
| 123 | - | cursor.move_by(3); | |
| 124 | - | cursor.resize(0); | |
| 125 | - | assert_eq!(cursor.selected(), None); | |
| 126 | - | assert!(cursor.is_empty()); | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | // A growing list must not move the cursor; the row under it is still the | |
| 130 | - | // row the user selected. | |
| 131 | - | #[test] | |
| 132 | - | fn growing_the_list_leaves_the_selection_alone() { | |
| 133 | - | let mut cursor = Cursor::new(); | |
| 134 | - | cursor.resize(3); | |
| 135 | - | cursor.move_by(1); | |
| 136 | - | cursor.resize(10); | |
| 137 | - | assert_eq!(cursor.selected(), Some(1)); | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | // isize::MIN has no positive counterpart; negating it panics in debug. | |
| 141 | - | #[test] | |
| 142 | - | fn extreme_negative_delta_saturates_rather_than_overflowing() { | |
| 143 | - | let mut cursor = Cursor::new(); | |
| 144 | - | cursor.resize(3); | |
| 145 | - | cursor.move_by(2); | |
| 146 | - | cursor.move_by(isize::MIN); | |
| 147 | - | assert_eq!(cursor.selected(), Some(0)); | |
| 148 | - | } | |
| 149 | - | } |
| @@ -1,103 +1,0 @@ | |||
| 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,132 +1,0 @@ | |||
| 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, `l` / `h` move between tabs, `Enter` activates, | |
| 7 | - | //! `Esc` cancels, `Ctrl-S` saves, `q` quits, `?` opens help, `/` filters, `:` | |
| 8 | - | //! opens the command line. | |
| 9 | - | //! | |
| 10 | - | //! Tabs get `l` / `h` rather than `Tab` because `Tab` already means focus and | |
| 11 | - | //! that meaning is documented across every view. Two navigation axes need two | |
| 12 | - | //! keys, and the vim pair reads as horizontal movement, which is what a tab bar | |
| 13 | - | //! is. | |
| 14 | - | //! | |
| 15 | - | //! Descended from sysop-tui's `keys.rs`, widened from that crate's six actions | |
| 16 | - | //! to the full reserved set the console's form surfaces need. | |
| 17 | - | ||
| 18 | - | use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | |
| 19 | - | ||
| 20 | - | /// A reserved key's meaning. `Passthrough` means the key is not reserved and | |
| 21 | - | /// belongs to whatever view currently holds focus. | |
| 22 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 23 | - | pub enum Action { | |
| 24 | - | NextFocus, | |
| 25 | - | PrevFocus, | |
| 26 | - | NextTab, | |
| 27 | - | PrevTab, | |
| 28 | - | Activate, | |
| 29 | - | Cancel, | |
| 30 | - | Save, | |
| 31 | - | Help, | |
| 32 | - | Quit, | |
| 33 | - | Filter, | |
| 34 | - | Command, | |
| 35 | - | Passthrough, | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | /// Classify a key event against the reserved keymap. | |
| 39 | - | /// | |
| 40 | - | /// This is a pure classifier with no notion of mode: it reports what a key | |
| 41 | - | /// *means* in the reserved map, not whether the app should honor it. Two | |
| 42 | - | /// caller obligations follow from that: | |
| 43 | - | /// | |
| 44 | - | /// - **Filter to `KeyEventKind::Press` first.** Windows terminals deliver both | |
| 45 | - | /// press and release for every key, so an unfiltered event loop performs | |
| 46 | - | /// each action twice. | |
| 47 | - | /// - **Ignore the character actions while text entry has focus.** `q`, `/`, | |
| 48 | - | /// `:`, `h`, and `l` are literal characters a user types into a field; a view | |
| 49 | - | /// holding an active text input should route keys to the input and consult | |
| 50 | - | /// this classifier only for `Cancel`, `Save`, and the focus movers. | |
| 51 | - | /// | |
| 52 | - | /// `h` and `l` make this obligation sharper than it was. The earlier | |
| 53 | - | /// character actions were punctuation and one letter that rarely opens a | |
| 54 | - | /// word; `h` and `l` are ordinary letters that appear in almost any typed | |
| 55 | - | /// value, so a view that forwards raw keys to an input without this check | |
| 56 | - | /// now changes tabs mid-word rather than merely on a stray `q`. | |
| 57 | - | pub fn classify(key: KeyEvent) -> Action { | |
| 58 | - | let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); | |
| 59 | - | let shift = key.modifiers.contains(KeyModifiers::SHIFT); | |
| 60 | - | ||
| 61 | - | match key.code { | |
| 62 | - | KeyCode::Char('s') | KeyCode::Char('S') if ctrl => Action::Save, | |
| 63 | - | KeyCode::BackTab => Action::PrevFocus, | |
| 64 | - | KeyCode::Tab if shift => Action::PrevFocus, | |
| 65 | - | KeyCode::Tab => Action::NextFocus, | |
| 66 | - | KeyCode::Char('l') => Action::NextTab, | |
| 67 | - | KeyCode::Char('h') => Action::PrevTab, | |
| 68 | - | KeyCode::Enter => Action::Activate, | |
| 69 | - | KeyCode::Esc => Action::Cancel, | |
| 70 | - | KeyCode::Char('?') => Action::Help, | |
| 71 | - | KeyCode::Char('q') => Action::Quit, | |
| 72 | - | KeyCode::Char('/') => Action::Filter, | |
| 73 | - | KeyCode::Char(':') => Action::Command, | |
| 74 | - | _ => Action::Passthrough, | |
| 75 | - | } | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | #[cfg(test)] | |
| 79 | - | mod tests { | |
| 80 | - | use super::*; | |
| 81 | - | ||
| 82 | - | fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { | |
| 83 | - | KeyEvent::new(code, mods) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | #[test] | |
| 87 | - | fn ctrl_s_saves_but_bare_s_does_not() { | |
| 88 | - | assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)), Action::Save); | |
| 89 | - | assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::NONE)), Action::Passthrough); | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | // Terminals disagree on how they report Shift-Tab: some send BackTab with no | |
| 93 | - | // modifier, others send Tab with SHIFT. Both must reach PrevFocus, or focus | |
| 94 | - | // navigation silently becomes one-directional on half the terminal emulators | |
| 95 | - | // in the stack. | |
| 96 | - | #[test] | |
| 97 | - | fn both_shift_tab_encodings_move_focus_backward() { | |
| 98 | - | assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus); | |
| 99 | - | assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)), Action::PrevFocus); | |
| 100 | - | assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::SHIFT)), Action::PrevFocus); | |
| 101 | - | assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus); | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | #[test] | |
| 105 | - | fn unreserved_keys_pass_through() { | |
| 106 | - | assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough); | |
| 107 | - | assert_eq!(classify(key(KeyCode::Down, KeyModifiers::NONE)), Action::Passthrough); | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | #[test] | |
| 111 | - | fn h_and_l_move_between_tabs() { | |
| 112 | - | assert_eq!(classify(key(KeyCode::Char('l'), KeyModifiers::NONE)), Action::NextTab); | |
| 113 | - | assert_eq!(classify(key(KeyCode::Char('h'), KeyModifiers::NONE)), Action::PrevTab); | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | // Tabs and focus are two navigation axes and must stay on separate keys. | |
| 117 | - | // docs/COMPONENT-LIBRARY.md documents Tab as focus movement across every | |
| 118 | - | // view, so a tab bar claiming Tab would silently redefine it everywhere. | |
| 119 | - | #[test] | |
| 120 | - | fn tab_key_still_means_focus_not_tabs() { | |
| 121 | - | assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus); | |
| 122 | - | assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus); | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // j/k stay unreserved so a list cursor keeps them. Only the horizontal half | |
| 126 | - | // of the vim pair is spoken for. | |
| 127 | - | #[test] | |
| 128 | - | fn vertical_vim_keys_are_not_claimed_by_tabs() { | |
| 129 | - | assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough); | |
| 130 | - | assert_eq!(classify(key(KeyCode::Char('k'), KeyModifiers::NONE)), Action::Passthrough); | |
| 131 | - | } | |
| 132 | - | } |
| @@ -1,196 +1,0 @@ | |||
| 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 | - | /// Width of the gutter between two linked panes. Three columns is the minimum | |
| 60 | - | /// an elbow needs: one to leave the left pane, one to carry the vertical run, | |
| 61 | - | /// one to enter the right pane. | |
| 62 | - | pub const GUTTER_WIDTH: u16 = 3; | |
| 63 | - | ||
| 64 | - | /// Center a `width` x `height` box inside `area`, for a modal drawn over a | |
| 65 | - | /// view. | |
| 66 | - | /// | |
| 67 | - | /// Clamps rather than overflowing: a box larger than the area it sits in | |
| 68 | - | /// becomes the area. A modal that renders partly offscreen is worse than a | |
| 69 | - | /// cramped one, because the keys that dismiss it are listed on its last row. | |
| 70 | - | pub fn centered(area: Rect, width: u16, height: u16) -> Rect { | |
| 71 | - | let width = width.min(area.width); | |
| 72 | - | let height = height.min(area.height); | |
| 73 | - | Rect { | |
| 74 | - | x: area.x + (area.width - width) / 2, | |
| 75 | - | y: area.y + (area.height - height) / 2, | |
| 76 | - | width, | |
| 77 | - | height, | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | /// Two panes with a connector gutter between them. | |
| 82 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 83 | - | pub struct PaneAreas { | |
| 84 | - | pub left: Rect, | |
| 85 | - | pub gutter: Rect, | |
| 86 | - | pub right: Rect, | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | /// Split a body area into two panes separated by a connector gutter. | |
| 90 | - | /// | |
| 91 | - | /// The split is even, with any odd column going to the left pane. Below the | |
| 92 | - | /// width needed for two usable panes the gutter collapses and the right pane | |
| 93 | - | /// takes zero width; callers render into it unconditionally, since ratatui | |
| 94 | - | /// clips a zero-area render, and a view that wants different narrow-terminal | |
| 95 | - | /// behavior can check `right.width`. | |
| 96 | - | pub fn panes(area: Rect) -> PaneAreas { | |
| 97 | - | // Two panes of at least this width each, plus the gutter, or the split is | |
| 98 | - | // not worth making: below it a pane is too narrow to hold a label. | |
| 99 | - | const MIN_PANE_WIDTH: u16 = 16; | |
| 100 | - | ||
| 101 | - | if area.width < MIN_PANE_WIDTH * 2 + GUTTER_WIDTH { | |
| 102 | - | return PaneAreas { | |
| 103 | - | left: area, | |
| 104 | - | gutter: Rect { width: 0, ..area }, | |
| 105 | - | right: Rect { width: 0, ..area }, | |
| 106 | - | }; | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | let usable = area.width - GUTTER_WIDTH; | |
| 110 | - | let left_width = usable - usable / 2; | |
| 111 | - | ||
| 112 | - | PaneAreas { | |
| 113 | - | left: Rect { width: left_width, ..area }, | |
| 114 | - | gutter: Rect { | |
| 115 | - | x: area.x + left_width, | |
| 116 | - | width: GUTTER_WIDTH, | |
| 117 | - | ..area | |
| 118 | - | }, | |
| 119 | - | right: Rect { | |
| 120 | - | x: area.x + left_width + GUTTER_WIDTH, | |
| 121 | - | width: usable / 2, | |
| 122 | - | ..area | |
| 123 | - | }, | |
| 124 | - | } | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | #[cfg(test)] | |
| 128 | - | mod tests { | |
| 129 | - | use super::*; | |
| 130 | - | ||
| 131 | - | #[test] | |
| 132 | - | fn panes_tile_the_area_exactly() { | |
| 133 | - | let areas = panes(Rect::new(0, 0, 80, 20)); | |
| 134 | - | assert_eq!(areas.left.x, 0); | |
| 135 | - | assert_eq!(areas.gutter.x, areas.left.x + areas.left.width); | |
| 136 | - | assert_eq!(areas.right.x, areas.gutter.x + areas.gutter.width); | |
| 137 | - | assert_eq!( | |
| 138 | - | areas.left.width + areas.gutter.width + areas.right.width, | |
| 139 | - | 80, | |
| 140 | - | "no column is unaccounted for" | |
| 141 | - | ); | |
| 142 | - | assert_eq!(areas.gutter.width, GUTTER_WIDTH); | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // An odd usable width cannot split evenly; the extra column has to go | |
| 146 | - | // somewhere deterministic rather than being dropped. | |
| 147 | - | #[test] | |
| 148 | - | fn odd_widths_give_the_extra_column_to_the_left() { | |
| 149 | - | let areas = panes(Rect::new(0, 0, 81, 20)); | |
| 150 | - | assert_eq!(areas.left.width, 39); | |
| 151 | - | assert_eq!(areas.right.width, 39); | |
| 152 | - | assert_eq!(areas.left.width + areas.gutter.width + areas.right.width, 81); | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | // A narrow terminal collapses to one pane rather than two unusable slivers. | |
| 156 | - | #[test] | |
| 157 | - | fn narrow_area_collapses_to_a_single_pane() { | |
| 158 | - | let areas = panes(Rect::new(0, 0, 30, 20)); | |
| 159 | - | assert_eq!(areas.left.width, 30); | |
| 160 | - | assert_eq!(areas.right.width, 0); | |
| 161 | - | assert_eq!(areas.gutter.width, 0); | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | #[test] | |
| 165 | - | fn full_height_gets_all_three_regions() { | |
| 166 | - | let areas = console(Rect::new(0, 0, 80, 24)); | |
| 167 | - | assert_eq!(areas.body.height, 24 - LOG_HEIGHT - 1); | |
| 168 | - | assert_eq!(areas.log.height, LOG_HEIGHT); | |
| 169 | - | assert_eq!(areas.footer.height, 1); | |
| 170 | - | assert_eq!( | |
| 171 | - | areas.body.height + areas.log.height + areas.footer.height, | |
| 172 | - | 24, | |
| 173 | - | "the three regions must tile the screen exactly, with no dead row" | |
| 174 | - | ); | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // A short terminal drops the log rather than starving the body. Without this | |
| 178 | - | // the layout hands the body 1-2 rows and spends the rest on log borders. | |
| 179 | - | #[test] | |
| 180 | - | fn short_terminal_drops_the_log_pane() { | |
| 181 | - | let areas = console(Rect::new(0, 0, 80, 10)); | |
| 182 | - | assert_eq!(areas.log.height, 0, "log is dropped below the minimum height"); | |
| 183 | - | assert_eq!(areas.body.height, 9); | |
| 184 | - | assert_eq!(areas.footer.height, 1); | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | // The degenerate case: a terminal so short there is only the footer. This | |
| 188 | - | // must not panic or produce a negative-height body. | |
| 189 | - | #[test] | |
| 190 | - | fn single_row_terminal_yields_footer_only() { | |
| 191 | - | let areas = console(Rect::new(0, 0, 80, 1)); | |
| 192 | - | assert_eq!(areas.body.height, 0); | |
| 193 | - | assert_eq!(areas.log.height, 0); | |
| 194 | - | assert_eq!(areas.footer.height, 1); | |
| 195 | - | } | |
| 196 | - | } |
| @@ -1,36 +1,0 @@ | |||
| 1 | - | //! Alloy design system for ratatui. | |
| 2 | - | //! | |
| 3 | - | //! Consumes makeover `.toml` themes (the same schema every make-family app | |
| 4 | - | //! reads) and renders them as ratatui `Color` / `Style`. Two Alloy-specific | |
| 5 | - | //! tokens (`border-subtle`, `border-strong`) are derived locally so theme | |
| 6 | - | //! files stay minimal — see `docs/TOKENS.md` for the storage format and | |
| 7 | - | //! derivation math, `docs/CONSOLE.md` for the widget roster this crate | |
| 8 | - | //! targets, and `docs/DESIGN-LANGUAGE.md` for the color-is-information rule | |
| 9 | - | //! enforced here: chrome is tinted-greyscale, accents live on text via | |
| 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 --> | |
| 18 | - | ||
| 19 | - | pub mod connector; | |
| 20 | - | pub mod cursor; | |
| 21 | - | pub mod focus; | |
| 22 | - | pub mod keys; | |
| 23 | - | pub mod layout; | |
| 24 | - | pub mod selection; | |
| 25 | - | pub mod text; | |
| 26 | - | pub mod theme; | |
| 27 | - | pub mod widgets; | |
| 28 | - | ||
| 29 | - | pub use connector::AlloyConnector; | |
| 30 | - | pub use cursor::Cursor; | |
| 31 | - | pub use focus::FocusRing; | |
| 32 | - | pub use keys::{Action, classify}; | |
| 33 | - | pub use layout::{ConsoleAreas, PaneAreas, console, panes}; | |
| 34 | - | pub use selection::{MARKER, selected_style}; | |
| 35 | - | pub use theme::{Mode, Theme, ThemeError}; | |
| 36 | - | pub use widgets::*; |
| @@ -1,36 +1,0 @@ | |||
| 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,42 +1,0 @@ | |||
| 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 | - | } |
| @@ -1,196 +1,0 @@ | |||
| 1 | - | //! Theme palette: makeover intents resolved into ratatui `Color`s, plus | |
| 2 | - | //! the two Alloy-derived border tokens. | |
| 3 | - | //! | |
| 4 | - | //! Per docs/TOKENS.md, Alloy consumes makeover `.toml` files (the same | |
| 5 | - | //! schema every make-family app already reads) and derives two extra tokens | |
| 6 | - | //! locally so theme files stay minimal and cross-app compatible: | |
| 7 | - | //! | |
| 8 | - | //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider | |
| 9 | - | //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection | |
| 10 | - | //! | |
| 11 | - | //! Mix is in linear sRGB, matching TOKENS.md's worked audit math. | |
| 12 | - | //! | |
| 13 | - | //! ratatui is immediate-mode with per-widget styling — there is no global | |
| 14 | - | //! visuals object. Widgets in this crate take a `&Theme` at construction time | |
| 15 | - | //! and pull colors from it. Apps build one `Theme` per theme load (via | |
| 16 | - | //! `makeover::load_theme` + `Theme::from_theme`) and thread it through. | |
| 17 | - | ||
| 18 | - | use ratatui::style::Color; | |
| 19 | - | use makeover::{Rgb, ThemeColors}; | |
| 20 | - | ||
| 21 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 22 | - | pub enum Mode { | |
| 23 | - | Light, | |
| 24 | - | Dark, | |
| 25 | - | HighContrast, | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | #[derive(Debug, Clone, Copy)] | |
| 29 | - | pub struct Theme { | |
| 30 | - | pub mode: Mode, | |
| 31 | - | ||
| 32 | - | pub surface_page: Color, | |
| 33 | - | pub surface_raised: Color, | |
| 34 | - | pub surface_sunken: Color, | |
| 35 | - | pub surface_overlay: Color, | |
| 36 | - | ||
| 37 | - | pub content_primary: Color, | |
| 38 | - | pub content_secondary: Color, | |
| 39 | - | pub content_muted: Color, | |
| 40 | - | ||
| 41 | - | pub action_primary: Color, | |
| 42 | - | ||
| 43 | - | pub status_danger: Color, | |
| 44 | - | pub status_success: Color, | |
| 45 | - | pub status_warning: Color, | |
| 46 | - | pub status_info: Color, | |
| 47 | - | ||
| 48 | - | pub line_border: Color, | |
| 49 | - | pub border_subtle: Color, | |
| 50 | - | pub border_strong: Color, | |
| 51 | - | ||
| 52 | - | pub category: [Color; 6], | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | #[derive(Debug, Clone)] | |
| 56 | - | pub enum ThemeError { | |
| 57 | - | MissingKey(&'static str), | |
| 58 | - | InvalidHex { key: &'static str, value: String }, | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | impl std::fmt::Display for ThemeError { | |
| 62 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 63 | - | match self { | |
| 64 | - | ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"), | |
| 65 | - | ThemeError::InvalidHex { key, value } => { | |
| 66 | - | write!(f, "theme key `{key}` has invalid hex value `{value}`") | |
| 67 | - | } | |
| 68 | - | } | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | impl std::error::Error for ThemeError {} | |
| 73 | - | ||
| 74 | - | impl Theme { | |
| 75 | - | /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`. | |
| 76 | - | /// Requires every intent Alloy renders — a malformed or partial theme is | |
| 77 | - | /// rejected explicitly rather than silently rendering with defaults. | |
| 78 | - | pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> { | |
| 79 | - | let get = |key: &'static str| -> Result<Rgb, ThemeError> { | |
| 80 | - | let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?; | |
| 81 | - | Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex { | |
| 82 | - | key, | |
| 83 | - | value: hex.clone(), | |
| 84 | - | }) | |
| 85 | - | }; | |
| 86 | - | ||
| 87 | - | let surface_page = get("surface.page")?; | |
| 88 | - | let content_primary = get("content.primary")?; | |
| 89 | - | let line_border = get("line.border")?; | |
| 90 | - | ||
| 91 | - | let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60); | |
| 92 | - | let border_strong = mix_linear_srgb(line_border, content_primary, 0.65); | |
| 93 | - | ||
| 94 | - | let mode = match theme.meta.variant.as_str() { | |
| 95 | - | "dark" => Mode::Dark, | |
| 96 | - | "high-contrast" => Mode::HighContrast, | |
| 97 | - | _ => Mode::Light, | |
| 98 | - | }; | |
| 99 | - | ||
| 100 | - | Ok(Self { | |
| 101 | - | mode, | |
| 102 | - | ||
| 103 | - | surface_page: rgb(surface_page), | |
| 104 | - | surface_raised: rgb(get("surface.raised")?), | |
| 105 | - | surface_sunken: rgb(get("surface.sunken")?), | |
| 106 | - | surface_overlay: rgb(get("surface.overlay")?), | |
| 107 | - | ||
| 108 | - | content_primary: rgb(content_primary), | |
| 109 | - | content_secondary: rgb(get("content.secondary")?), | |
| 110 | - | content_muted: rgb(get("content.muted")?), | |
| 111 | - | ||
| 112 | - | action_primary: rgb(get("action.primary")?), | |
| 113 | - | ||
| 114 | - | status_danger: rgb(get("status.danger")?), | |
| 115 | - | status_success: rgb(get("status.success")?), | |
| 116 | - | status_warning: rgb(get("status.warning")?), | |
| 117 | - | status_info: rgb(get("status.info")?), | |
| 118 | - | ||
| 119 | - | line_border: rgb(line_border), | |
| 120 | - | border_subtle: rgb(border_subtle), | |
| 121 | - | border_strong: rgb(border_strong), | |
| 122 | - | ||
| 123 | - | category: [ | |
| 124 | - | rgb(get("category.one")?), | |
| 125 | - | rgb(get("category.two")?), | |
| 126 | - | rgb(get("category.three")?), | |
| 127 | - | rgb(get("category.four")?), | |
| 128 | - | rgb(get("category.five")?), | |
| 129 | - | rgb(get("category.six")?), | |
| 130 | - | ], | |
| 131 | - | }) | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | fn rgb(c: Rgb) -> Color { | |
| 136 | - | Color::Rgb(c.r, c.g, c.b) | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | // Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are | |
| 140 | - | // gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually | |
| 141 | - | // less uniform than OKLab but keeps the derived hex reproducible against the | |
| 142 | - | // contrast tables in TOKENS.md. | |
| 143 | - | fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb { | |
| 144 | - | let al = srgb_to_linear(a); | |
| 145 | - | let bl = srgb_to_linear(b); | |
| 146 | - | let m = ( | |
| 147 | - | al.0 + (bl.0 - al.0) * t, | |
| 148 | - | al.1 + (bl.1 - al.1) * t, | |
| 149 | - | al.2 + (bl.2 - al.2) * t, | |
| 150 | - | ); | |
| 151 | - | linear_to_srgb(m) | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) { | |
| 155 | - | (channel_to_linear(c.r), channel_to_linear(c.g), channel_to_linear(c.b)) | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb { | |
| 159 | - | Rgb { | |
| 160 | - | r: channel_to_srgb(c.0), | |
| 161 | - | g: channel_to_srgb(c.1), | |
| 162 | - | b: channel_to_srgb(c.2), | |
| 163 | - | } | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | fn channel_to_linear(c: u8) -> f32 { | |
| 167 | - | let c = c as f32 / 255.0; | |
| 168 | - | if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | fn channel_to_srgb(c: f32) -> u8 { | |
| 172 | - | let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }; | |
| 173 | - | (v * 255.0).round().clamp(0.0, 255.0) as u8 | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | #[cfg(test)] | |
| 177 | - | mod tests { | |
| 178 | - | use super::*; | |
| 179 | - | ||
| 180 | - | // TOKENS.md line 61 anchors the derivation math against Akari Dawn: | |
| 181 | - | // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary | |
| 182 | - | // must produce #7f786d (the value the contrast-audit table is calibrated on). | |
| 183 | - | // If this test fails, the audit table in TOKENS.md is stale, not the code. | |
| 184 | - | #[test] | |
| 185 | - | fn akari_dawn_border_strong_matches_tokens_md() { | |
| 186 | - | let border = Rgb::from_hex("#cabeae").unwrap(); | |
| 187 | - | let primary = Rgb::from_hex("#1a1816").unwrap(); | |
| 188 | - | let got = mix_linear_srgb(border, primary, 0.65); | |
| 189 | - | assert_eq!( | |
| 190 | - | (got.r, got.g, got.b), | |
| 191 | - | (0x7f, 0x78, 0x6d), | |
| 192 | - | "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d", | |
| 193 | - | got.r, got.g, got.b | |
| 194 | - | ); | |
| 195 | - | } | |
| 196 | - | } |