max / alloy_tui
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8 files changed,
+720 insertions,
-73 deletions
| @@ -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 | + | } |