Skip to main content

max / alloy_tui

47.5 KB · 1344 lines History Blame Raw
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 //! # Two panes
11 //!
12 //! Streams on the left, devices on the right, joined by a connector through
13 //! the gutter. A stream is an app or service moving audio; a device is the
14 //! hardware it moves through. Every stream is routed to exactly one device,
15 //! which is what makes the pairing drawable at all.
16 //!
17 //! On vocabulary: the left pane holds *streams*, not "sources". PulseAudio and
18 //! PipeWire already use `source` for a capture device, and this module's
19 //! `Direction::Input` means exactly that. Naming the app column "sources"
20 //! would put the word next to a logged `pactl move-sink-input` meaning
21 //! something else, and the log pane's whole job is teaching that vocabulary.
22 //!
23 //! Not a patchbay. PipeWire's real graph is port-level and many-to-many, and
24 //! even one playing stream produces eight `pw-link` entries on a stereo setup.
25 //! Routing a stream to a device covers what people actually want ("move this
26 //! to my headphones"); the general graph is Helvum's job.
27
28 use std::collections::HashMap;
29
30 use alloy_tui::{
31 AlloyBlock, AlloyConnector, AlloyList, Cursor, FocusRing, Hint, Severity, Theme, hint, layout,
32 list_row_y, text,
33 };
34 use anyhow::{Context, Result};
35 use ratatui::Frame;
36 use ratatui::crossterm::event::{KeyCode, KeyEvent};
37 use ratatui::layout::Rect;
38 use ratatui::text::{Line, Span};
39 use serde::Deserialize;
40
41 use crate::cli::{CommandLog, Invocation};
42 use crate::shell::{Flow, View, block_title};
43
44 /// How much one keypress moves the volume. Matches the step swayosd uses for
45 /// the Fn keys, so the console and the hardware keys agree.
46 const VOLUME_STEP: u8 = 5;
47
48 /// PipeWire's unity volume — `pactl`'s raw values are relative to this, not to
49 /// 100. Reading `value_percent` instead would mean parsing a localized string
50 /// with a `%` glued to it.
51 const VOLUME_UNITY: u32 = 65536;
52
53 /// Ticks between device re-reads. Streams come and go with applications and
54 /// are re-read every tick; devices change only when hardware is plugged in, so
55 /// polling them at the same rate would spend four `pactl` spawns a second to
56 /// learn nothing. `r` refreshes both immediately.
57 const DEVICE_POLL_TICKS: u64 = 10;
58
59 /// Pane indices for the focus ring.
60 const PANE_STREAMS: usize = 0;
61 const PANE_DEVICES: usize = 1;
62
63 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
64 pub enum Direction {
65 Output,
66 Input,
67 }
68
69 impl Direction {
70 const fn label(self) -> &'static str {
71 match self {
72 Direction::Output => "output",
73 Direction::Input => "input",
74 }
75 }
76
77 /// The `pactl` noun for a *device* in this direction.
78 const fn device_noun(self) -> &'static str {
79 match self {
80 Direction::Output => "sink",
81 Direction::Input => "source",
82 }
83 }
84
85 /// The `pactl` noun for a *stream* in this direction. Note the asymmetry:
86 /// a stream playing *out* is an input *to a sink*, so playback streams are
87 /// `sink-input` and capture streams are `source-output`.
88 const fn stream_noun(self) -> &'static str {
89 match self {
90 Direction::Output => "sink-input",
91 Direction::Input => "source-output",
92 }
93 }
94 }
95
96 #[derive(Debug, Clone)]
97 pub struct Device {
98 pub index: u32,
99 pub name: String,
100 pub description: String,
101 pub direction: Direction,
102 pub volume: u8,
103 pub muted: bool,
104 pub is_default: bool,
105 }
106
107 impl Device {
108 fn severity(&self) -> Severity {
109 match (self.is_default, self.muted) {
110 (_, true) => Severity::Warn,
111 (true, false) => Severity::Healthy,
112 (false, false) => Severity::Info,
113 }
114 }
115
116 fn state_label(&self) -> &'static str {
117 match (self.is_default, self.muted) {
118 (true, true) => "default, muted",
119 (true, false) => "default",
120 (false, true) => "muted",
121 (false, false) => "",
122 }
123 }
124 }
125
126 /// An application or service moving audio through a device.
127 #[derive(Debug, Clone)]
128 pub struct Stream {
129 pub index: u32,
130 /// Who is playing. `media.name` (what is playing) is deliberately not kept
131 /// alongside it: the streams pane is the narrower of the two and has no
132 /// column to spare, and `media.name` already serves as the last fallback
133 /// for this field when an app sets no name of its own.
134 pub app: String,
135 pub direction: Direction,
136 /// Index of the device this stream is routed to. The pairing the
137 /// connector draws.
138 pub device_index: u32,
139 pub volume: u8,
140 pub muted: bool,
141 /// Paused. PulseAudio's word, kept because it is what the CLI says.
142 pub corked: bool,
143 }
144
145 impl Stream {
146 fn severity(&self) -> Severity {
147 match (self.muted, self.corked) {
148 (true, _) => Severity::Warn,
149 (false, true) => Severity::Info,
150 (false, false) => Severity::Healthy,
151 }
152 }
153
154 fn state_label(&self) -> &'static str {
155 match (self.muted, self.corked) {
156 (true, true) => "muted, idle",
157 (true, false) => "muted",
158 (false, true) => "idle",
159 (false, false) => "playing",
160 }
161 }
162 }
163
164 /// What an action applies to. Devices and streams take the same verbs with
165 /// different `pactl` nouns and identifiers, so the backend takes one of these
166 /// rather than duplicating every method.
167 #[derive(Debug, Clone, Copy)]
168 pub enum Target<'a> {
169 Device(&'a Device),
170 Stream(&'a Stream),
171 }
172
173 impl Target<'_> {
174 /// The `pactl` noun, which is what its subcommands are built from
175 /// (`set-sink-volume`, `set-sink-input-mute`).
176 fn noun(&self) -> &'static str {
177 match self {
178 Target::Device(device) => device.direction.device_noun(),
179 Target::Stream(stream) => stream.direction.stream_noun(),
180 }
181 }
182
183 /// How `pactl` names this target. Devices go by name, which is stable and
184 /// readable in the log; streams go by index, which is all they have.
185 fn id(&self) -> String {
186 match self {
187 Target::Device(device) => device.name.clone(),
188 Target::Stream(stream) => stream.index.to_string(),
189 }
190 }
191
192 fn volume(&self) -> u8 {
193 match self {
194 Target::Device(device) => device.volume,
195 Target::Stream(stream) => stream.volume,
196 }
197 }
198 }
199
200 pub trait Backend {
201 fn name(&self) -> &'static str;
202 fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>>;
203 fn list_streams(&self, log: &mut CommandLog) -> Result<Vec<Stream>>;
204
205 /// Set a target's volume, as a percentage of unity.
206 fn set_volume(&self, target: Target<'_>, percent: u8, log: &mut CommandLog) -> Result<()>;
207
208 /// Toggle a target's mute.
209 fn toggle_mute(&self, target: Target<'_>, log: &mut CommandLog) -> Result<()>;
210
211 /// Make a device the default for its direction.
212 fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
213
214 /// Route a stream to a device. The pairing edit.
215 fn move_stream(&self, stream: &Stream, device: &Device, log: &mut CommandLog) -> Result<()>;
216 }
217
218 /// Pick a backend: `pactl` when it answers, the mock otherwise.
219 pub fn detect() -> Box<dyn Backend> {
220 if Invocation::new("pactl").arg("--version").probe() {
221 Box::new(PaCtl)
222 } else {
223 Box::new(Mock)
224 }
225 }
226
227 pub struct PaCtl;
228
229 impl Backend for PaCtl {
230 fn name(&self) -> &'static str {
231 "pactl"
232 }
233
234 fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
235 let sinks = Invocation::new("pactl")
236 .args(["-f", "json", "list", "sinks"])
237 .run(log)?;
238 let default_sink = Invocation::new("pactl").arg("get-default-sink").run(log)?;
239 let sources = Invocation::new("pactl")
240 .args(["-f", "json", "list", "sources"])
241 .run(log)?;
242 let default_source = Invocation::new("pactl").arg("get-default-source").run(log)?;
243
244 let mut devices = parse_devices(&sinks, Direction::Output, default_sink.trim())
245 .context("parsing sinks")?;
246 devices.extend(
247 parse_devices(&sources, Direction::Input, default_source.trim())
248 .context("parsing sources")?,
249 );
250 Ok(devices)
251 }
252
253 fn list_streams(&self, log: &mut CommandLog) -> Result<Vec<Stream>> {
254 let playback = Invocation::new("pactl")
255 .args(["-f", "json", "list", "sink-inputs"])
256 .run(log)?;
257 let capture = Invocation::new("pactl")
258 .args(["-f", "json", "list", "source-outputs"])
259 .run(log)?;
260
261 let mut streams =
262 parse_streams(&playback, Direction::Output).context("parsing sink-inputs")?;
263 streams.extend(
264 parse_streams(&capture, Direction::Input).context("parsing source-outputs")?,
265 );
266 Ok(streams)
267 }
268
269 fn set_volume(&self, target: Target<'_>, percent: u8, log: &mut CommandLog) -> Result<()> {
270 Invocation::new("pactl")
271 .arg(format!("set-{}-volume", target.noun()))
272 .arg(target.id())
273 .arg(format!("{percent}%"))
274 .run(log)
275 .map(drop)
276 }
277
278 fn toggle_mute(&self, target: Target<'_>, log: &mut CommandLog) -> Result<()> {
279 Invocation::new("pactl")
280 .arg(format!("set-{}-mute", target.noun()))
281 .arg(target.id())
282 .arg("toggle")
283 .run(log)
284 .map(drop)
285 }
286
287 fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
288 Invocation::new("pactl")
289 .arg(format!("set-default-{}", device.direction.device_noun()))
290 .arg(&device.name)
291 .run(log)
292 .map(drop)
293 }
294
295 fn move_stream(&self, stream: &Stream, device: &Device, log: &mut CommandLog) -> Result<()> {
296 Invocation::new("pactl")
297 .arg(format!("move-{}", stream.direction.stream_noun()))
298 .arg(stream.index.to_string())
299 .arg(&device.name)
300 .run(log)
301 .map(drop)
302 }
303 }
304
305 /// Fixed sample state, for machines without PipeWire.
306 pub struct Mock;
307
308 impl Backend for Mock {
309 fn name(&self) -> &'static str {
310 "mock"
311 }
312
313 fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
314 log.record("# no PipeWire; showing mock devices", Severity::Warn);
315 Ok(vec![
316 Device {
317 index: 1,
318 name: "alsa_output.analog-stereo".into(),
319 description: "Analog Stereo".into(),
320 direction: Direction::Output,
321 volume: 90,
322 muted: false,
323 is_default: true,
324 },
325 Device {
326 index: 2,
327 name: "alsa_output.hdmi-stereo".into(),
328 description: "HDMI Stereo".into(),
329 direction: Direction::Output,
330 volume: 100,
331 muted: true,
332 is_default: false,
333 },
334 Device {
335 index: 3,
336 name: "alsa_input.analog-stereo".into(),
337 description: "Analog Stereo Microphone".into(),
338 direction: Direction::Input,
339 volume: 75,
340 muted: false,
341 is_default: true,
342 },
343 ])
344 }
345
346 fn list_streams(&self, _log: &mut CommandLog) -> Result<Vec<Stream>> {
347 Ok(vec![
348 Stream {
349 index: 100,
350 app: "Firefox".into(),
351 direction: Direction::Output,
352 device_index: 1,
353 volume: 100,
354 muted: false,
355 corked: false,
356 },
357 Stream {
358 index: 101,
359 app: "mpv".into(),
360 direction: Direction::Output,
361 device_index: 2,
362 volume: 80,
363 muted: false,
364 corked: true,
365 },
366 ])
367 }
368
369 // The mock is a display fixture, not a simulator. Mutating it would show
370 // the user a change that did not happen to any real device, which is worse
371 // than plainly declining.
372 fn set_volume(&self, _target: Target<'_>, _percent: u8, log: &mut CommandLog) -> Result<()> {
373 log.record("# mock backend: volume unchanged", Severity::Warn);
374 Ok(())
375 }
376
377 fn toggle_mute(&self, _target: Target<'_>, log: &mut CommandLog) -> Result<()> {
378 log.record("# mock backend: mute unchanged", Severity::Warn);
379 Ok(())
380 }
381
382 fn set_default(&self, _device: &Device, log: &mut CommandLog) -> Result<()> {
383 log.record("# mock backend: default unchanged", Severity::Warn);
384 Ok(())
385 }
386
387 fn move_stream(
388 &self,
389 _stream: &Stream,
390 _device: &Device,
391 log: &mut CommandLog,
392 ) -> Result<()> {
393 log.record("# mock backend: routing unchanged", Severity::Warn);
394 Ok(())
395 }
396 }
397
398 // ---- pactl JSON ----
399
400 #[derive(Deserialize)]
401 struct PaDevice {
402 index: u32,
403 name: String,
404 #[serde(default)]
405 description: String,
406 #[serde(default)]
407 mute: bool,
408 #[serde(default)]
409 volume: HashMap<String, PaChannel>,
410 /// Field with two different meanings depending on which list it came from,
411 /// which is a trap worth spelling out.
412 ///
413 /// On a **source** it names the sink this source is a monitor *of*, and is
414 /// empty for real capture hardware. On a **sink** it names the monitor
415 /// that sink *has*, and is non-empty for essentially every sink. So it
416 /// identifies monitors only among sources; applied to sinks it matches
417 /// almost all of them.
418 #[serde(default)]
419 monitor_source: String,
420 }
421
422 #[derive(Deserialize)]
423 struct PaStream {
424 index: u32,
425 /// Set on a `sink-input`: the sink it plays to.
426 #[serde(default)]
427 sink: Option<u32>,
428 /// Set on a `source-output`: the source it captures from.
429 #[serde(default)]
430 source: Option<u32>,
431 #[serde(default)]
432 mute: bool,
433 #[serde(default)]
434 corked: bool,
435 #[serde(default)]
436 volume: HashMap<String, PaChannel>,
437 #[serde(default)]
438 properties: HashMap<String, serde_json::Value>,
439 }
440
441 #[derive(Deserialize)]
442 struct PaChannel {
443 value: u32,
444 }
445
446 fn parse_devices(raw: &str, direction: Direction, default_name: &str) -> Result<Vec<Device>> {
447 let parsed: Vec<PaDevice> = serde_json::from_str(raw).context("pactl emitted invalid JSON")?;
448
449 Ok(parsed
450 .into_iter()
451 .filter(|device| !is_monitor(device, direction))
452 .map(|device| Device {
453 index: device.index,
454 volume: channel_volume(&device.volume),
455 muted: device.mute,
456 is_default: device.name == default_name,
457 // A device with no description falls back to its node name, which
458 // is ugly but identifies the thing; an empty row does not.
459 description: if device.description.is_empty() {
460 device.name.clone()
461 } else {
462 device.description
463 },
464 name: device.name,
465 direction,
466 })
467 .collect())
468 }
469
470 fn parse_streams(raw: &str, direction: Direction) -> Result<Vec<Stream>> {
471 let parsed: Vec<PaStream> = serde_json::from_str(raw).context("pactl emitted invalid JSON")?;
472
473 Ok(parsed
474 .into_iter()
475 .filter_map(|stream| {
476 // A stream with no device is one PulseAudio is still setting up or
477 // tearing down. It has nothing to pair with, and showing an
478 // unroutable row invites a keypress that cannot work.
479 let device_index = match direction {
480 Direction::Output => stream.sink,
481 Direction::Input => stream.source,
482 }?;
483 Some(Stream {
484 index: stream.index,
485 app: stream_app_name(&stream.properties),
486 direction,
487 device_index,
488 volume: channel_volume(&stream.volume),
489 muted: stream.mute,
490 corked: stream.corked,
491 })
492 })
493 .collect())
494 }
495
496 fn property(properties: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
497 properties
498 .get(key)
499 .and_then(|value| value.as_str())
500 .map(str::to_string)
501 .filter(|s| !s.is_empty())
502 }
503
504 /// Best available human name for a stream.
505 ///
506 /// `application.name` is what an app sets for display and is right when
507 /// present. Failing that the binary name at least identifies the process.
508 /// `media.name` is the last resort because it describes what is playing rather
509 /// than who is playing it, and is often something like "playback".
510 fn stream_app_name(properties: &HashMap<String, serde_json::Value>) -> String {
511 property(properties, "application.name")
512 .or_else(|| property(properties, "application.process.binary"))
513 .or_else(|| property(properties, "media.name"))
514 .unwrap_or_else(|| "unknown".to_string())
515 }
516
517 /// Is this device a monitor (a loopback of an output rather than real capture
518 /// hardware)?
519 ///
520 /// Only sources can be. `monitor_source` is populated on sinks too, with the
521 /// opposite meaning, so the direction check is what keeps this from matching
522 /// every output.
523 fn is_monitor(device: &PaDevice, direction: Direction) -> bool {
524 direction == Direction::Input && !device.monitor_source.is_empty()
525 }
526
527 /// Collapse a per-channel volume map to one number.
528 ///
529 /// The loudest channel, not an average: a device with one channel at 0 is not
530 /// "half volume", and showing it as such would explain nothing about why the
531 /// audio sounds wrong. Channel order is not meaningful here, so the max is
532 /// also stable across the map's arbitrary iteration order.
533 fn channel_volume(channels: &HashMap<String, PaChannel>) -> u8 {
534 let raw = channels.values().map(|c| c.value).max().unwrap_or(0);
535 // Volumes can exceed unity (PipeWire allows boost); clamp so the display
536 // stays a percentage a user can reason about.
537 let percent = (u64::from(raw) * 100).div_ceil(u64::from(VOLUME_UNITY));
538 percent.min(100) as u8
539 }
540
541 /// The `alloy audio` screen.
542 pub struct AudioView {
543 backend: Box<dyn Backend>,
544 devices: Vec<Device>,
545 streams: Vec<Stream>,
546 focus: FocusRing,
547 stream_cursor: Cursor,
548 device_cursor: Cursor,
549 error: Option<String>,
550 ticks: u64,
551 }
552
553 impl AudioView {
554 pub fn new(log: &mut CommandLog) -> Self {
555 let mut view = Self {
556 backend: detect(),
557 devices: Vec::new(),
558 streams: Vec::new(),
559 focus: FocusRing::new(2),
560 stream_cursor: Cursor::new(),
561 device_cursor: Cursor::new(),
562 error: None,
563 ticks: 0,
564 };
565 view.refresh_devices(log);
566 view.refresh_streams(log);
567 view
568 }
569
570 // Neither refresh clears `error` on success, which is deliberate and not
571 // an oversight. Refreshes run on the background tick, so clearing there
572 // would wipe an error the user needs to read within a second of it
573 // appearing — a rejected route would flash and vanish before it could be
574 // read. Errors are cleared by the next keypress instead (see `handle`),
575 // which is the point at which the user has moved on.
576
577 fn refresh_devices(&mut self, log: &mut CommandLog) {
578 match self.backend.list_devices(log) {
579 Ok(devices) => {
580 self.devices = devices;
581 self.device_cursor.resize(self.devices.len());
582 }
583 Err(err) => self.error = Some(err.to_string()),
584 }
585 }
586
587 fn refresh_streams(&mut self, log: &mut CommandLog) {
588 match self.backend.list_streams(log) {
589 Ok(streams) => {
590 self.streams = streams;
591 self.stream_cursor.resize(self.streams.len());
592 }
593 Err(err) => self.error = Some(err.to_string()),
594 }
595 }
596
597 fn selected_stream(&self) -> Option<&Stream> {
598 self.streams.get(self.stream_cursor.selected()?)
599 }
600
601 fn selected_device(&self) -> Option<&Device> {
602 self.devices.get(self.device_cursor.selected()?)
603 }
604
605 /// Index into `devices` of the device the selected stream is routed to.
606 fn paired_device_index(&self) -> Option<usize> {
607 let stream = self.selected_stream()?;
608 self.devices
609 .iter()
610 .position(|device| device.index == stream.device_index)
611 }
612
613 /// The target the action keys apply to: whatever the focused pane has
614 /// selected.
615 fn target(&self) -> Option<Target<'_>> {
616 match self.focus.current() {
617 PANE_STREAMS => self.selected_stream().map(Target::Stream),
618 _ => self.selected_device().map(Target::Device),
619 }
620 }
621
622 /// Re-read state after an action.
623 ///
624 /// Quiet: the user asked for the action, not for the four-command re-read
625 /// that confirms it. Logging both buries the command that was actually
626 /// pressed for.
627 fn resync(&mut self, log: &mut CommandLog) {
628 log.quiet(|log| {
629 self.refresh_devices(log);
630 self.refresh_streams(log);
631 });
632 }
633
634 fn set_volume(&mut self, log: &mut CommandLog, delta: i16) {
635 let Some(target) = self.target() else {
636 return;
637 };
638 // Clamped here rather than handed to pactl as a relative `+5%`, so the
639 // console never asks for a volume above unity. Boost is a real thing
640 // users want occasionally, but not from a key that repeats.
641 let percent = (i16::from(target.volume()) + delta).clamp(0, 100) as u8;
642 let result = self.backend.set_volume(target, percent, log);
643 self.finish(result, log);
644 }
645
646 fn toggle_mute(&mut self, log: &mut CommandLog) {
647 let Some(target) = self.target() else {
648 return;
649 };
650 let result = self.backend.toggle_mute(target, log);
651 self.finish(result, log);
652 }
653
654 fn set_default(&mut self, log: &mut CommandLog) {
655 let Some(device) = self.selected_device() else {
656 return;
657 };
658 let result = self.backend.set_default(device, log);
659 self.finish(result, log);
660 }
661
662 /// Route the selected stream to the selected device — the pairing edit.
663 fn route(&mut self, log: &mut CommandLog) {
664 let (Some(stream), Some(device)) = (self.selected_stream(), self.selected_device()) else {
665 return;
666 };
667 // Directions have to agree: a playback stream cannot be routed to a
668 // microphone. pactl would reject it, but saying so here is clearer
669 // than surfacing its error.
670 if stream.direction != device.direction {
671 self.error = Some(format!(
672 "cannot route {} stream to {} device",
673 stream.direction.label(),
674 device.direction.label()
675 ));
676 return;
677 }
678 let result = self.backend.move_stream(stream, device, log);
679 self.finish(result, log);
680 }
681
682 /// Record an action's outcome and re-read state if it worked.
683 fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
684 match result {
685 Ok(()) => self.resync(log),
686 Err(err) => self.error = Some(err.to_string()),
687 }
688 }
689
690 fn stream_row<'a>(&self, theme: &Theme, stream: &'a Stream) -> Line<'a> {
691 let volume = if stream.muted {
692 " --".to_string()
693 } else {
694 format!("{:>3}%", stream.volume)
695 };
696 Line::from(vec![
697 text::bold(theme, format!("{:<18}", truncate(&stream.app, 17))),
698 Span::styled(format!("{volume:<6}"), stream.severity().style(theme)),
699 text::muted(theme, stream.state_label()),
700 ])
701 }
702
703 fn device_row<'a>(&self, theme: &Theme, device: &'a Device) -> Line<'a> {
704 let volume = if device.muted {
705 " --".to_string()
706 } else {
707 format!("{:>3}%", device.volume)
708 };
709 Line::from(vec![
710 text::bold(theme, format!("{:<22}", truncate(&device.description, 21))),
711 Span::styled(format!("{volume:<6}"), device.severity().style(theme)),
712 text::muted(theme, device.state_label()),
713 ])
714 }
715
716 }
717
718 /// Everything one pane needs to draw itself.
719 struct Pane<'a> {
720 title: &'a str,
721 focused: bool,
722 rows: Vec<Line<'a>>,
723 selected: Option<usize>,
724 /// Shown instead of the list when there are no rows. Both panes are
725 /// legitimately empty in normal use — nothing is playing, or there is no
726 /// hardware — so neither should render as a blank box.
727 empty_message: &'a str,
728 }
729
730 /// Draw one pane, returning its inner area so the connector can locate rows
731 /// inside it.
732 fn render_pane(frame: &mut Frame, area: Rect, theme: &Theme, pane: Pane<'_>) -> Rect {
733 let block = AlloyBlock::new(theme)
734 .focused(pane.focused)
735 .build()
736 .title(block_title(pane.title));
737 let inner = block.inner(area);
738 frame.render_widget(block, area);
739
740 if pane.rows.is_empty() {
741 frame.render_widget(Line::from(text::muted(theme, pane.empty_message)), inner);
742 } else {
743 frame.render_widget(
744 AlloyList::new(theme, pane.rows).selected(pane.selected),
745 inner,
746 );
747 }
748 inner
749 }
750
751 /// Clip a description to `width` columns, marking the clip.
752 ///
753 /// Counts `char`s rather than bytes: device descriptions carry non-ASCII
754 /// (a "Björn's Headset"), and slicing those by byte index panics.
755 fn truncate(text: &str, width: usize) -> String {
756 if text.chars().count() <= width {
757 return text.to_string();
758 }
759 let kept: String = text.chars().take(width.saturating_sub(1)).collect();
760 format!("{kept}")
761 }
762
763 impl View for AudioView {
764 fn title(&self) -> String {
765 format!("audio ({})", self.backend.name())
766 }
767
768 fn hints(&self) -> Vec<Hint> {
769 vec![
770 hint("Tab", "pane"),
771 hint("j/k", "select"),
772 hint("Enter", "route"),
773 hint("-/+", "volume"),
774 hint("m", "mute"),
775 hint("d", "default"),
776 hint("r", "refresh"),
777 ]
778 }
779
780 fn status(&self) -> Option<(Severity, String)> {
781 self.error
782 .as_ref()
783 .map(|message| (Severity::Error, message.clone()))
784 }
785
786 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
787 let panes = layout::panes(area);
788
789 let stream_rows: Vec<Line> = self
790 .streams
791 .iter()
792 .map(|stream| self.stream_row(theme, stream))
793 .collect();
794 let left = render_pane(
795 frame,
796 panes.left,
797 theme,
798 Pane {
799 title: "streams",
800 focused: self.focus.is_focused(PANE_STREAMS),
801 rows: stream_rows,
802 selected: self.stream_cursor.selected(),
803 empty_message: "nothing playing",
804 },
805 );
806
807 // A terminal too narrow for two panes shows streams alone. Devices are
808 // reachable by widening; a pair of six-column panes is reachable by
809 // nothing.
810 if panes.right.width == 0 {
811 return;
812 }
813
814 let device_rows: Vec<Line> = self
815 .devices
816 .iter()
817 .map(|device| self.device_row(theme, device))
818 .collect();
819 let right = render_pane(
820 frame,
821 panes.right,
822 theme,
823 Pane {
824 title: "devices",
825 focused: self.focus.is_focused(PANE_DEVICES),
826 rows: device_rows,
827 selected: self.device_cursor.selected(),
828 empty_message: "no audio devices",
829 },
830 );
831
832 // The connector, for the selected stream only.
833 let (Some(stream_index), Some(device_index)) =
834 (self.stream_cursor.selected(), self.paired_device_index())
835 else {
836 return;
837 };
838 let from_y = list_row_y(
839 left,
840 self.streams.len(),
841 self.stream_cursor.selected(),
842 stream_index,
843 );
844 let to_y = list_row_y(
845 right,
846 self.devices.len(),
847 self.device_cursor.selected(),
848 device_index,
849 );
850 if let (Some(from_y), Some(to_y)) = (from_y, to_y) {
851 // The gutter spans the panes' inner rows, so the connector lines
852 // up with list rows rather than with the block borders.
853 let gutter = Rect {
854 y: left.y,
855 height: left.height,
856 ..panes.gutter
857 };
858 frame.render_widget(AlloyConnector::new(theme, from_y, to_y), gutter);
859 }
860 }
861
862 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
863 // Any keypress dismisses the previous error. Actions below may set a
864 // new one, which then survives until the user presses another key
865 // rather than until the next background tick.
866 self.error = None;
867
868 match alloy_tui::classify(key) {
869 alloy_tui::Action::NextFocus => {
870 self.focus.next();
871 return Flow::Continue;
872 }
873 alloy_tui::Action::PrevFocus => {
874 self.focus.prev();
875 return Flow::Continue;
876 }
877 alloy_tui::Action::Activate => {
878 self.route(log);
879 return Flow::Continue;
880 }
881 _ => {}
882 }
883
884 let cursor = if self.focus.is_focused(PANE_STREAMS) {
885 &mut self.stream_cursor
886 } else {
887 &mut self.device_cursor
888 };
889
890 match key.code {
891 KeyCode::Char('j') | KeyCode::Down => cursor.next(),
892 KeyCode::Char('k') | KeyCode::Up => cursor.prev(),
893 // `=` alongside `+` so the shifted key is not required.
894 KeyCode::Char('+') | KeyCode::Char('=') => {
895 self.set_volume(log, i16::from(VOLUME_STEP));
896 }
897 KeyCode::Char('-') => self.set_volume(log, -i16::from(VOLUME_STEP)),
898 KeyCode::Char('m') => self.toggle_mute(log),
899 KeyCode::Char('d') => self.set_default(log),
900 KeyCode::Char('r') => {
901 // An explicit refresh is a user action, so it logs.
902 self.refresh_devices(log);
903 self.refresh_streams(log);
904 }
905 _ => {}
906 }
907 Flow::Continue
908 }
909
910 fn tick(&mut self, log: &mut CommandLog) {
911 self.ticks += 1;
912 let poll_devices = self.ticks % DEVICE_POLL_TICKS == 0;
913 log.quiet(|log| {
914 self.refresh_streams(log);
915 if poll_devices {
916 self.refresh_devices(log);
917 }
918 });
919 }
920 }
921
922 #[cfg(test)]
923 mod tests {
924 use super::*;
925
926 // Captured from `pactl -f json list sinks` on PipeWire 1.5.84, with the
927 // enormous `properties` blob dropped (the parser ignores it) and a second
928 // sink added to give the list more than one row. Everything the parser
929 // reads is verbatim.
930 const SINKS: &str = r#"[
931 {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo",
932 "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
933 "volume":{"front-left":{"value":58980,"value_percent":"90%","db":"-2.75 dB"},
934 "front-right":{"value":58980,"value_percent":"90%","db":"-2.75 dB"}},
935 "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor"},
936 {"index":70,"state":"RUNNING","name":"alsa_output.hdmi-stereo",
937 "description":"HDMI Stereo","mute":true,
938 "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
939 "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
940 "monitor_source":""}
941 ]"#;
942
943 // Captured from `pactl -f json list sources`. The middle entry is a
944 // monitor, which is the case the filter exists for.
945 const SOURCES: &str = r#"[
946 {"index":60,"state":"SUSPENDED","name":"alsa_input.acp-pdm-mach.stereo-fallback",
947 "description":"ACP/ACP3X/ACP6x Audio Coprocessor Stereo","mute":false,
948 "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"},
949 "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
950 "monitor_source":""},
951 {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor",
952 "description":"Monitor of Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
953 "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
954 "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo"},
955 {"index":62,"state":"SUSPENDED","name":"alsa_input.pci-0000_c1_00.6.analog-stereo",
956 "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false,
957 "volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}},
958 "monitor_source":""}
959 ]"#;
960
961 // Captured from `pactl -f json list sink-inputs`, properties trimmed to
962 // the keys the parser reads, plus a second entry with no
963 // `application.name` to exercise the name fallback.
964 const SINK_INPUTS: &str = r#"[
965 {"index":342,"sink":61,"corked":false,"mute":false,
966 "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}},
967 "properties":{"application.name":"speech-dispatcher-dummy",
968 "application.process.binary":"sd_dummy","media.name":"playback"}},
969 {"index":343,"sink":70,"corked":true,"mute":true,
970 "volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}},
971 "properties":{"application.process.binary":"mpv","media.name":"Some Song"}}
972 ]"#;
973
974 #[test]
975 fn parses_sinks_with_volume_and_mute() {
976 let devices = parse_devices(SINKS, Direction::Output, "alsa_output.hdmi-stereo").unwrap();
977 assert_eq!(devices.len(), 2);
978 assert_eq!(devices[0].index, 61);
979 assert_eq!(devices[0].volume, 90);
980 assert!(!devices[0].muted);
981 assert!(!devices[0].is_default);
982 assert!(devices[1].muted);
983 assert!(devices[1].is_default, "the default sink is matched by name");
984 }
985
986 // A sink's `monitor_source` names the monitor it *has*; a source's names
987 // the sink it *is a monitor of*. Filtering sinks on that field would hide
988 // every real output, which is the bug this test pins down.
989 #[test]
990 fn the_monitor_filter_does_not_swallow_sinks() {
991 let devices = parse_devices(SINKS, Direction::Output, "").unwrap();
992 assert_eq!(devices.len(), 2, "both sinks survive despite a monitor_source");
993 }
994
995 #[test]
996 fn drops_monitor_sources() {
997 let devices = parse_devices(SOURCES, Direction::Input, "").unwrap();
998 assert_eq!(devices.len(), 2, "the monitor source is filtered out");
999 assert!(
1000 devices.iter().all(|d| !d.description.starts_with("Monitor of")),
1001 "no monitor survived the filter"
1002 );
1003 assert_eq!(devices[1].volume, 50);
1004 }
1005
1006 #[test]
1007 fn parses_streams_with_their_device_pairing() {
1008 let streams = parse_streams(SINK_INPUTS, Direction::Output).unwrap();
1009 assert_eq!(streams.len(), 2);
1010 assert_eq!(streams[0].index, 342);
1011 assert_eq!(streams[0].app, "speech-dispatcher-dummy");
1012 assert_eq!(streams[0].device_index, 61, "the pairing the connector draws");
1013 assert!(!streams[0].corked);
1014 assert_eq!(streams[1].volume, 50);
1015 assert!(streams[1].corked);
1016 assert!(streams[1].muted);
1017 }
1018
1019 // Not every stream sets `application.name`; falling through to the binary
1020 // beats showing "unknown", and beats `media.name`, which names the audio
1021 // rather than the app.
1022 #[test]
1023 fn stream_name_falls_back_to_the_binary() {
1024 let streams = parse_streams(SINK_INPUTS, Direction::Output).unwrap();
1025 assert_eq!(streams[1].app, "mpv");
1026 }
1027
1028 // A stream mid-setup has no device. It cannot be paired or routed, so it
1029 // must not occupy a row that invites a keypress that cannot work.
1030 #[test]
1031 fn streams_with_no_device_are_dropped() {
1032 let raw = r#"[{"index":9,"corked":false,"mute":false,"volume":{},"properties":{}}]"#;
1033 assert!(parse_streams(raw, Direction::Output).unwrap().is_empty());
1034 }
1035
1036 // Capture streams carry `source`, not `sink`. Reading the wrong field
1037 // would drop every capture stream as unpaired.
1038 #[test]
1039 fn capture_streams_pair_via_the_source_field() {
1040 let raw = r#"[{"index":5,"source":62,"corked":false,"mute":false,"volume":{},
1041 "properties":{"application.name":"Recorder"}}]"#;
1042 let streams = parse_streams(raw, Direction::Input).unwrap();
1043 assert_eq!(streams.len(), 1);
1044 assert_eq!(streams[0].device_index, 62);
1045 }
1046
1047 #[test]
1048 fn empty_device_list_parses_to_nothing() {
1049 assert!(parse_devices("[]", Direction::Output, "").unwrap().is_empty());
1050 }
1051
1052 #[test]
1053 fn malformed_json_is_an_error_not_an_empty_list() {
1054 assert!(parse_devices("not json", Direction::Output, "").is_err());
1055 assert!(parse_streams("not json", Direction::Output).is_err());
1056 }
1057
1058 // Unity is 65536, not 100. Treating the raw value as a percent would show
1059 // a normal device at "65536%".
1060 #[test]
1061 fn volume_is_scaled_from_unity() {
1062 let full = HashMap::from([("mono".to_string(), PaChannel { value: VOLUME_UNITY })]);
1063 assert_eq!(channel_volume(&full), 100);
1064
1065 let half = HashMap::from([("mono".to_string(), PaChannel { value: VOLUME_UNITY / 2 })]);
1066 assert_eq!(channel_volume(&half), 50);
1067
1068 assert_eq!(channel_volume(&HashMap::new()), 0, "no channels reads as silent");
1069 }
1070
1071 #[test]
1072 fn volume_above_unity_clamps_to_100() {
1073 let boosted = HashMap::from([("mono".to_string(), PaChannel { value: VOLUME_UNITY * 2 })]);
1074 assert_eq!(channel_volume(&boosted), 100);
1075 }
1076
1077 // The loudest channel, not the average: one silent channel of a stereo
1078 // pair must not read as 50%.
1079 #[test]
1080 fn volume_reports_the_loudest_channel() {
1081 let lopsided = HashMap::from([
1082 ("front-left".to_string(), PaChannel { value: VOLUME_UNITY }),
1083 ("front-right".to_string(), PaChannel { value: 0 }),
1084 ]);
1085 assert_eq!(channel_volume(&lopsided), 100);
1086 }
1087
1088 #[test]
1089 fn truncate_marks_clipped_descriptions() {
1090 assert_eq!(truncate("short", 10), "short");
1091 assert_eq!(truncate("a very long device name", 10), "a very lo…");
1092 }
1093
1094 // Slicing a multi-byte description by byte index panics. Device names do
1095 // carry non-ASCII.
1096 #[test]
1097 fn truncate_handles_multibyte_descriptions() {
1098 assert_eq!(truncate("Björn's Headset Pro", 8), "Björn's…");
1099 assert_eq!(truncate("Björn", 10), "Björn");
1100 }
1101
1102 // ---- view behavior ----
1103
1104 fn mock_view() -> (AudioView, CommandLog) {
1105 let mut log = CommandLog::new();
1106 let mut view = AudioView {
1107 backend: Box::new(Mock),
1108 devices: Vec::new(),
1109 streams: Vec::new(),
1110 focus: FocusRing::new(2),
1111 stream_cursor: Cursor::new(),
1112 device_cursor: Cursor::new(),
1113 error: None,
1114 ticks: 0,
1115 };
1116 view.refresh_devices(&mut log);
1117 view.refresh_streams(&mut log);
1118 (view, log)
1119 }
1120
1121 // The pairing the connector draws: stream 0 routes to device index 1,
1122 // which is position 0 in the device list. Matching on the list position
1123 // instead of the device index would be right only by coincidence here.
1124 #[test]
1125 fn pairing_resolves_a_device_index_to_a_list_position() {
1126 let (view, _log) = mock_view();
1127 assert_eq!(view.paired_device_index(), Some(0));
1128 }
1129
1130 #[test]
1131 fn pairing_follows_the_selected_stream() {
1132 let (mut view, _log) = mock_view();
1133 view.stream_cursor.move_by(1);
1134 // Stream 1 routes to device index 2, which is position 1.
1135 assert_eq!(view.paired_device_index(), Some(1));
1136 }
1137
1138 // A stream routed to a device that is not in the list (filtered, or gone
1139 // between the two reads) has no drawable pairing.
1140 #[test]
1141 fn pairing_is_absent_when_the_device_is_missing() {
1142 let (mut view, _log) = mock_view();
1143 view.devices.retain(|d| d.index != 1);
1144 assert_eq!(view.paired_device_index(), None);
1145 }
1146
1147 #[test]
1148 fn tab_moves_focus_between_the_panes() {
1149 let (mut view, _log) = mock_view();
1150 assert!(view.focus.is_focused(PANE_STREAMS));
1151 view.focus.next();
1152 assert!(view.focus.is_focused(PANE_DEVICES));
1153 view.focus.next();
1154 assert!(view.focus.is_focused(PANE_STREAMS), "two panes wrap");
1155 }
1156
1157 // The action keys follow focus, so `m` mutes the app when the stream pane
1158 // is focused and the device when it is not.
1159 #[test]
1160 fn the_action_target_follows_focus() {
1161 let (mut view, _log) = mock_view();
1162 assert!(matches!(view.target(), Some(Target::Stream(_))));
1163 view.focus.focus(PANE_DEVICES);
1164 assert!(matches!(view.target(), Some(Target::Device(_))));
1165 }
1166
1167 // pactl builds its subcommands from these nouns, and the stream case is
1168 // counterintuitive: a stream playing *out* is a `sink-input`.
1169 #[test]
1170 fn targets_use_the_right_pactl_nouns() {
1171 let (mut view, _log) = mock_view();
1172 assert_eq!(view.target().unwrap().noun(), "sink-input");
1173 assert_eq!(view.target().unwrap().id(), "100", "streams go by index");
1174
1175 view.focus.focus(PANE_DEVICES);
1176 assert_eq!(view.target().unwrap().noun(), "sink");
1177 assert_eq!(
1178 view.target().unwrap().id(),
1179 "alsa_output.analog-stereo",
1180 "devices go by name"
1181 );
1182 }
1183
1184 #[test]
1185 fn capture_targets_use_the_source_nouns() {
1186 let (mut view, _log) = mock_view();
1187 view.focus.focus(PANE_DEVICES);
1188 // The third mock device is the microphone.
1189 view.device_cursor.move_by(2);
1190 assert_eq!(view.target().unwrap().noun(), "source");
1191 }
1192
1193 // Routing a playback stream to a microphone is not a thing. pactl would
1194 // refuse, but refusing here says why.
1195 #[test]
1196 fn routing_across_directions_is_refused() {
1197 let (mut view, mut log) = mock_view();
1198 view.device_cursor.move_by(2); // the input device
1199 view.route(&mut log);
1200 let error = view.error.expect("a cross-direction route reports an error");
1201 assert!(error.contains("cannot route"), "got: {error}");
1202 }
1203
1204 #[test]
1205 fn routing_within_a_direction_is_allowed() {
1206 let (mut view, mut log) = mock_view();
1207 view.device_cursor.move_by(1); // the HDMI output
1208 view.route(&mut log);
1209 assert!(view.error.is_none(), "same-direction routing is accepted");
1210 }
1211
1212 // Background polling is console bookkeeping. If it logged, the pane would
1213 // fill with commands nobody pressed a key for.
1214 #[test]
1215 fn ticks_do_not_write_to_the_command_log() {
1216 let (mut view, mut log) = mock_view();
1217 let before = log.entries().len();
1218 for _ in 0..DEVICE_POLL_TICKS * 2 {
1219 view.tick(&mut log);
1220 }
1221 assert_eq!(log.entries().len(), before, "ticks are silent");
1222 }
1223
1224 // Devices are polled on a slower cadence than streams, so the tick counter
1225 // has to actually reach the device poll.
1226 #[test]
1227 fn devices_are_polled_on_the_slower_cadence() {
1228 let (mut view, mut log) = mock_view();
1229 view.devices.clear();
1230 for _ in 0..DEVICE_POLL_TICKS - 1 {
1231 view.tick(&mut log);
1232 }
1233 assert!(view.devices.is_empty(), "not yet re-read");
1234 view.tick(&mut log);
1235 assert!(!view.devices.is_empty(), "re-read on the tenth tick");
1236 }
1237
1238 // The bug this pins: refreshes run on the background tick, so if a
1239 // successful refresh cleared `error`, a rejected route would be readable
1240 // for under a second before a poll wiped it.
1241 #[test]
1242 fn a_background_tick_does_not_clear_an_action_error() {
1243 let (mut view, mut log) = mock_view();
1244 view.device_cursor.move_by(2); // the input device
1245 view.route(&mut log);
1246 assert!(view.error.is_some(), "the route was refused");
1247
1248 for _ in 0..DEVICE_POLL_TICKS + 1 {
1249 view.tick(&mut log);
1250 }
1251 assert!(
1252 view.error.is_some(),
1253 "the error survived a full device-poll cycle"
1254 );
1255 }
1256
1257 #[test]
1258 fn a_keypress_clears_a_stale_error() {
1259 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1260
1261 let (mut view, mut log) = mock_view();
1262 view.device_cursor.move_by(2);
1263 view.route(&mut log);
1264 assert!(view.error.is_some());
1265
1266 view.handle(
1267 KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
1268 &mut log,
1269 );
1270 assert!(view.error.is_none(), "moving on dismisses the error");
1271 }
1272
1273 #[test]
1274 fn acting_with_no_selection_is_inert() {
1275 let mut log = CommandLog::new();
1276 let mut view = AudioView {
1277 backend: Box::new(Mock),
1278 devices: Vec::new(),
1279 streams: Vec::new(),
1280 focus: FocusRing::new(2),
1281 stream_cursor: Cursor::new(),
1282 device_cursor: Cursor::new(),
1283 error: None,
1284 ticks: 0,
1285 };
1286 view.set_volume(&mut log, 5);
1287 view.toggle_mute(&mut log);
1288 view.set_default(&mut log);
1289 view.route(&mut log);
1290 assert!(view.error.is_none(), "no selection is not an error");
1291 }
1292
1293 /// Parse whatever this machine's PipeWire actually reports.
1294 ///
1295 /// Ignored by default because it needs a running PipeWire and its result
1296 /// depends on the hardware. Run it (`cargo test -p alloy -- --ignored
1297 /// --nocapture`) when touching the parser: a fixture only proves the
1298 /// parser handles the output someone imagined it would get, which is
1299 /// exactly how the sink/source `monitor_source` inversion got written.
1300 #[test]
1301 #[ignore = "requires a running PipeWire"]
1302 fn parses_this_machines_real_state() {
1303 let mut log = CommandLog::new();
1304 let devices = PaCtl.list_devices(&mut log).expect("pactl should answer");
1305 let streams = PaCtl.list_streams(&mut log).expect("pactl should answer");
1306
1307 assert!(!devices.is_empty(), "a machine with PipeWire has some device");
1308 assert!(
1309 devices.iter().any(|d| d.direction == Direction::Output),
1310 "at least one output must survive the monitor filter"
1311 );
1312 for device in &devices {
1313 assert!(!device.description.is_empty(), "every row is identifiable");
1314 assert!(device.volume <= 100, "volume is a clamped percentage");
1315 assert!(
1316 !device.description.starts_with("Monitor of"),
1317 "monitor leaked into the list: {}",
1318 device.description
1319 );
1320 println!(
1321 "device {:<8} {:<48} {:>3}% mute={} default={}",
1322 device.direction.label(),
1323 device.description,
1324 device.volume,
1325 device.muted,
1326 device.is_default
1327 );
1328 }
1329 for stream in &streams {
1330 // Every listed stream must resolve to a listed device, or the
1331 // connector has nothing to draw to.
1332 let paired = devices.iter().find(|d| d.index == stream.device_index);
1333 println!(
1334 "stream {:<8} {:<20} -> {:<40} {:>3}%",
1335 stream.direction.label(),
1336 stream.app,
1337 paired.map_or("(unlisted)", |d| d.description.as_str()),
1338 stream.volume
1339 );
1340 assert!(!stream.app.is_empty(), "every stream row is identifiable");
1341 }
1342 }
1343 }
1344