Skip to main content

max / alloy

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
Signed with PGP, not checked
Commit: e28ba8828c4aaedbfb920b70912d28bb140ee6e1
Parent: 0b5a67c
8 files changed, +720 insertions, -73 deletions
M docs/IMAGE.md +7 -5
@@ -26,7 +26,7 @@
26 26
27 27 Universal Blue's convention is exactly the bootc + Containerfile pattern above, using `FROM ghcr.io/ublue-os/main`. Battle-tested. But being downstream of ublue means inheriting ublue's opinions and coupling to their release cadence, which [STACK.md's Base section](STACK.md#base) explicitly rejects.
28 28
29 - **The nuance:** Alloy uses the ublue-style *convention* (Containerfile, OCI registry, CI-driven build) *without* using ublue's base image. `FROM quay.io/fedora/fedora-bootc:42`, not `FROM ghcr.io/ublue-os/main`. That's what "alongside ublue, not downstream" resolves to in practice.
29 + **The nuance:** Alloy uses the ublue-style *convention* (Containerfile, OCI registry, CI-driven build) *without* using ublue's base image. `FROM quay.io/fedora/fedora-bootc:43`, not `FROM ghcr.io/ublue-os/main`. That's what "alongside ublue, not downstream" resolves to in practice.
30 30
31 31 ### "Install Silverblue and run our script" (non-option)
32 32
@@ -50,7 +50,7 @@
50 50
51 51 ### Registry: `quay.io` (deferred until distribution starts)
52 52
53 - Red Hat's container registry is the intended destination once Alloy has an image worth distributing. Free for public images, ecosystem-coherent with the `quay.io/fedora/fedora-bootc:42` base.
53 + Red Hat's container registry is the intended destination once Alloy has an image worth distributing. Free for public images, ecosystem-coherent with the `quay.io/fedora/fedora-bootc:43` base.
54 54
55 55 **Current state:** no registry provisioned. `.builds/alloy-image.yml` runs a **build + lint smoke test** only — validates the Containerfile on every push, without publishing anywhere. When the image is worth distributing, the CI file grows back `sign` and `push` tasks and `quay.io/alloy/alloy` gets provisioned with a robot account.
56 56
@@ -64,7 +64,7 @@
64 64
65 65 The Containerfile at the repo root (see `Containerfile`) sketches:
66 66
67 - 1. **Base:** `FROM quay.io/fedora/fedora-bootc:42`.
67 + 1. **Base:** `FROM quay.io/fedora/fedora-bootc:43`.
68 68 2. **Third-party repos:** Tailscale, any COPRs Alloy depends on for packages not in Fedora main.
69 69 3. **Package additions:** the full Alloy stack from [STACK.md](STACK.md) — compositor, bar, launcher, notifications, terminal, editor, shell, viewers, utilities, continuity daemons, fonts, themes.
70 70 4. **Package removals:** stock Silverblue desktop pieces Alloy replaces (gnome-shell, gdm — the latter gated on the greeter pick).
@@ -86,7 +86,9 @@
86 86
87 87 ## Fedora version tracking
88 88
89 - Pinned to `:42` explicitly. Bumps to `:43` and beyond are a deliberate act — Alloy tests the new base, updates the Containerfile, ships. Not automatic. This is the "correctness over deadline" pattern applied to base tracking.
89 + Pinned explicitly, currently `:43`. Bumps to `:44` and beyond are a deliberate act: Alloy tests the new base, updates the Containerfile, ships. Not automatic. This is the "correctness over deadline" pattern applied to base tracking.
90 +
91 + Note that `build/alloy-0.0.yaml` remains bootc-image-builder's fedora-42 package set, because bib ships no fedora-43 definition. That is deliberate and documented in the file's own header; it is not drift.
90 92
91 93 ## Open questions
92 94
@@ -94,4 +96,4 @@
94 96 - [ ] `bootc-image-builder` for ISO generation. First-time-user path is `bootc install` from a live environment; the ISO is what makes that a smooth experience. Verify the ISO builder handles Alloy's specific package set.
95 97 - [x] **Containerfile stage policy: multi-stage only where a non-Fedora Rust binary needs to ship.** Currently that's one stage (`rust-build` for wl-screenrec); everything else comes from RPM in the runtime stage. Do not add stages preemptively.
96 98 - [x] sr.ht account: **`~maxmj`** for v0. LLC-owned `~makecreative` remains the long-term target; migration if/when the project grows.
97 - - [x] **Publish `:latest`, `:42`, and `:42-YYYYMMDD` tags.** `:latest` for casual users, `:42` for version-pinned users, `:42-YYYYMMDD` for reproducibility. Wired up when distribution starts.
99 + - [x] **Publish `:latest`, `:<fedora-version>`, and `:<fedora-version>-YYYYMMDD` tags.** Currently `:latest`, `:43`, `:43-YYYYMMDD`. `:latest` for casual users, the bare version for version-pinned users, the dated tag for reproducibility. Wired up when distribution starts.
@@ -7,12 +7,30 @@
7 7 //! escaping mistake, a format that documents itself is worth a `serde_json`
8 8 //! dependency.
9 9 //!
10 - //! The same mock-or-real detection as `net` applies: a machine with no
11 - //! PipeWire still gets a usable screen.
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.
12 27
13 28 use std::collections::HashMap;
14 29
15 - use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text};
30 + use alloy_tui::{
31 + AlloyBlock, AlloyConnector, AlloyList, Cursor, FocusRing, Hint, Severity, Theme, hint, layout,
32 + list_row_y, text,
33 + };
16 34 use anyhow::{Context, Result};
17 35 use ratatui::Frame;
18 36 use ratatui::crossterm::event::{KeyCode, KeyEvent};
@@ -32,6 +50,16 @@
32 50 /// with a `%` glued to it.
33 51 const VOLUME_UNITY: u32 = 65536;
34 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 +
35 63 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 64 pub enum Direction {
37 65 Output,
@@ -46,19 +74,28 @@
46 74 }
47 75 }
48 76
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 {
77 + /// The `pactl` noun for a *device* in this direction.
78 + const fn device_noun(self) -> &'static str {
53 79 match self {
54 80 Direction::Output => "sink",
55 81 Direction::Input => "source",
56 82 }
57 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 + }
58 94 }
59 95
60 96 #[derive(Debug, Clone)]
61 97 pub struct Device {
98 + pub index: u32,
62 99 pub name: String,
63 100 pub description: String,
64 101 pub direction: Direction,
@@ -86,18 +123,96 @@
86 123 }
87 124 }
88 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 +
89 200 pub trait Backend {
90 201 fn name(&self) -> &'static str;
91 - fn list(&self, log: &mut CommandLog) -> Result<Vec<Device>>;
202 + fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>>;
203 + fn list_streams(&self, log: &mut CommandLog) -> Result<Vec<Stream>>;
92 204
93 - /// Set a device's volume, as a percentage of unity.
94 - fn set_volume(&self, device: &Device, percent: u8, log: &mut CommandLog) -> Result<()>;
205 + /// Set a target's volume, as a percentage of unity.
206 + fn set_volume(&self, target: Target<'_>, percent: u8, log: &mut CommandLog) -> Result<()>;
95 207
96 - /// Toggle a device's mute.
97 - fn toggle_mute(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
208 + /// Toggle a target's mute.
209 + fn toggle_mute(&self, target: Target<'_>, log: &mut CommandLog) -> Result<()>;
98 210
99 211 /// Make a device the default for its direction.
100 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<()>;
101 216 }
102 217
103 218 /// Pick a backend: `pactl` when it answers, the mock otherwise.
@@ -116,11 +231,7 @@
116 231 "pactl"
117 232 }
118 233
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.
234 + fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
124 235 let sinks = Invocation::new("pactl")
125 236 .args(["-f", "json", "list", "sinks"])
126 237 .run(log)?;
@@ -139,19 +250,35 @@
139 250 Ok(devices)
140 251 }
141 252
142 - fn set_volume(&self, device: &Device, percent: u8, log: &mut CommandLog) -> Result<()> {
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<()> {
143 270 Invocation::new("pactl")
144 - .arg(format!("set-{}-volume", device.direction.noun()))
145 - .arg(&device.name)
271 + .arg(format!("set-{}-volume", target.noun()))
272 + .arg(target.id())
146 273 .arg(format!("{percent}%"))
147 274 .run(log)
148 275 .map(drop)
149 276 }
150 277
151 - fn toggle_mute(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
278 + fn toggle_mute(&self, target: Target<'_>, log: &mut CommandLog) -> Result<()> {
152 279 Invocation::new("pactl")
153 - .arg(format!("set-{}-mute", device.direction.noun()))
154 - .arg(&device.name)
280 + .arg(format!("set-{}-mute", target.noun()))
281 + .arg(target.id())
155 282 .arg("toggle")
156 283 .run(log)
157 284 .map(drop)
@@ -159,7 +286,16 @@
159 286
160 287 fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
161 288 Invocation::new("pactl")
162 - .arg(format!("set-default-{}", device.direction.noun()))
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())
163 299 .arg(&device.name)
164 300 .run(log)
165 301 .map(drop)
@@ -174,10 +310,11 @@
174 310 "mock"
175 311 }
176 312
177 - fn list(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
313 + fn list_devices(&self, log: &mut CommandLog) -> Result<Vec<Device>> {
178 314 log.record("# no PipeWire; showing mock devices", Severity::Warn);
179 315 Ok(vec![
180 316 Device {
317 + index: 1,
181 318 name: "alsa_output.analog-stereo".into(),
182 319 description: "Analog Stereo".into(),
183 320 direction: Direction::Output,
@@ -186,6 +323,7 @@
186 323 is_default: true,
187 324 },
188 325 Device {
326 + index: 2,
189 327 name: "alsa_output.hdmi-stereo".into(),
190 328 description: "HDMI Stereo".into(),
191 329 direction: Direction::Output,
@@ -194,6 +332,7 @@
194 332 is_default: false,
195 333 },
196 334 Device {
335 + index: 3,
197 336 name: "alsa_input.analog-stereo".into(),
198 337 description: "Analog Stereo Microphone".into(),
199 338 direction: Direction::Input,
@@ -204,15 +343,38 @@
204 343 ])
205 344 }
206 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 +
207 369 // 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<()> {
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<()> {
211 373 log.record("# mock backend: volume unchanged", Severity::Warn);
212 374 Ok(())
213 375 }
214 376
215 - fn toggle_mute(&self, _device: &Device, log: &mut CommandLog) -> Result<()> {
377 + fn toggle_mute(&self, _target: Target<'_>, log: &mut CommandLog) -> Result<()> {
216 378 log.record("# mock backend: mute unchanged", Severity::Warn);
217 379 Ok(())
218 380 }
@@ -221,12 +383,23 @@
221 383 log.record("# mock backend: default unchanged", Severity::Warn);
222 384 Ok(())
223 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 + }
224 396 }
225 397
226 398 // ---- pactl JSON ----
227 399
228 400 #[derive(Deserialize)]
229 401 struct PaDevice {
402 + index: u32,
230 403 name: String,
231 404 #[serde(default)]
232 405 description: String,
@@ -246,6 +419,25 @@
246 419 monitor_source: String,
247 420 }
248 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 +
249 441 #[derive(Deserialize)]
250 442 struct PaChannel {
251 443 value: u32,
@@ -258,6 +450,7 @@
258 450 .into_iter()
259 451 .filter(|device| !is_monitor(device, direction))
260 452 .map(|device| Device {
453 + index: device.index,
261 454 volume: channel_volume(&device.volume),
262 455 muted: device.mute,
263 456 is_default: device.name == default_name,
@@ -274,6 +467,53 @@
274 467 .collect())
275 468 }
276 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 +
277 517 /// Is this device a monitor (a loopback of an output rather than real capture
278 518 /// hardware)?
279 519 ///
@@ -302,8 +542,12 @@
302 542 pub struct AudioView {
303 543 backend: Box<dyn Backend>,
304 544 devices: Vec<Device>,
305 - cursor: Cursor,
545 + streams: Vec<Stream>,
546 + focus: FocusRing,
547 + stream_cursor: Cursor,
548 + device_cursor: Cursor,
306 549 error: Option<String>,
550 + ticks: u64,
307 551 }
308 552
309 553 impl AudioView {
@@ -311,74 +555,197 @@
311 555 let mut view = Self {
312 556 backend: detect(),
313 557 devices: Vec::new(),
314 - cursor: Cursor::new(),
558 + streams: Vec::new(),
559 + focus: FocusRing::new(2),
560 + stream_cursor: Cursor::new(),
561 + device_cursor: Cursor::new(),
315 562 error: None,
563 + ticks: 0,
316 564 };
317 - view.refresh(log);
565 + view.refresh_devices(log);
566 + view.refresh_streams(log);
318 567 view
319 568 }
320 569
321 - fn refresh(&mut self, log: &mut CommandLog) {
322 - match self.backend.list(log) {
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) {
323 579 Ok(devices) => {
324 580 self.devices = devices;
325 - self.cursor.resize(self.devices.len());
326 - self.error = None;
581 + self.device_cursor.resize(self.devices.len());
327 582 }
328 583 Err(err) => self.error = Some(err.to_string()),
329 584 }
330 585 }
331 586
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),
587 + fn refresh_streams(&mut self, log: &mut CommandLog) {
588 + match self.backend.list_streams(log) {
Lines truncated
@@ -16,10 +16,27 @@
16 16 /// rest is scrollback for a future `alloy log` or a scroll binding.
17 17 const LOG_CAPACITY: usize = 256;
18 18
19 - /// The command log — the console's transcript of what it actually ran.
19 + /// The command log — the console's transcript of what the user asked for.
20 + ///
21 + /// "What the user asked for" is the precise contract, and it is narrower than
22 + /// "everything the console runs". Three kinds of invocation are the console's
23 + /// own bookkeeping rather than a user action, and recording them would drown
24 + /// the pane in traffic nobody triggered:
25 + ///
26 + /// - **Probes**, which run before the user has asked for anything.
27 + /// - **Background polls**, which run on the shell tick so that a stream
28 + /// appearing is visible without a keypress.
29 + /// - **Post-action re-reads**, which confirm what an action actually did.
30 + ///
31 + /// Those go through [`CommandLog::quiet`]. Everything a keypress directly
32 + /// causes is recorded, including an explicit refresh. Without this split a
33 + /// single volume nudge writes its action plus a four-command re-read into a
34 + /// two-row pane, and the command the user actually pressed a key for scrolls
35 + /// off before they can read it.
20 36 #[derive(Debug, Default)]
21 37 pub struct CommandLog {
22 38 entries: VecDeque<LogEntry>,
39 + muted: bool,
23 40 }
24 41
25 42 impl CommandLog {
@@ -28,12 +45,28 @@
28 45 }
29 46
30 47 pub fn record(&mut self, command: impl Into<String>, outcome: Severity) {
48 + if self.muted {
49 + return;
50 + }
31 51 if self.entries.len() == LOG_CAPACITY {
32 52 self.entries.pop_front();
33 53 }
34 54 self.entries.push_back(LogEntry::new(command, outcome));
35 55 }
36 56
57 + /// Run `f` with recording suppressed, for console bookkeeping.
58 + ///
59 + /// Scoped rather than a pair of set-muted calls so the suppression cannot
60 + /// leak: an early return or a `?` inside `f` still restores the previous
61 + /// state. Nesting restores to the enclosing state rather than to unmuted.
62 + pub fn quiet<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
63 + let was_muted = self.muted;
64 + self.muted = true;
65 + let out = f(self);
66 + self.muted = was_muted;
67 + out
68 + }
69 +
37 70 /// Entries oldest-first, for [`AlloyLog`](alloy_tui::AlloyLog).
38 71 ///
39 72 /// `VecDeque` is not contiguous, so the slice view needs the ring
@@ -177,6 +210,32 @@
177 210 assert_eq!(entries[LOG_CAPACITY - 1].command, format!("cmd {}", LOG_CAPACITY + 9));
178 211 }
179 212
213 + #[test]
214 + fn quiet_suppresses_recording_and_restores_after() {
215 + let mut log = CommandLog::new();
216 + log.record("visible", Severity::Healthy);
217 + log.quiet(|log| log.record("hidden", Severity::Healthy));
218 + log.record("visible again", Severity::Healthy);
219 +
220 + let commands: Vec<&str> = log.entries().iter().map(|e| e.command.as_str()).collect();
221 + assert_eq!(commands, ["visible", "visible again"]);
222 + }
223 +
224 + // Nesting must restore to the enclosing state, not unconditionally to
225 + // unmuted, or an inner scope silently re-enables logging for the outer one.
226 + #[test]
227 + fn nested_quiet_restores_to_the_enclosing_state() {
228 + let mut log = CommandLog::new();
229 + log.quiet(|log| {
230 + log.quiet(|log| log.record("inner", Severity::Healthy));
231 + log.record("outer", Severity::Healthy);
232 + });
233 + log.record("after", Severity::Healthy);
234 +
235 + let commands: Vec<&str> = log.entries().iter().map(|e| e.command.as_str()).collect();
236 + assert_eq!(commands, ["after"], "both nested levels stayed muted");
237 + }
238 +
180 239 // `entries()` straightens the deque; a wrapped ring must still read back in
181 240 // order, or the pane shows the transcript spliced at the wrap point.
182 241 #[test]
@@ -6,6 +6,8 @@
6 6 //! than in each view, so a new subcommand supplies only its body and its
7 7 //! hints and inherits the rest.
8 8
9 + use std::time::Duration;
10 +
9 11 use alloy_tui::keys::{Action, classify};
10 12 use alloy_tui::{AlloyLog, AlloyStatusBar, Hint, Severity, Theme, hint, layout};
11 13 use anyhow::Result;
@@ -42,8 +44,26 @@
42 44
43 45 /// Handle a key the shell did not claim.
44 46 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow;
47 +
48 + /// Called roughly every [`TICK`] while no key is pressed.
49 + ///
50 + /// For views onto state that changes without the user (an app starting
51 + /// playback, an interface coming up). Default is nothing, so a view onto
52 + /// state that only changes when acted on costs no background work.
53 + ///
54 + /// Anything run from here is console bookkeeping, not a user action, so it
55 + /// belongs inside [`CommandLog::quiet`].
56 + fn tick(&mut self, _log: &mut CommandLog) {}
45 57 }
46 58
59 + /// How long the loop waits for a key before ticking.
60 + ///
61 + /// This bounds tick latency, not input latency: a keypress wakes the poll
62 + /// immediately. One second is slow enough that a view polling a couple of
63 + /// commands per tick stays cheap, and fast enough that an app starting
64 + /// playback shows up before the user wonders whether the console noticed.
65 + pub const TICK: Duration = Duration::from_secs(1);
66 +
47 67 /// Run a view to completion: set up the terminal, loop, and restore.
48 68 ///
49 69 /// The terminal is restored even when the loop fails, so a backend error does
@@ -64,6 +84,14 @@
64 84 loop {
65 85 terminal.draw(|frame| draw(frame, theme, view, log))?;
66 86
87 + // Poll rather than block, so a view can refresh state that changes
88 + // without the user. `poll` returns as soon as an event arrives, so
89 + // this costs nothing in input responsiveness.
90 + if !event::poll(TICK)? {
91 + view.tick(log);
92 + continue;
93 + }
94 +
67 95 let Event::Key(key) = event::read()? else {
68 96 continue;
69 97 };
@@ -56,10 +56,94 @@
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
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 +
63 147 #[test]
64 148 fn full_height_gets_all_three_regions() {
65 149 let areas = console(Rect::new(0, 0, 80, 24));
@@ -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 @@
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::*;
@@ -201,21 +201,48 @@
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)
216 206 }
217 207 }
218 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;
242 + }
243 + Some(area.y + row as u16)
244 + }
245 +
219 246 impl Widget for AlloyList<'_> {
220 247 fn render(self, area: Rect, buf: &mut Buffer) {
221 248 if area.height == 0 || area.width == 0 {
@@ -385,6 +412,31 @@
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.
@@ -1,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 + }