Skip to main content

max / alloy_tui

audio: two panes, stream-to-device routing, and a shell tick Streams on the left, devices on the right, connector through the gutter. Every stream is routed to exactly one device, which is the property that makes the pairing drawable: one line out of each row on the left, no crossings by construction. The left pane is streams, not sources. PulseAudio and PipeWire already use source for a capture device, and this module's Direction::Input means exactly that. Labelling the app column "sources" next to a logged move-sink-input would teach the wrong word, and teaching the real vocabulary is what the log pane is for. Not a patchbay. The real PipeWire graph is port-level and many-to-many: one playing stream is already eight pw-link entries on stereo. Routing a stream to a device covers moving this to my headphones, which is the ask. The general graph is Helvum's job. Only the selected pairing is drawn. Drawing all of them is the obvious reading of the idea and it does not survive a terminal, where lines cross and a cell holds one glyph. One lit pairing reads the same and needs no crossing logic. AlloyConnector is tested by rendering elbows into a buffer and asserting on the glyphs, since corner direction is the kind of thing that looks right until seen beside its mirror image. list_offset and list_row_y are public now because the connector has to agree with AlloyList about which rows are on screen and where. A second copy of the scroll rule is how a connector ends up one row off after scrolling. The shell polls with a timeout instead of blocking on read, so a view can refresh state that changes without the user. That is what streams need: an app starting playback should appear unprompted. Devices poll at a tenth the rate, since they change only when hardware is plugged in and matching the stream cadence would spend four pactl spawns a second to learn nothing. Polling forced the log contract to be stated properly, and it turned out to already be broken. The pane records what the user asked for, not everything the console runs: probes, background polls, and post-action re-reads are bookkeeping. Before this, one volume keypress wrote its action plus a four-command re-read into a two-row pane, so the command actually pressed for scrolled off immediately. CommandLog::quiet is scoped rather than a muted flag, so an early return cannot leak suppression, and nesting restores to the enclosing state. A refresh no longer clears the error on success. It runs on the tick, so clearing there gave a rejected route under a second on screen before a poll wiped it. Errors clear on the next keypress instead, which is when the user has moved on. Both halves are pinned by tests. Backends take a Target rather than duplicating every verb per kind. The nouns are the counterintuitive part and carry a comment: a stream playing out is a sink-input, so playback streams are sink-input and capture streams are source-output. Devices are addressed by name, which reads well in the log; streams by index, which is all they have. Cross-direction routing is refused with a reason rather than passed to pactl to reject. Per-app volume and mute fall out of the Target work, since the focused pane decides what the action keys apply to. Verified against this machine's real PipeWire: the live stream resolves to the live device, which is the pairing the connector draws. The ignored test now prints the routing table it derived. Still not eyeballed, and this is the first screen where that gap is real: three commits of layout, focus, and connector geometry that no one has looked at. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 17:54 UTC
Commit: 04c222ba64a3a7a429be5e99df71bdf0d6fe758b
Parent: 50478d2
4 files changed, +332 insertions, -12 deletions
@@ -0,0 +1,182 @@
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 + }
@@ -56,11 +56,95 @@ pub fn console_with_log_height(area: Rect, log_height: u16) -> ConsoleAreas {
56 56 ConsoleAreas { body, log, footer }
57 57 }
58 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 + /// Two panes with a connector gutter between them.
65 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
66 + pub struct PaneAreas {
67 + pub left: Rect,
68 + pub gutter: Rect,
69 + pub right: Rect,
70 + }
71 +
72 + /// Split a body area into two panes separated by a connector gutter.
73 + ///
74 + /// The split is even, with any odd column going to the left pane. Below the
75 + /// width needed for two usable panes the gutter collapses and the right pane
76 + /// takes zero width; callers render into it unconditionally, since ratatui
77 + /// clips a zero-area render, and a view that wants different narrow-terminal
78 + /// behavior can check `right.width`.
79 + pub fn panes(area: Rect) -> PaneAreas {
80 + // Two panes of at least this width each, plus the gutter, or the split is
81 + // not worth making: below it a pane is too narrow to hold a label.
82 + const MIN_PANE_WIDTH: u16 = 16;
83 +
84 + if area.width < MIN_PANE_WIDTH * 2 + GUTTER_WIDTH {
85 + return PaneAreas {
86 + left: area,
87 + gutter: Rect { width: 0, ..area },
88 + right: Rect { width: 0, ..area },
89 + };
90 + }
91 +
92 + let usable = area.width - GUTTER_WIDTH;
93 + let left_width = usable - usable / 2;
94 +
95 + PaneAreas {
96 + left: Rect { width: left_width, ..area },
97 + gutter: Rect {
98 + x: area.x + left_width,
99 + width: GUTTER_WIDTH,
100 + ..area
101 + },
102 + right: Rect {
103 + x: area.x + left_width + GUTTER_WIDTH,
104 + width: usable / 2,
105 + ..area
106 + },
107 + }
108 + }
109 +
59 110 #[cfg(test)]
60 111 mod tests {
61 112 use super::*;
62 113
63 114 #[test]
115 + fn panes_tile_the_area_exactly() {
116 + let areas = panes(Rect::new(0, 0, 80, 20));
117 + assert_eq!(areas.left.x, 0);
118 + assert_eq!(areas.gutter.x, areas.left.x + areas.left.width);
119 + assert_eq!(areas.right.x, areas.gutter.x + areas.gutter.width);
120 + assert_eq!(
121 + areas.left.width + areas.gutter.width + areas.right.width,
122 + 80,
123 + "no column is unaccounted for"
124 + );
125 + assert_eq!(areas.gutter.width, GUTTER_WIDTH);
126 + }
127 +
128 + // An odd usable width cannot split evenly; the extra column has to go
129 + // somewhere deterministic rather than being dropped.
130 + #[test]
131 + fn odd_widths_give_the_extra_column_to_the_left() {
132 + let areas = panes(Rect::new(0, 0, 81, 20));
133 + assert_eq!(areas.left.width, 39);
134 + assert_eq!(areas.right.width, 39);
135 + assert_eq!(areas.left.width + areas.gutter.width + areas.right.width, 81);
136 + }
137 +
138 + // A narrow terminal collapses to one pane rather than two unusable slivers.
139 + #[test]
140 + fn narrow_area_collapses_to_a_single_pane() {
141 + let areas = panes(Rect::new(0, 0, 30, 20));
142 + assert_eq!(areas.left.width, 30);
143 + assert_eq!(areas.right.width, 0);
144 + assert_eq!(areas.gutter.width, 0);
145 + }
146 +
147 + #[test]
64 148 fn full_height_gets_all_three_regions() {
65 149 let areas = console(Rect::new(0, 0, 80, 24));
66 150 assert_eq!(areas.body.height, 24 - LOG_HEIGHT - 1);
M src/lib.rs +3 -1
@@ -16,6 +16,7 @@
16 16 //!
17 17 //! <!-- wiki: alloy-console -->
18 18
19 + pub mod connector;
19 20 pub mod cursor;
20 21 pub mod focus;
21 22 pub mod keys;
@@ -25,10 +26,11 @@ pub mod text;
25 26 pub mod theme;
26 27 pub mod widgets;
27 28
29 + pub use connector::AlloyConnector;
28 30 pub use cursor::Cursor;
29 31 pub use focus::FocusRing;
30 32 pub use keys::{Action, classify};
31 - pub use layout::{ConsoleAreas, console};
33 + pub use layout::{ConsoleAreas, PaneAreas, console, panes};
32 34 pub use selection::{MARKER, selected_style};
33 35 pub use theme::{Mode, Theme, ThemeError};
34 36 pub use widgets::*;
M src/widgets.rs +63 -11
@@ -201,19 +201,46 @@ impl<'a> AlloyList<'a> {
201 201 }
202 202
203 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 204 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)
205 + list_offset(self.items.len(), height, self.selected)
206 + }
207 + }
208 +
209 + /// First visible row of a list, given its length, viewport height, and
210 + /// selection.
211 + ///
212 + /// Stateless by design: the offset is derived from the selection each frame
213 + /// rather than carried between frames, which is what lets [`AlloyList`] stay
214 + /// immediate-mode. The cost is that scrolling centers the selection instead of
215 + /// scrolling by the minimum amount; the benefit is that no caller has to own
216 + /// and thread a `ListState`.
217 + ///
218 + /// Public because anything drawing *alongside* a list has to agree with it
219 + /// about which rows are on screen and where. [`AlloyConnector`](crate::AlloyConnector)
220 + /// needs a row's y position, and computing that from a second, separate copy
221 + /// of this rule is how a connector ends up pointing one row off after a scroll.
222 + pub fn list_offset(len: usize, height: usize, selected: Option<usize>) -> usize {
223 + let (Some(selected), true) = (selected, len > height) else {
224 + return 0;
225 + };
226 + let max_offset = len - height;
227 + selected.saturating_sub(height / 2).min(max_offset)
228 + }
229 +
230 + /// Screen row for list item `index`, or `None` when it is scrolled out of
231 + /// view.
232 + ///
233 + /// `area` is the list's viewport, already inside any block border.
234 + pub fn list_row_y(area: Rect, len: usize, selected: Option<usize>, index: usize) -> Option<u16> {
235 + if area.height == 0 || index >= len {
236 + return None;
237 + }
238 + let offset = list_offset(len, area.height as usize, selected);
239 + let row = index.checked_sub(offset)?;
240 + if row >= area.height as usize {
241 + return None;
216 242 }
243 + Some(area.y + row as u16)
217 244 }
218 245
219 246 impl Widget for AlloyList<'_> {
@@ -385,6 +412,31 @@ mod tests {
385 412 assert_eq!(list_of(50, Some(25)).offset(10), 20);
386 413 }
387 414
415 + #[test]
416 + fn row_y_maps_visible_items_to_screen_rows() {
417 + let area = Rect::new(0, 5, 20, 10);
418 + assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5));
419 + assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7));
420 + }
421 +
422 + // After a scroll the mapping has to follow the offset. A connector using a
423 + // separate copy of the scroll rule is exactly what this prevents.
424 + #[test]
425 + fn row_y_accounts_for_scrolling() {
426 + let area = Rect::new(0, 0, 20, 10);
427 + // 50 items, selection at 25 => offset 20, so item 20 is the top row.
428 + assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0));
429 + assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5));
430 + }
431 +
432 + #[test]
433 + fn row_y_is_none_for_rows_scrolled_out_of_view() {
434 + let area = Rect::new(0, 0, 20, 10);
435 + assert_eq!(list_row_y(area, 50, Some(25), 0), None, "above the viewport");
436 + assert_eq!(list_row_y(area, 50, Some(25), 49), None, "below the viewport");
437 + assert_eq!(list_row_y(area, 3, Some(0), 9), None, "past the end of the list");
438 + }
439 +
388 440 // A log longer than its pane shows the newest entries. Showing the head
389 441 // instead would freeze the pane on startup noise and never display the
390 442 // command the user just triggered.