Skip to main content

max / alloy_tui

console: add alloy audio, extract the list cursor into alloy_tui Second subcommand, picked partly for what can be checked here. rpm-ostree and swaymsg are absent on the dev box, so update and display would have been mock-only, and mock-only is how the nmcli escaping assumption survived review last time. pactl and wpctl are both present. pactl over wpctl, though CONSOLE.md sanctions either. wpctl status renders a box-drawing tree meant for eyes: parsing it means depending on indentation and glyphs that exist to look right. pactl -f json is a stated contract, and serde was already in the tree via toml, so the cost is serde_json alone. Cursor comes out of net.rs into alloy_tui rather than being written a second time in audio.rs. It is not FocusRing: a focus ring wraps, because that is what Tab means, and a list cursor clamps, because a user holding j at the bottom expects to stay there. The part worth centralizing is clamp-on-resize. Every view re-fetches a list that can come back shorter, and a cursor left past the end selects a row nobody can see. It also returns Option rather than reporting row 0 of an empty list as selected. The monitor filter had a real bug, caught by a test written to pin down that exact hazard. monitor_source means opposite things per direction: on a source it names the sink the source monitors, and is empty for capture hardware; on a sink it names the monitor that sink has, and is populated for essentially every sink. Filtering both directions on it hid every output. It now applies to sources only, and the field carries a comment saying why, because nothing in the name suggests it. Volume is the loudest channel, not the mean. A stereo device with one channel at zero is not at half volume, and rendering it as 50% explains nothing about why the audio sounds wrong. Raw values are relative to unity at 65536 rather than to 100, and clamp: PipeWire permits boost above unity, but not from a key that repeats. Actions clamp console-side and re-read after every change, so what is on screen is what the daemon reports rather than what was requested. The mock declines to mutate and says so in the log. It is a display fixture, and showing a volume change that reached no device would be worse than plainly not doing it. Four invocations per refresh where net needs one, all of them logged. pactl has no combined form, and hiding three of the four would break the pane's contract that what is displayed is what ran. There is also an ignored test that parses this machine's real devices. It needs a running PipeWire so it cannot run in a clean checkout, but it is the check that would have caught the monitor inversion against hardware instead of against a fixture someone wrote from memory. Not run through cargo fmt. The tree does not conform to default rustfmt today, alloy_tui/src/theme.rs included, so formatting it would rewrite code this commit has no business touching. Still not eyeballed. Both subcommands are verified at the parser against live output, and no one has looked at a rendered frame yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 17:36 UTC
Commit: 50478d23a782fde8d3999f4f9019e6b7efe9cc23
Parent: 071c59a
2 files changed, +151 insertions, -0 deletions
A src/cursor.rs +149
@@ -0,0 +1,149 @@
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 + }
M src/lib.rs +2
@@ -16,6 +16,7 @@
16 16 //!
17 17 //! <!-- wiki: alloy-console -->
18 18
19 + pub mod cursor;
19 20 pub mod focus;
20 21 pub mod keys;
21 22 pub mod layout;
@@ -24,6 +25,7 @@ pub mod text;
24 25 pub mod theme;
25 26 pub mod widgets;
26 27
28 + pub use cursor::Cursor;
27 29 pub use focus::FocusRing;
28 30 pub use keys::{Action, classify};
29 31 pub use layout::{ConsoleAreas, console};