Skip to main content

max / alloy

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
Signed with PGP, not checked
Commit: 0b5a67cd29f5add50d8866ff8cdfc1973d6fce5a
Parent: d2a79c8
7 files changed, +746 insertions, -49 deletions
M Cargo.lock +39 -7
@@ -25,6 +25,8 @@
25 25 "anyhow",
26 26 "clap",
27 27 "ratatui",
28 + "serde",
29 + "serde_json",
28 30 "theme-common",
29 31 ]
30 32
@@ -1183,9 +1185,9 @@
1183 1185
1184 1186 [[package]]
1185 1187 name = "serde"
1186 - version = "1.0.228"
1188 + version = "1.0.229"
1187 1189 source = "registry+https://github.com/rust-lang/crates.io-index"
1188 - checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1190 + checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
1189 1191 dependencies = [
1190 1192 "serde_core",
1191 1193 "serde_derive",
@@ -1193,22 +1195,35 @@
1193 1195
1194 1196 [[package]]
1195 1197 name = "serde_core"
1196 - version = "1.0.228"
1198 + version = "1.0.229"
1197 1199 source = "registry+https://github.com/rust-lang/crates.io-index"
1198 - checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1200 + checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
1199 1201 dependencies = [
1200 1202 "serde_derive",
1201 1203 ]
1202 1204
1203 1205 [[package]]
1204 1206 name = "serde_derive"
1205 - version = "1.0.228"
1207 + version = "1.0.229"
1206 1208 source = "registry+https://github.com/rust-lang/crates.io-index"
1207 - checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1209 + checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
1208 1210 dependencies = [
1209 1211 "proc-macro2",
1210 1212 "quote",
1211 - "syn 2.0.118",
1213 + "syn 3.0.0",
1214 + ]
1215 +
1216 + [[package]]
1217 + name = "serde_json"
1218 + version = "1.0.150"
1219 + source = "registry+https://github.com/rust-lang/crates.io-index"
1220 + checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
1221 + dependencies = [
1222 + "itoa",
1223 + "memchr",
1224 + "serde",
1225 + "serde_core",
1226 + "zmij",
1212 1227 ]
1213 1228
1214 1229 [[package]]
@@ -1335,6 +1350,17 @@
1335 1350 "unicode-ident",
1336 1351 ]
1337 1352
1353 + [[package]]
1354 + name = "syn"
1355 + version = "3.0.0"
1356 + source = "registry+https://github.com/rust-lang/crates.io-index"
1357 + checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
1358 + dependencies = [
1359 + "proc-macro2",
1360 + "quote",
1361 + "unicode-ident",
1362 + ]
1363 +
1338 1364 [[package]]
1339 1365 name = "terminfo"
1340 1366 version = "0.9.0"
@@ -1765,3 +1791,9 @@
1765 1791 version = "0.46.0"
1766 1792 source = "registry+https://github.com/rust-lang/crates.io-index"
1767 1793 checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
1794 +
1795 + [[package]]
1796 + name = "zmij"
1797 + version = "1.0.23"
1798 + source = "registry+https://github.com/rust-lang/crates.io-index"
1799 + checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -17,4 +17,6 @@
17 17 anyhow = "1"
18 18 clap = { version = "4", features = ["derive"] }
19 19 ratatui.workspace = true
20 + serde = { version = "1.0.229", features = ["derive"] }
21 + serde_json = "1.0.150"
20 22 theme-common.workspace = true
@@ -6,6 +6,7 @@
6 6 //!
7 7 //! <!-- wiki: alloy-console -->
8 8
9 + mod audio;
9 10 mod cli;
10 11 mod net;
11 12 mod shell;
@@ -31,6 +32,8 @@
31 32 enum Command {
32 33 /// Network interfaces and connections
33 34 Net,
35 + /// Audio outputs and inputs
36 + Audio,
34 37 }
35 38
36 39 fn main() -> Result<()> {
@@ -43,5 +46,9 @@
43 46 let mut view = net::NetView::new(&mut log);
44 47 shell::run(&theme, &mut view, &mut log)
45 48 }
49 + Command::Audio => {
50 + let mut view = audio::AudioView::new(&mut log);
51 + shell::run(&theme, &mut view, &mut log)
52 + }
46 53 }
47 54 }
@@ -6,7 +6,7 @@
6 6 //! and demoed on a machine whose real network state you would rather not
7 7 //! touch.
8 8
9 - use alloy_tui::{AlloyBlock, AlloyList, Hint, Severity, Theme, hint, text};
9 + use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text};
10 10 use anyhow::Result;
11 11 use ratatui::Frame;
12 12 use ratatui::crossterm::event::{KeyCode, KeyEvent};
@@ -260,7 +260,7 @@
260 260 pub struct NetView {
261 261 backend: Box<dyn Backend>,
262 262 interfaces: Vec<Interface>,
263 - selected: usize,
263 + cursor: Cursor,
264 264 error: Option<String>,
265 265 }
266 266
@@ -269,7 +269,7 @@
269 269 let mut view = Self {
270 270 backend: detect(),
271 271 interfaces: Vec::new(),
272 - selected: 0,
272 + cursor: Cursor::new(),
273 273 error: None,
274 274 };
275 275 view.refresh(log);
@@ -280,26 +280,15 @@
280 280 match self.backend.list(log) {
281 281 Ok(interfaces) => {
282 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));
283 + // Refresh can shrink the list (an interface went away); the
284 + // cursor clamps itself back into range.
285 + self.cursor.resize(self.interfaces.len());
286 286 self.error = None;
287 287 }
288 288 Err(err) => self.error = Some(err.to_string()),
289 289 }
290 290 }
291 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 292 fn row<'a>(&self, theme: &Theme, iface: &'a Interface) -> Line<'a> {
304 293 let address = iface
305 294 .addresses
@@ -356,13 +345,16 @@
356 345 .iter()
357 346 .map(|iface| self.row(theme, iface))
358 347 .collect();
359 - frame.render_widget(AlloyList::new(theme, rows).selected(Some(self.selected)), inner);
348 + frame.render_widget(
349 + AlloyList::new(theme, rows).selected(self.cursor.selected()),
350 + inner,
351 + );
360 352 }
361 353
362 354 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
363 355 match key.code {
364 - KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
365 - KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
356 + KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
357 + KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
366 358 KeyCode::Char('r') => self.refresh(log),
367 359 _ => {}
368 360 }
@@ -489,45 +481,58 @@
489 481 assert_eq!(ifaces[0].state, State::Connected);
490 482 }
491 483
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() {
484 + fn mock_view() -> (NetView, CommandLog) {
485 + let mut log = CommandLog::new();
496 486 let mut view = NetView {
497 487 backend: Box::new(Mock),
498 488 interfaces: Vec::new(),
499 - selected: 0,
489 + cursor: Cursor::new(),
500 490 error: None,
501 491 };
502 - let mut log = CommandLog::new();
503 492 view.refresh(&mut log);
504 - view.move_selection(2);
505 - assert_eq!(view.selected, 2);
493 + (view, log)
494 + }
495 +
496 + // Cursor's own tests cover the clamping; this checks the wiring, that
497 + // refresh actually tells the cursor the new length. Without that call the
498 + // cursor keeps pointing at a row that no longer exists.
499 + #[test]
500 + fn refresh_resizes_the_cursor_when_the_list_shrinks() {
501 + let (mut view, mut log) = mock_view();
502 + view.cursor.move_by(2);
503 + assert_eq!(view.cursor.selected(), Some(2));
506 504
507 505 view.backend = Box::new(EmptyBackend);
508 506 view.refresh(&mut log);
509 - assert_eq!(view.selected, 0, "cursor cannot point past the end");
507 + assert_eq!(view.cursor.selected(), None, "no selection in an empty list");
510 508 }
511 509
510 + // A failed refresh must leave the last good list on screen rather than
511 + // blanking it, and surface the error in the status area.
512 512 #[test]
513 - fn selection_does_not_move_past_the_ends() {
514 - let mut view = NetView {
515 - backend: Box::new(Mock),
516 - interfaces: Vec::new(),
517 - selected: 0,
518 - error: None,
519 - };
520 - let mut log = CommandLog::new();
521 - view.refresh(&mut log);
513 + fn a_failed_refresh_keeps_the_previous_interfaces() {
514 + let (mut view, mut log) = mock_view();
515 + assert_eq!(view.interfaces.len(), 3);
522 516
523 - view.move_selection(-1);
524 - assert_eq!(view.selected, 0, "no underflow at the top");
525 - view.move_selection(99);
526 - assert_eq!(view.selected, 2, "clamped at the last row");
517 + view.backend = Box::new(FailingBackend);
518 + view.refresh(&mut log);
519 + assert_eq!(view.interfaces.len(), 3, "the stale list is still shown");
520 + assert!(view.error.is_some(), "the failure is surfaced");
527 521 }
528 522
529 523 struct EmptyBackend;
530 524
525 + struct FailingBackend;
526 +
527 + impl Backend for FailingBackend {
528 + fn name(&self) -> &'static str {
529 + "failing"
530 + }
531 + fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> {
532 + anyhow::bail!("nmcli went away")
533 + }
534 + }
535 +
531 536 impl Backend for EmptyBackend {
532 537 fn name(&self) -> &'static str {
533 538 "empty"
@@ -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 @@
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};
@@ -1,0 +1,669 @@
1 + //! `alloy audio` — a PipeWire front, via `pactl`.
2 + //!
3 + //! docs/CONSOLE.md names this a "wpctl / pactl front" and either would do, but
4 + //! `wpctl status` renders a box-drawing tree meant for human eyes: parsing it
5 + //! means guessing at indentation and glyphs that exist to look right, not to
6 + //! be read back. `pactl -f json` is a stated contract. After the nmcli
7 + //! escaping mistake, a format that documents itself is worth a `serde_json`
8 + //! dependency.
9 + //!
10 + //! The same mock-or-real detection as `net` applies: a machine with no
11 + //! PipeWire still gets a usable screen.
12 +
13 + use std::collections::HashMap;
14 +
15 + use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text};
16 + use anyhow::{Context, Result};
17 + use ratatui::Frame;
18 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
19 + use ratatui::layout::Rect;
20 + use ratatui::text::{Line, Span};
21 + use serde::Deserialize;
22 +
23 + use crate::cli::{CommandLog, Invocation};
24 + use crate::shell::{Flow, View, block_title};
25 +
26 + /// How much one keypress moves the volume. Matches the step swayosd uses for
27 + /// the Fn keys, so the console and the hardware keys agree.
28 + const VOLUME_STEP: u8 = 5;
29 +
30 + /// PipeWire's unity volume — `pactl`'s raw values are relative to this, not to
31 + /// 100. Reading `value_percent` instead would mean parsing a localized string
32 + /// with a `%` glued to it.
33 + const VOLUME_UNITY: u32 = 65536;
34 +
35 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 + pub enum Direction {
37 + Output,
38 + Input,
39 + }
40 +
41 + impl Direction {
42 + const fn label(self) -> &'static str {
43 + match self {
44 + Direction::Output => "output",
45 + Direction::Input => "input",
46 + }
47 + }
48 +
49 + /// The `pactl` noun for this direction. `pactl` spells its subcommands per
50 + /// device class (`set-sink-volume`, `set-source-mute`), so every action
51 + /// interpolates this.
52 + const fn noun(self) -> &'static str {
53 + match self {
54 + Direction::Output => "sink",
55 + Direction::Input => "source",
56 + }
57 + }
58 + }
59 +
60 + #[derive(Debug, Clone)]
61 + pub struct Device {
62 + pub name: String,
63 + pub description: String,
64 + pub direction: Direction,
65 + pub volume: u8,
66 + pub muted: bool,
67 + pub is_default: bool,
68 + }
69 +
70 + impl Device {
71 + fn severity(&self) -> Severity {
72 + match (self.is_default, self.muted) {
73 + (_, true) => Severity::Warn,
74 + (true, false) => Severity::Healthy,
75 + (false, false) => Severity::Info,
76 + }
77 + }
78 +
79 + fn state_label(&self) -> &'static str {
80 + match (self.is_default, self.muted) {
81 + (true, true) => "default, muted",
82 + (true, false) => "default",
83 + (false, true) => "muted",
84 + (false, false) => "",
85 + }
86 + }
87 + }
88 +
89 + pub trait Backend {
90 + fn name(&self) -> &'static str;
91 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Device>>;
92 +
93 + /// Set a device's volume, as a percentage of unity.
94 + fn set_volume(&self, device: &Device, percent: u8, log: &mut CommandLog) -> Result<()>;
95 +
96 + /// Toggle a device's mute.
97 + fn toggle_mute(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
98 +
99 + /// Make a device the default for its direction.
100 + fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
101 + }
102 +
103 + /// Pick a backend: `pactl` when it answers, the mock otherwise.
104 + pub fn detect() -> Box<dyn Backend> {
105 + if Invocation::new("pactl").arg("--version").probe() {
106 + Box::new(PaCtl)
107 + } else {
108 + Box::new(Mock)
109 + }
110 + }
111 +
112 + pub struct PaCtl;
113 +
114 + impl Backend for PaCtl {
115 + fn name(&self) -> &'static str {
116 + "pactl"
117 + }
118 +
119 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
120 + // Four invocations, all logged. That is more log noise than `net`'s
121 + // single line, but `pactl` has no combined form and inventing one by
122 + // hiding three of the four would break the pane's contract that what
123 + // you see is what ran.
124 + let sinks = Invocation::new("pactl")
125 + .args(["-f", "json", "list", "sinks"])
126 + .run(log)?;
127 + let default_sink = Invocation::new("pactl").arg("get-default-sink").run(log)?;
128 + let sources = Invocation::new("pactl")
129 + .args(["-f", "json", "list", "sources"])
130 + .run(log)?;
131 + let default_source = Invocation::new("pactl").arg("get-default-source").run(log)?;
132 +
133 + let mut devices = parse_devices(&sinks, Direction::Output, default_sink.trim())
134 + .context("parsing sinks")?;
135 + devices.extend(
136 + parse_devices(&sources, Direction::Input, default_source.trim())
137 + .context("parsing sources")?,
138 + );
139 + Ok(devices)
140 + }
141 +
142 + fn set_volume(&self, device: &Device, percent: u8, log: &mut CommandLog) -> Result<()> {
143 + Invocation::new("pactl")
144 + .arg(format!("set-{}-volume", device.direction.noun()))
145 + .arg(&device.name)
146 + .arg(format!("{percent}%"))
147 + .run(log)
148 + .map(drop)
149 + }
150 +
151 + fn toggle_mute(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
152 + Invocation::new("pactl")
153 + .arg(format!("set-{}-mute", device.direction.noun()))
154 + .arg(&device.name)
155 + .arg("toggle")
156 + .run(log)
157 + .map(drop)
158 + }
159 +
160 + fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
161 + Invocation::new("pactl")
162 + .arg(format!("set-default-{}", device.direction.noun()))
163 + .arg(&device.name)
164 + .run(log)
165 + .map(drop)
166 + }
167 + }
168 +
169 + /// Fixed sample state, for machines without PipeWire.
170 + pub struct Mock;
171 +
172 + impl Backend for Mock {
173 + fn name(&self) -> &'static str {
174 + "mock"
175 + }
176 +
177 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
178 + log.record("# no PipeWire; showing mock devices", Severity::Warn);
179 + Ok(vec![
180 + Device {
181 + name: "alsa_output.analog-stereo".into(),
182 + description: "Analog Stereo".into(),
183 + direction: Direction::Output,
184 + volume: 90,
185 + muted: false,
186 + is_default: true,
187 + },
188 + Device {
189 + name: "alsa_output.hdmi-stereo".into(),
190 + description: "HDMI Stereo".into(),
191 + direction: Direction::Output,
192 + volume: 100,
193 + muted: true,
194 + is_default: false,
195 + },
196 + Device {
197 + name: "alsa_input.analog-stereo".into(),
198 + description: "Analog Stereo Microphone".into(),
199 + direction: Direction::Input,
200 + volume: 75,
201 + muted: false,
202 + is_default: true,
203 + },
204 + ])
205 + }
206 +
207 + // The mock is a display fixture, not a simulator. Mutating it would show
208 + // the user a volume change that did not happen to any real device, which
209 + // is worse than plainly declining.
210 + fn set_volume(&self, _device: &Device, _percent: u8, log: &mut CommandLog) -> Result<()> {
211 + log.record("# mock backend: volume unchanged", Severity::Warn);
212 + Ok(())
213 + }
214 +
215 + fn toggle_mute(&self, _device: &Device, log: &mut CommandLog) -> Result<()> {
216 + log.record("# mock backend: mute unchanged", Severity::Warn);
217 + Ok(())
218 + }
219 +
220 + fn set_default(&self, _device: &Device, log: &mut CommandLog) -> Result<()> {
221 + log.record("# mock backend: default unchanged", Severity::Warn);
222 + Ok(())
223 + }
224 + }
225 +
226 + // ---- pactl JSON ----
227 +
228 + #[derive(Deserialize)]
229 + struct PaDevice {
230 + name: String,
231 + #[serde(default)]
232 + description: String,
233 + #[serde(default)]
234 + mute: bool,
235 + #[serde(default)]
236 + volume: HashMap<String, PaChannel>,
237 + /// Field with two different meanings depending on which list it came from,
238 + /// which is a trap worth spelling out.
239 + ///
240 + /// On a **source** it names the sink this source is a monitor *of*, and is
241 + /// empty for real capture hardware. On a **sink** it names the monitor
242 + /// that sink *has*, and is non-empty for essentially every sink. So it
243 + /// identifies monitors only among sources; applied to sinks it matches
244 + /// almost all of them.
245 + #[serde(default)]
246 + monitor_source: String,
247 + }
248 +
249 + #[derive(Deserialize)]
250 + struct PaChannel {
251 + value: u32,
252 + }
253 +
254 + fn parse_devices(raw: &str, direction: Direction, default_name: &str) -> Result<Vec<Device>> {
255 + let parsed: Vec<PaDevice> = serde_json::from_str(raw).context("pactl emitted invalid JSON")?;
256 +
257 + Ok(parsed
258 + .into_iter()
259 + .filter(|device| !is_monitor(device, direction))
260 + .map(|device| Device {
261 + volume: channel_volume(&device.volume),
262 + muted: device.mute,
263 + is_default: device.name == default_name,
264 + // A device with no description falls back to its node name, which
265 + // is ugly but identifies the thing; an empty row does not.
266 + description: if device.description.is_empty() {
267 + device.name.clone()
268 + } else {
269 + device.description
270 + },
271 + name: device.name,
272 + direction,
273 + })
274 + .collect())
275 + }
276 +
277 + /// Is this device a monitor (a loopback of an output rather than real capture
278 + /// hardware)?
279 + ///
280 + /// Only sources can be. `monitor_source` is populated on sinks too, with the
281 + /// opposite meaning, so the direction check is what keeps this from matching
282 + /// every output.
283 + fn is_monitor(device: &PaDevice, direction: Direction) -> bool {
284 + direction == Direction::Input && !device.monitor_source.is_empty()
285 + }
286 +
287 + /// Collapse a per-channel volume map to one number.
288 + ///
289 + /// The loudest channel, not an average: a device with one channel at 0 is not
290 + /// "half volume", and showing it as such would explain nothing about why the
291 + /// audio sounds wrong. Channel order is not meaningful here, so the max is
292 + /// also stable across the map's arbitrary iteration order.
293 + fn channel_volume(channels: &HashMap<String, PaChannel>) -> u8 {
294 + let raw = channels.values().map(|c| c.value).max().unwrap_or(0);
295 + // Volumes can exceed unity (PipeWire allows boost); clamp so the display
296 + // stays a percentage a user can reason about.
297 + let percent = (u64::from(raw) * 100).div_ceil(u64::from(VOLUME_UNITY));
298 + percent.min(100) as u8
299 + }
300 +
301 + /// The `alloy audio` screen.
302 + pub struct AudioView {
303 + backend: Box<dyn Backend>,
304 + devices: Vec<Device>,
305 + cursor: Cursor,
306 + error: Option<String>,
307 + }
308 +
309 + impl AudioView {
310 + pub fn new(log: &mut CommandLog) -> Self {
311 + let mut view = Self {
312 + backend: detect(),
313 + devices: Vec::new(),
314 + cursor: Cursor::new(),
315 + error: None,
316 + };
317 + view.refresh(log);
318 + view
319 + }
320 +
321 + fn refresh(&mut self, log: &mut CommandLog) {
322 + match self.backend.list(log) {
323 + Ok(devices) => {
324 + self.devices = devices;
325 + self.cursor.resize(self.devices.len());
326 + self.error = None;
327 + }
328 + Err(err) => self.error = Some(err.to_string()),
329 + }
330 + }
331 +
332 + fn selected(&self) -> Option<&Device> {
333 + self.devices.get(self.cursor.selected()?)
334 + }
335 +
336 + /// Run an action against the selected device, then re-read state.
337 + ///
338 + /// The re-read is what keeps the screen honest: `pactl` may clamp or round
339 + /// what was asked for, so the displayed volume is always what the daemon
340 + /// reports rather than what the console requested.
341 + fn act(
342 + &mut self,
343 + log: &mut CommandLog,
344 + action: impl FnOnce(&dyn Backend, &Device, &mut CommandLog) -> Result<()>,
345 + ) {
346 + let Some(device) = self.selected().cloned() else {
347 + return;
348 + };
349 + match action(self.backend.as_ref(), &device, log) {
350 + Ok(()) => self.refresh(log),
351 + Err(err) => self.error = Some(err.to_string()),
352 + }
353 + }
354 +
355 + fn nudge_volume(&mut self, log: &mut CommandLog, delta: i16) {
356 + let Some(device) = self.selected() else {
357 + return;
358 + };
359 + // Clamped here rather than handed to pactl as a relative `+5%`, so the
360 + // console never asks for a volume above unity. Boost is a real thing
361 + // users want occasionally, but not from a key that repeats.
362 + let target = (i16::from(device.volume) + delta).clamp(0, 100) as u8;
363 + self.act(log, move |backend, device, log| {
364 + backend.set_volume(device, target, log)
365 + });
366 + }
367 +
368 + fn row<'a>(&self, theme: &Theme, device: &'a Device) -> Line<'a> {
369 + let volume = if device.muted {
370 + " --".to_string()
371 + } else {
372 + format!("{:>3}%", device.volume)
373 + };
374 +
375 + Line::from(vec![
376 + text::bold(theme, format!("{:<32}", truncate(&device.description, 31))),
377 + text::muted(theme, format!("{:<8}", device.direction.label())),
378 + Span::styled(format!("{volume:<6}"), device.severity().style(theme)),
379 + text::secondary(theme, device.state_label()),
380 + ])
381 + }
382 + }
383 +
384 + /// Clip a description to `width` columns, marking the clip.
385 + ///
386 + /// Counts `char`s rather than bytes: device descriptions carry non-ASCII
387 + /// (a "Björn's Headset"), and slicing those by byte index panics.
388 + fn truncate(text: &str, width: usize) -> String {
389 + if text.chars().count() <= width {
390 + return text.to_string();
391 + }
392 + let kept: String = text.chars().take(width.saturating_sub(1)).collect();
393 + format!("{kept}…")
394 + }
395 +
396 + impl View for AudioView {
397 + fn title(&self) -> String {
398 + format!("audio ({})", self.backend.name())
399 + }
400 +
401 + fn hints(&self) -> Vec<Hint> {
402 + vec![
403 + hint("j/k", "select"),
404 + hint("-/+", "volume"),
405 + hint("m", "mute"),
406 + hint("d", "default"),
407 + hint("r", "refresh"),
408 + ]
409 + }
410 +
411 + fn status(&self) -> Option<(Severity, String)> {
412 + self.error
413 + .as_ref()
414 + .map(|message| (Severity::Error, message.clone()))
415 + }
416 +
417 + fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
418 + let block = AlloyBlock::new(theme)
419 + .focused(true)
420 + .build()
421 + .title(block_title(&self.title()));
422 + let inner = block.inner(area);
423 + frame.render_widget(block, area);
424 +
425 + if self.devices.is_empty() {
426 + frame.render_widget(Line::from(text::muted(theme, "no audio devices")), inner);
427 + return;
428 + }
429 +
430 + let rows: Vec<Line> = self
431 + .devices
432 + .iter()
433 + .map(|device| self.row(theme, device))
434 + .collect();
435 + frame.render_widget(
436 + AlloyList::new(theme, rows).selected(self.cursor.selected()),
437 + inner,
438 + );
439 + }
440 +
441 + fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
442 + match key.code {
443 + KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
444 + KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
445 + // `=` alongside `+` so the shifted key is not required.
446 + KeyCode::Char('+') | KeyCode::Char('=') => {
447 + self.nudge_volume(log, i16::from(VOLUME_STEP));
448 + }
449 + KeyCode::Char('-') => self.nudge_volume(log, -i16::from(VOLUME_STEP)),
450 + KeyCode::Char('m') => self.act(log, |backend, device, log| {
451 + backend.toggle_mute(device, log)
452 + }),
453 + KeyCode::Char('d') => self.act(log, |backend, device, log| {
454 + backend.set_default(device, log)
455 + }),
456 + KeyCode::Char('r') => self.refresh(log),
457 + _ => {}
458 + }
459 + Flow::Continue
460 + }
461 + }
462 +
463 + #[cfg(test)]
464 + mod tests {
465 + use super::*;
466 +
467 + // Captured from `pactl -f json list sinks` on PipeWire 1.5.84, with the
468 + // enormous `properties` blob dropped (the parser ignores it) and a second
469 + // sink added to give the list more than one row. Everything the parser
470 + // reads is verbatim.
471 + const SINKS: &str = r#"[
472 + {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo",
473 + "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
474 + "volume":{"front-left":{"value":58980,"value_percent":"90%","db":"-2.75 dB"},
475 + "front-right":{"value":58980,"value_percent":"90%","db":"-2.75 dB"}},
476 + "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor"},
477 + {"index":70,"state":"RUNNING","name":"alsa_output.hdmi-stereo",
478 + "description":"HDMI Stereo","mute":true,
479 + "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
480 + "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
481 + "monitor_source":""}
482 + ]"#;
483 +
484 + // Captured from `pactl -f json list sources`. The middle entry is a
485 + // monitor, which is the case the filter exists for.
486 + const SOURCES: &str = r#"[
487 + {"index":60,"state":"SUSPENDED","name":"alsa_input.acp-pdm-mach.stereo-fallback",
488 + "description":"ACP/ACP3X/ACP6x Audio Coprocessor Stereo","mute":false,
489 + "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
490 + "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
491 + "monitor_source":""},
492 + {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor",
493 + "description":"Monitor of Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
494 + "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
495 + "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo"},
496 + {"index":62,"state":"SUSPENDED","name":"alsa_input.pci-0000_c1_00.6.analog-stereo",
497 + "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
498 + "volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}},
499 + "monitor_source":""}
500 + ]"#;
Lines truncated
@@ -1,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 + }