Skip to content

Add opt-in per-channel/stereo-pair volume and meter display (multichannel-pairing) - #76

Draft
HoneyHazard wants to merge 45 commits into
tsowell:mainfrom
HoneyHazard:multichannel-pairing
Draft

Add opt-in per-channel/stereo-pair volume and meter display (multichannel-pairing)#76
HoneyHazard wants to merge 45 commits into
tsowell:mainfrom
HoneyHazard:multichannel-pairing

Conversation

@HoneyHazard

@HoneyHazard HoneyHazard commented Aug 13, 2026

Copy link
Copy Markdown

⚠️ Full Disclosure: Drafted with AI assistance (Claude); reviewed by me briefly. I am neither a Rust developer nor pipewire expert. If I should stop making these PRs into your wonderful project, please let me know. ⚠️

That being said, I hope these can be helpful and useful additions that users could appreciate.


Heads up before anything else: this is a big, experimental branch - by far the largest thing I've sent your way. It touches volume/meter rendering, navigation, and config surface pretty broadly, and I want to be upfront that I'm not confident every corner of it is production-ready. I'm opening this as a draft specifically so it's visible and diffable, not because I think it's ready for review yet. Please treat it as "here's where this experiment landed" rather than "please merge this." Happy to split it into smaller pieces, drop parts of it, or close it outright if that's more useful to you - just let me know.

What this is

Today, a stereo (or multi-channel) node always collapses to one blended volume bar and one blended peak meter, even when its channels actually differ - you can't see or adjust them independently. This branch adds an opt-in way to see and control channels individually, while leaving the classic collapsed view as the default - near-identical to stock wiremix out of the box, with one deliberate exception covered under Backward compatibility below.

Classic collapsed view - the default, unchanged from stock wiremix

Default unified view - identical to stock wiremix's own single-bar-per-node layout, including on a 5.1 device (M-Audio Sonica Theater) and a device with two unpaired AUX channels (HDA Intel PCH).

Split view - Space cycles to it, showing per-channel/per-pair bars and meters

Same devices with the split view toggled on (Space) - a real 5.1 stream splits into its two stereo pairs (front/rear, radiating from a shared center marker, labeled "F L|R"/"R L|R") plus its two unpaired channels (FC, LFE); a device with two unrelated AUX channels (no detected pair) gets one row per channel instead of guessing a pairing that isn't there. Real, live peak meters throughout - not mocked.

The mental model: three views, cycled with Space

  • unified (default) - exactly one bar/row per node, same as stock wiremix always has been.
  • linked - every node's channels get their own row, but a volume key still applies to the whole node at once: a relative key (h/l, arrows) shifts every channel by the same delta, preserving whatever imbalance they already had (matches pulsemixer's own behavior: +10 on 30%/50% gives 40%/60%, not 50%/50%); an absolute key (0-9) sets every channel to that identical value.
  • channels - same split display, but a volume key targets only the individually-cursored channel and leaves every other channel exactly as it was.

Space cycles unified → linked → channels → unified, in the order given by view_cycle (also directly reachable via SelectView for a dedicated key straight to one view, skipping the cycle).

How a split row lays out

  • A node with exactly one simple left/right pair and nothing else gets a single row - two bars radiating outward from a shared center marker (split_style = "radiating", the default).
  • A node with more going on (extra channels alongside a pair, more than one pair - a 5.1/7.1 device - or no pair at all) gets one row per detected pair/channel instead. A pair's row still radiates from a center marker and picks up a group label ("F L|R"/"F", configurable via pair_label_style); an unpaired channel occupies just the left half of that same column grid (own label/bar/percentage, right side left blank) so every row in the block starts and ends at the same columns.
  • Setting split_style = "stacked" instead always gives one plain, left-aligned row per raw channel, regardless of pairing - useful if the radiating/grouped look isn't wanted.
  • Peak meters split the same way the volume bars do, using new meter_split_* char_set/theme keys (shipped with real default glyphs - / - not just an opt-in None) that fall back to the classic meter_left/meter_right/meter_center glyphs whenever nothing is split.

What's new in config (all optional - see Backward compatibility below for exactly how close the defaults stay to today's behavior)

# Which of the three views (see above) the app starts in. Space still
# switches between all three live; this only picks the startup one.
initial_view = "unified"         # "unified" | "linked" | "channels"

# Only consulted while the current view is "unified": how a node whose
# channels currently hold *different* values is indicated without
# leaving the collapsed view. "none" = today's behavior, just the mean,
# no indication. "cycle" (the default here) = the one bar/label
# alternates through each channel's own value, see
# unified_imbalance_cycle_seconds below for the interval. "split" =
# just that one imbalanced node auto-splits into its own radiating
# row; every other, balanced node stays collapsed.
unified_imbalance = "cycle"      # "none" | "cycle" | "split"

# How long unified_imbalance = "cycle" shows each channel before
# advancing to the next one, in seconds. Ignored otherwise.
unified_imbalance_cycle_seconds = 1.5

# How a split row lays out. "radiating" = a lone pair gets one row,
# two bars radiating from a shared center marker; anything with more
# channels (extra singles alongside a pair, more than one pair, or no
# pair at all) gets one row per detected pair/channel instead, each
# still radiating on its own row. "stacked" = always one plain,
# left-aligned row per raw channel, regardless of pairing.
split_style = "radiating"        # "radiating" | "stacked"

# How a radiating pair row labels which physical pair it's showing -
# only matters once more than one row can appear in the same node's
# split display. "verbose" spells it out ("F L/R"); "short" is just
# the group name ("F").
pair_label_style = "verbose"     # "verbose" | "short"

# Which views Space steps through, and in what order. Removing one
# excludes it from the cycle without disabling it entirely - it's
# still reachable via the SelectView action.
view_cycle = [ "unified", "linked", "channels" ]

# A lone stereo pair's own single-row fast path (it never shows a group
# label the way a multi-row block does) shrinks its label area down to
# just what a plain "N%" needs, handing the reclaimed width to its
# volume bars - a real width increase, at the cost of no longer sharing
# a bar-start column with other rows elsewhere in the same view.
expand_unused_label_space = true

# An unpaired channel's row in a split_style = "radiating" block
# normally occupies just the left half of the row's column grid
# (mirroring where a paired row's own left bar would be, so every row
# in the block starts/ends its bar at the same columns) - when this is
# on, it stretches across the row's full remaining width instead.
expand_unpaired_channel_bars = false

Backward compatibility

Almost every new setting defaults to reproducing stock wiremix's existing behavior exactly - initial_view = "unified", nothing forced split, and a balanced node (all channels already equal) looks and behaves exactly as it always has, on real hardware included. The one deliberate exception is unified_imbalance, which defaults to "cycle" rather than a do-nothing "none": stock wiremix has never had any way to indicate an imbalanced node without splitting it, so "none" would just be reproducing a gap, not a stock behavior worth preserving by default. "cycle" costs nothing for the common case (a balanced node never cycles - see is_imbalanced) and surfaces real information the rest of the time. "none" is still one config line away if you'd rather have the old silent-average behavior back.

More examples

A few more angles on this that the two screenshots above don't cover on their own - individual per-channel targeting, all three unified_imbalance behaviors, and compat vs default char_set.

Channels view - the cursor is individually targeting Sonica Theater's RR channel

channels view (third stop on Space's cycle) - every row is independently addressable; the small marker on RR's row (not the header) shows which single channel h/l/0-9 currently target. SW_VOCALS_PRE further down happens to be a genuinely imbalanced real stream (75%/127%), useful context for the next few screenshots.

unified_imbalance = none - Sonica Theater's imbalanced 93%/84% pair just shows their mean, no indication

unified_imbalance = "none" - the old default, still available as an explicit opt-out. Same Sonica Theater as the unified-view.png screenshot up top (also genuinely imbalanced, 93%/84% per its own linked-view breakdown below) - just a single static "90%", no indication anything differs channel to channel.

unified_imbalance = cycle, frame one - SW_VOCALS_PRE showing its L channel
unified_imbalance = cycle, frame two 1.5s later - the same node now showing its R channel

unified_imbalance = "cycle" (the default) - two captures of the same still-unified list, 1.5s apart. SW_VOCALS_PRE's single bar/label alternates between "L 75%" and "R 127%" - a way to notice an imbalance without ever leaving the collapsed view.

unified_imbalance = split - only the one imbalanced node splits, everything else stays collapsed

unified_imbalance = "split" - same idea, different resolution: SW_VOCALS_PRE auto-splits into a radiating two-bar row on its own, while every other, balanced node (including the 5.1 device) stays exactly as one collapsed bar. Nothing forces a session-wide view switch for this - it's purely a per-node reaction to that node's own current values.

compat char_set - thin seamless vertical-bar meters
default char_set - blockier filled-rectangle meters

Same real devices, same linked view, compat vs default char_set - compat's thin meter reads as one continuous bar; default's reads as discrete lit segments. Both split the same way; only the glyph family differs.

Tested

  • cargo test --release: all passing (261 tests as of the latest commit on this branch, including dedicated coverage for pair detection, radiating vs. stacked layout, per-channel navigation/volume routing, imbalance cycling/splitting, and a regression test for a Stacked block getting clipped mid-scroll - see below). Since first opening this PR, live day-to-day use turned up two more real bugs, both now fixed: Channel mode's own selector marker briefly grew into a whole-block bracket instead of staying localized to just the targeted channel's row, and the peak meter's left/right halves could render a different number of characters for numerically-equal levels at certain terminal widths (a ratatui Fill-constraint rounding issue - fixed by giving both halves an explicit, equal Length instead, the same fix the volume bar needed for the identical failure mode). Also cleaned up the config surface itself: channel_display/channel_mode were two lower-level booleans/enums left over from before the three named views existed - Space's own view-cycling logic already showed that channel_mode = true unconditionally wins regardless of channel_display, meaning the two never actually produced a 4th, distinct combination beyond unified/linked/channels. Collapsed both into the single initial_view key above; nothing was lost. Later still, changed unified_imbalance's own default from "none" to "cycle" (see "Backward compatibility" above) and promoted the cycle interval from a hardcoded 1.5s constant to the configurable unified_imbalance_cycle_seconds shown above.
  • cargo fmt --check / cargo clippy --release --all-targets: clean.
  • Manually verified live against real hardware (a 5.1 analog surround device, a Bluetooth headset, a couple of unpaired-AUX devices) and real playing audio, in all three views, with actual moving peak meters - every screenshot above was captured with confirmed live meter activity (verified by diffing consecutive frames before saving), not a static/idle state.
  • While preparing this PR I found and fixed a real bug: scrolling a tall split block (e.g. the 5.1 device) partially into view could show a channel row with no header above it and a gap in the middle of the block, instead of a clean cut at the bottom - root-caused to ratatui's Layout::split spreading a size shortfall across all equal-Length constraints rather than just the trailing ones. Fixed by only ever asking for as many row constraints as can actually fit; covered by a new regression test.
  • Since the changes above, this branch has also been merged forward into wiremix-extras' combined main (resolving conflicts against its other already-merged features) each time; the combined suite (328 tests, including this branch's own 261) passes there too, and the binary built from that combined main is what's actually rebuilt and redeployed for day-to-day use.

Known caveats

  • This is a large surface area for one PR - I'd understand completely if you'd rather see it split up, or if parts of it don't fit the direction you want for wiremix. Ask and I'll adjust.
  • I haven't stress-tested this against every exotic channel layout PipeWire can produce (quad, 7.1, non-standard AUX arrangements beyond what I have hardware for) - only what I could actually plug in and test.

This feature, together with other experimental changes, is included and should be ready to play with in the wiremix-extras experiment.

HoneyHazard and others added 30 commits August 10, 2026 02:44
Detects named left/right channel pairs (stereo, and other spa_audio_channel
L/R pairs) from a node's real per-channel position/volume data via a new
channel_pairing module - pairing is name-based (an explicit L/R suffix
table), not index-adjacency, since PipeWire doesn't guarantee adjacent
ordering and generic AUX channels carry no pairing information at all.

When show_channel_volumes is enabled and a node has a detected pair, its
volume bar splits into two independently-clickable bars radiating outward
from a shared center marker - mirroring the existing stereo peak meter's
own radiating layout, just for volume instead of peaks. Each channel's
percentage and fill are computed independently, no averaging. A new
Action::SetChannelAbsoluteVolume(usize, f32) and View::channel_volume()
let mouse clicks on one radiating half set just that channel, leaving the
other untouched - unlike the existing SetAbsoluteVolume/SetRelativeVolume,
which still apply to every channel together via the existing .fill()
behavior, unchanged.

Off by default - nodes without a detected pair, and every node when the
option is off, render exactly as before. Live-verified: default off is
pixel-identical to the pre-existing single-bar display; with the option
on, clicking one radiating half changes only that channel (confirmed via
screenshot: left channel jumped to 80% while right stayed at 1%).

Tested: cargo test --release (163/163 passing, including 10 new
channel_pairing tests covering real hardware examples from this machine's
own pw-dump output - a real 5.1 device pairing fronts/rears while leaving
center/LFE single, and a real AUX0/AUX1 device correctly never pairing),
cargo fmt --check / cargo clippy --release --all-targets -- -D warnings /
cargo doc --no-deps all clean.
Closes the gap between the pure channel_pairing logic tests and manual
screenshot verification: renders NodeWidget's volume area directly and
inspects the resulting character buffer to confirm the option's default
(off) behavior is unchanged, a detected pair renders two independent
percentages rather than one masking average, and a node with no pairable
channels (mono) falls back to the single bar even with the option on.

Tested: cargo test --release (159/159), cargo fmt --check, cargo clippy
--release --all-targets -- -D warnings, all clean.
…etChannelAbsoluteVolume

Mirrors the existing SetAbsoluteVolume/SetRelativeVolume pair but scoped
to a single channel index, so per-channel adjustments (e.g. custom
keybindings for increasing/decreasing one channel of a stereo pair) can
be relative as well as absolute. View::channel_volume now takes a
VolumeAdjustment like View::volume already does, instead of a bare
absolute f32.

162/162 tests passing, fmt/clippy/doc clean.
…ative keybinding examples

Confirms and locks down the TOML tuple-variant syntax for these two
actions ({ SetChannelAbsoluteVolume = [N, volume] }) with new config
parsing tests, then adds them to wiremix.toml's keybinding docs as a
commented-out example (Ctrl+Left/Right for channel 0, Ctrl+Up/Down for
channel 1) alongside a note that Ctrl+arrow isn't guaranteed to reach
wiremix on every terminal/multiplexer setup. Not enabled by default -
documentation only, same treatment as the existing SelectTab example.

164/164 tests passing, fmt/clippy/doc clean.
…er-channel targeting

Per §7.3/§7.4 of the multichannel design notes: ObjectList gains
channel_mode (a session-wide toggle) and selected_channel, and down()/up()
fold channel cycling into the existing navigation stream - moving past a
node's last channel advances to the next node, landing on its first
channel if it has more than one. A node with 0 or 1 channels is never
cycled into, so it behaves exactly as today.

Action::ToggleChannelMode flips the mode via a new
ObjectList::toggle_channel_mode(). The existing whole-node volume keys
(SetAbsoluteVolume/SetRelativeVolume, bound to h/l, arrows, and 0-9) now
redirect to the per-channel path when a channel is selected, so Channel
mode reuses today's keybindings rather than needing new ones - only the
channel-specific actions (SetChannelAbsoluteVolume/SetChannelRelativeVolume)
still require their own explicit index.

Not wired to rendering yet - toggling channel mode changes navigation and
volume-key targeting correctly (unit tested), but there's no visual
stacked-row display for it yet, so it has no observable effect in the
running app until that lands. Deliberately left off wiremix.toml's
keybinding docs for the same reason - no point documenting a toggle with
nothing to see yet.

170/170 tests passing, fmt/clippy/doc clean.
Per §5 point 3 of the multichannel design notes: a node with more than
one channel now actually expands into one header line plus one stacked
row per channel when channel mode is on, instead of just changing
navigation/volume-key targeting with nothing to look at (as it did after
the previous commit).

- NodeWidget::node_height() replaces the bare NodeWidget::height()
  constant for layout purposes - a node's real height now depends on
  channel_mode and its own channel count.
- ObjectList::item_heights() and the render/visible_count walks in
  object_list.rs are rewritten to sum real, possibly heterogeneous
  per-object heights instead of assuming a uniform one (§4's traced
  plan), so scrolling, the "more above/below" indicators, and the
  Ratatui layout constraints all stay correct once some nodes are taller
  than others.
- New ChannelRowWidget renders one channel's own volume bar/percentage
  per line, with click-to-set mouse areas mirroring StereoVolumeWidget's
  existing per-column pattern. Each row's marker glyph (reusing
  char_set.selector_middle/theme.selector, no new style) shows only on
  whichever channel is currently cursor-targeted for the selected node -
  §7.5 axis 4's "reuse existing mechanism" resolution, made trivial here
  because a stacked channel row is a real one-line row rather than a
  radiating bar with no row to attach a selector to.
- ToggleChannelMode is now documented in wiremix.toml as a keybinding
  example (still not a default binding), since it finally has something
  visible to toggle.

Deliberately out of scope for this slice (noted in NOTES-multichannel.md
rather than silently skipped): no peak meter in channel-row mode, no
channel name/position label beyond a raw index, and no row_selected
background tinting on channel rows - the marker glyph is the only
selection indicator.

178/178 tests passing, fmt/clippy/doc clean. Live-verified against this
machine's real running PipeWire graph: toggling channel mode expands
every 2-channel node into two rows, the marker correctly starts on
channel 0 of the selected node, and Down steps to channel 1 before
advancing to the next node's channel 0 - confirmed via screenshots at
each step, not just unit tests.
Previously, arriving at a new node always landed on channel 0 regardless
of navigation direction - so up() from a node's channel 1 could skip
past the previous node's own channel 1 entirely (landing on its channel
0 instead), breaking the "up undoes down" expectation the rest of the
list's navigation already has. select()/initial_channel() now take a
from_end flag: down() lands on the first channel, up() lands on the
last, so walking down N steps and back up N-1 steps retraces the exact
same (node, channel) sequence in reverse.

This closes the "reasonable follow-up" explicitly flagged in the
previous commit's own doc comment, per NOTES-multichannel.md §9.1.

179/179 tests passing (added a symmetric round-trip test plus fixed the
one existing up-navigation test that asserted the old, asymmetric
landing), fmt/clippy/doc clean.
Closes §6's deferred "channel label stripping the group discussed" item
now that there's an actual investigation to report: no existing
name-lookup was available anywhere in the Rust bindings (checked
libspa-sys's type_info.rs and the pipewire/libspa crates directly), so
channel_pairing::channel_name() hand-maps every named enum spa_audio_channel
value straight from spa/param/audio/raw.h's own doc comments (FL, FR,
LFE, TFLC, ...), computes AUX{n} for the whole START_Aux..=LAST_Aux
range PipeWire itself defines as valid aux channels (not just the 64
that happen to have individual named constants), and falls back to "?"
for UNKNOWN/NA or anything future this list hasn't caught up to.

ChannelRowWidget now looks up node.positions[channel_index] and shows
that name instead of the bare index, falling back to the index itself
when a node has no positions data at all (some streams never send
SPA_FORMAT_AUDIO_position). Label column widened from 6 to 10 columns
to fit worst-case names like "AUX63 100%".

184/184 tests passing (3 new in channel_pairing.rs for the name
function itself, 2 new + 1 updated in node_widget.rs for the
fallback/AUX-numbering/named-channel row content), fmt/clippy/doc
clean. Live-verified against the real PipeWire graph: every real
stream's channel rows now show "FL"/"FR" instead of "0"/"1".
…ht rows

Closes §6's last open question: whether lazy_capture/capture_hidden/
rotation logic (all keyed on ObjectId, one entry per node) need any
changes now that a node can occupy multiple visual rows.

Traced it rather than assuming: App::visible_objects (what
update_capturing() diffs capture state against) comes entirely from
ObjectList::visible_objects(), which already went through §9.2's
heterogeneous-height rewrite - it slices the node list by object count,
not by line count, so a taller channel-mode node correctly reduces how
many *objects* fit without needing any per-channel-row capture concept.
Capture itself is inherently node-scoped (one capture stream per node,
peaks are stereo-pair based per §1) - there was never a "which channel
row" question for it to answer.

Added a regression test proving this rather than just asserting it:
channel_mode_shrinks_visible_objects_for_lazy_capture confirms
visible_objects() correctly excludes a node once channel mode makes it
too tall to fit, at the exact API lazy_capture consumes (not just the
lower-level visible_count() already covered in §9.2).

185/185 tests passing, fmt/clippy/doc clean.
Clicking a channel row's volume bar or label already dispatched
Action::SelectObject (selecting the row's node), but nothing updated
selected_channel to match the clicked channel - so subsequent h/l
keypresses could target a stale channel index left over from whatever
was last cursored via the keyboard, on a completely different node.

New mouse-only Action::SelectChannel(usize) (marked #[serde(skip_deserializing)],
like SelectObject/SetTarget - not user-bindable, only dispatched from
mouse areas) sets ObjectList::selected_channel directly. Added to both
of ChannelRowWidget's mouse areas (the label's mute-toggle click and the
per-column volume click-to-set loop) alongside the existing
SelectObject/SetChannelAbsoluteVolume actions, so clicking anywhere on a
channel row now keeps keyboard and mouse targeting in sync.

186/186 tests passing, fmt/clippy/doc clean. Not live-click-verified:
xdotool's synthetic clicks land at the right window coordinates
(confirmed via getmouselocation) but don't reach wiremix's mouse-tracking
mode in this environment - a tooling limitation, not something specific
to this change. Relying on the new unit test
(select_channel_sets_selected_channel_without_touching_selected) plus
the fact this mirrors an already-shipped, already-verified pattern
(SetChannelAbsoluteVolume's own click-to-set mouse areas, live-verified
in §9.2) rather than claiming a live check that didn't actually happen.
…esting

Four independent corrections, all from direct feedback after trying the
build:

1. Channel mode no longer shows the selector marker on a node's header
   row - only the individually-targeted channel row does, so there's
   exactly one place to look for "which channel is this."

2. Channel row labels and percentages now render in independent
   fixed-width right-aligned columns ("FL  100%" / "FR    0%") instead
   of one right-aligned "{label} {percent}%" string, which shifted the
   label itself left/right depending on the percentage's own digit
   count ("FL 100%" vs " FL 50%" vs "  FL 3%").

3. StereoVolumeWidget's two radiating bars now share one explicitly
   computed width instead of two independent Fill(1) constraints, which
   could differ by a column when the remaining space was odd -
   numerically equal L/R volumes could render as visibly different bar
   lengths purely from that width mismatch.

4. View::volume()'s Relative branch now applies the same delta to each
   channel's own current value independently, instead of averaging
   first and filling every channel to the new mean - preserves existing
   imbalance the way pulsemixer does (+10 on a=30/b=50 gives a=40/b=60,
   not a=b=50). Absolute/set operations are unchanged (still fill every
   channel to one explicit target).

Extended the test mock (MockCommand::NodeVolumes) since proving tsowell#4
needed to actually observe dispatched volumes, which the existing mock
harness couldn't do (node_volumes was a hard no-op) - the established
workaround of only checking handle()'s bool return wasn't decisive
enough for a claim this specific.

190/190 tests passing (5 new, 3 updated for the new column-alignment
strings), fmt/clippy/doc clean.
…lance/split_style axis matrix

Per direct feedback after trying the build: the earlier single
show_channel_volumes bool only ever gave "always radiating" - there was
no way to get a single unified bar that only splits when a node is
actually imbalanced, no way to choose stacked over radiating for linked
(non-Channel-mode) display, and no runtime toggle at all, only a static
config value.

New independent config axes (all default to today's stock behavior,
byte-for-byte, with zero-visual-change as the baseline):

- channel_display: "unified" (today, one bar/row always) | "always"
  (always split, per split_style). Toggle live with the new
  Action::CycleChannelDisplay (unbound by default) - cycles
  unified -> always -> unified.
- unified_imbalance: only consulted when channel_display = "unified" -
  "none" (today, flat mean) | "cycle" (reserved for a future increment -
  temporal per-channel cycling per NOTES-multichannel.md §7.2) | "split"
  (just that one imbalanced node renders split while balanced nodes stay
  collapsed - implemented now, since it falls out of the same dispatch
  logic almost for free).
- split_style: "radiating" (two bars sharing one row, only for a
  detected 2-channel pair) | "stacked" (one row per channel, the
  fallback for anything that isn't a pair regardless of this setting).
- channel_mode (existing runtime toggle) gains a matching config default
  and CLI flag pair, for consistency with the other three.

All four axes are independent: channel_mode (individual vs linked
setting) always forces stacked display regardless of the other three,
since radiating's marker-placement problem for an individually-cursored
channel isn't solved yet.

Implementation: a single volume_display() function in node_widget.rs
resolves (ChannelState, &view::Node) -> Unified | Radiating | Stacked,
used identically by both NodeWidget::render() (what to actually draw)
and NodeWidget::node_height() (how tall the row needs to be), so the two
can never disagree about a node's shape. ChannelState bundles all four
axes so call sites don't need four separate parameters everywhere.
channel_mode/channel_display live on ObjectList as runtime-mutable
state, seeded from config at App::new(); unified_imbalance/split_style
are read straight from Config for now (no runtime toggle built yet,
config-only).

203/203 tests passing (13 new: 8 direct volume_display() precedence
tests covering every axis combination including the channel_mode-always-
wins rule and the pair/non-pair radiating fallback, plus config
parsing/keybinding tests for the new fields and action), fmt/clippy/doc
clean. Live-verified against the real PipeWire graph: pressing the
CycleChannelDisplay key toggles every node between the ordinary
single-bar display and split radiating bars (with symmetric widths,
confirming the earlier bar-width fix holds under the new dispatch path
too) and back again.
Point 4 of the feedback batch: "if volume bars are split per-channel,
monitor is also split for channels." Each ChannelRowWidget row now gets
its own mono-style meter reading node.peaks[channel_index] directly,
instead of no meter at all (the previous, explicitly-documented gap).

Honors the existing peaks config exactly as asked: peaks = "off" still
shows no meter anywhere; "auto"/"mono" both render as a single mono
gauge per row regardless, since one channel has nothing to show a
left/right split of - "gauge as mono, but show it on each channel row."
Reuses meter::render_mono() directly (not the whole-node MeterWidget,
which averages multi-channel peaks together) and resets peaks_dirty to
match MeterWidget's own bookkeeping.

Real per-channel peak data was already available (node.peaks: Option<
Arc<[AtomicF32]>>, one entry per channel - MeterWidget's existing
fallback path for >2-channel nodes already averages this same array, it
just never had anywhere to show the values individually before).

204/204 tests passing (1 new, exercising real per-channel peak values -
one channel loud, one silent, confirming each row reads its own
channel's peak rather than a shared or averaged one), fmt/clippy/doc
clean. Live-verified against the real PipeWire graph: every channel row
in Channel mode now shows its own meter alongside its volume bar.
Closes the last item from §7.2's original design and the feedback
batch's remaining deferred piece. When channel_display = "unified" and
unified_imbalance = "cycle", an imbalanced node's single bar now
temporally cycles through each channel's own label+percentage+bar
instead of silently showing the mean.

Stateless by design, per §7.2's own reasoning: cycling_channel() derives
which channel to show purely from elapsed_seconds (threaded fresh from
App::start_time through AppWidget -> ObjectListWidget -> NodeWidget every
render, no polling/ticking) and a deterministic per-node phase offset
from the node's own object ID - every imbalanced node cycles at the same
rate but lands on a different phase, so a list full of imbalanced nodes
doesn't flip in lockstep. No new stored per-node state anywhere, matching
how positions/volumes are already read fresh from view::Node each frame.
Fixed 1.5s interval for now (not user-configurable yet).

Label format keeps the original "no width cost" promise from the design
conversation: the channel index and percentage pack into the exact same
5-column budget the mean-only label already used ("0 79%" / "1100%"),
adapting whether a separator fits based on the percentage's own digit
count, rather than widening the row.

Also corrects a second stale reference this session ran into: the design
notes cited "App::rotate_capturing/frames_since_rotation" as an existing
precedent to build on - it doesn't exist anywhere in this branch either
(same kind of cross-branch mixup as the row_selected one from earlier),
so this was built from scratch using RenderPacer's own Instant-based
timing as the closest real precedent instead.

210/210 tests passing (7 new: precedence gating for every other axis
combination, real time-based advancement after one full
CYCLE_INTERVAL_SECONDS, and a hand-verified phase-offset test proving
two different object IDs land on different channels at the same instant
- object_id 1 -> channel 0, object_id 500 -> channel 1, computed by hand
against the actual formula before asserting), fmt/clippy/doc clean.

Live-verified partially, deliberately not fully: ran with
channel_display = "unified" + unified_imbalance = "cycle" against the
real PipeWire graph - confirmed clean startup, no panics, and every
currently-balanced real stream renders exactly as before (correctly
gated - cycling never activates without real imbalance). Did not
artificially imbalance a real stream to watch it animate, since that's
an audible change to a live, actively-playing stream for a check the
unit tests (specifically the time-advancement and phase-offset ones)
already cover decisively.
…dback fixes

- Radiating display now uses one row per detected channel_pairing group
  (a pair renders as a radiating mini-row, a single as its own row)
  whenever a node has more than one simple pair or extra unpaired
  channels alongside a pair - previously stereo_pair() only ever
  looked at the first detected pair, silently dropping every other
  channel from both rendering and mouse interaction (confirmed against
  real "M-Audio Sonica Theater Analog Surround 5.1" hardware layout:
  FL,FR,RL,RR,FC,LFE).
- Single-channel rows now share one bar_width with any radiating pair
  rows in the same block, so a maxed single channel and a maxed side
  of a pair fill the same number of characters instead of the single
  row stretching to its own full width.
- Fixed a selector-marker bug: a selected node with no channel pair
  (e.g. AUX0/AUX1 hardware) rendered with no visible marker anywhere
  once display split, since the marker logic only checked
  selected_channel (Channel mode only) - now falls back to marking the
  header row whenever display is split but nothing is individually
  targeted.
- Added meter_channel_active/inactive/overload char_set fields so a
  channel row's own peak meter uses a visually distinct (shorter/
  thinner) glyph from a whole-node meter, keeping several stacked
  channel meters from blending into one solid block.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per user's explicit column spec: whenever split_style = "radiating",
every row in a split display - a detected pair or a lone unpaired
channel alike - now shares one column grid (label | left/unpaired % |
left/unpaired bar | divider-or-blank | right bar-or-blank | right
%-or-blank | meter, split the same way). An unpaired channel occupies
just the left half and leaves the rest blank, instead of stretching its
own bar/meter across the whole row - so its bar starts and ends at the
same columns a paired row's left bar would, resolving the bar-alignment
mismatch reported between AUX-style devices and real stereo devices for
free, as a side effect of the shared layout rather than a separate fix.

- New RadiatingRowWidget (replaces the old pair-only
  RadiatingChannelRowWidget) takes an Option<right_index> and handles
  both shapes; ChannelRowWidget goes back to being purely the plain
  Channel-mode/split_style="stacked" widget with no bar_width plumbing,
  since only the radiating context ever needs cross-row bar-width
  sharing now.
- Radiating pair rows are labeled with a new group name
  (channel_pairing::pair_group_name), not either channel's own name -
  "FL" as a pair label was indistinguishable from a real FL row. New
  pair_label_style config (verbose "F L/R" / short "F") controls the
  format; defaults to verbose.
- In Channel mode / split_style = "stacked", a node that's just one
  simple stereo pair and nothing else now labels its two rows "L"/"R"
  instead of "FL"/"FR" - there's no ambiguity to a real channel name
  away when it's the only pair on the node.
- Fixed a real bug surfaced while rewriting the pair-row meter: it
  wasn't honoring peaks = "mono" the way MeterWidget does for a
  whole node, always attempting a stereo split even when mono was
  forced.

Live-verified against real hardware (the user's actual 5.1 "Sonica
Theater" device and the AUX0/AUX1 "Built-in Audio Pro" analog output):
all six Sonica Theater channels now render as F L/R, R L/R, FC, LFE
with consistent bar lengths and shared column alignment; the AUX0/AUX1
device's selector marker correctly falls back to the header row now
that it renders through the same radiating-context path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lit block

User clarified: they wanted the bar-start column consistent for *every*
row containing a volume in the list - Unified single-bar nodes, the
classic single-pair Radiating fast path, and Stacked/split-block rows
all needed to match, not just rows within the same node's own split
block (which tsowell#63 already handled). Widened the shared padding
NodeWidget::render() reserves before a plain single-row node's volume
content (2 -> 7 = RADIATING_LABEL_WIDTH, i.e. same value the Stacked
block's own leading label column already uses) and StereoVolumeWidget's
label_l/label_r (4 -> 5, the one extra column needed to close the
remaining gap) so every row's bar lands at the same absolute column
regardless of which widget renders it.

Verified with exact character-position measurement (not screenshot
eyeballing) both in a new unit test and against the real PipeWire graph
via tmux capture-pane: every row - Sonica Theater's pair/single rows,
HDA Intel PCH's AUX0/AUX1, SW_NULL_MIC's unpaired channels, and the
plain single-pair fast path (PYLE_SOUTH, cmus-out streams) - now starts
its bar at column 14.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four fixes from a live test pass:

- unified_imbalance = "cycle" label ("0 64%"/"1100%" alternating)
  packed the channel index and percentage into a tight 5-column budget,
  squeezing out the separating space once percent hit 100 ("1100%") -
  genuinely read as malformed, not just tight. Always include the
  space now ("1 100%") and give the label column the extra column it
  needs (VOLUME_LABEL_WIDTH 5 -> 6), cascading the same +1 through
  StereoVolumeWidget's label_l/label_r and NODE_VOLUME_PADDING so the
  cross-node bar-start alignment from the previous commit still holds.

- Fixed a real bug: meter_channel_* (the per-channel monitor gauge
  added for split rows) was set to the *exact same character* as
  volume_empty for both compat ("─") and extracompat ("-"), making a
  channel's monitor gauge visually indistinguishable from an empty
  volume bar. Picked genuinely distinct glyphs for both, and added a
  test (channel_bar_glyphs_never_collide_with_whole_node_glyphs) that
  checks every built-in char_set for this class of collision going
  forward.

- Added volume_channel_filled/volume_channel_empty - a channel row's
  own volume bar now uses a distinct "disjoint, thin" glyph (small
  squares for default, dashed rules for compat, o/O for extracompat)
  instead of reusing the whole-node volume_filled/volume_empty,
  mirroring the meter_channel_* treatment already done for monitors.

- The Fill(1) gap between a row's volume area and its meter area grew
  proportionally with terminal width, wasting space in wide terminals
  for what's just a divider. Replaced with a small fixed MIDSCREEN_GAP
  in both the plain single-row layout and every Stacked-block row,
  handing the reclaimed width back to the volume/meter areas.

Live-verified against the real PipeWire graph: JamesDSP Sink's cycling
indicator now reads "0 64%" / "1 100%" unambiguously; Sonica Theater/
HDA Intel PCH/SW_NULL_MIC's volume bars and monitor gauges render with
clearly distinct glyphs from each other; cross-node bar-start alignment
(column 14) still holds after the width changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lyphs

The user's original ask was to fix the monitor-bar chars for split rows
(meter_channel_*, already correct after the previous commit's collision
fix) - not to introduce a separate distinct character style for the
volume-setting bars themselves. Revert volume_channel_filled/
volume_channel_empty entirely: ChannelRowWidget/RadiatingRowWidget's
volume bars go back to plain volume_filled/volume_empty, same as the
whole-node bar. meter_channel_* (the actual fix requested) is untouched.

Live-verified: Sonica Theater/HDA Intel PCH/SW_NULL_MIC's volume bars
now render with the same character as PYLE_SOUTH/cmus-out's whole-node
bars, while their monitor gauges keep the distinct meter_channel_*
glyph.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…/right

RadiatingRowWidget's paired-row meter called meter::render_stereo,
which uses meter_left_active/meter_right_active - the exact same
characters as a normal whole-node stereo meter. Its unpaired siblings
in the same Stacked block use meter_channel_active instead. Within one
split block this meant a pair row's monitor gauge and an unpaired
row's monitor gauge used two visually unrelated character sets, which
read as "completely different" rather than as two rows of the same
kind of gauge.

Added meter::render_stereo_channel (shares render_stereo's layout via
a new render_stereo_with_chars core, parameterized on which glyphs to
use per side) and wired it into RadiatingRowWidget's paired case.
Every row in a Stacked block - paired or unpaired - now uses
meter_channel_* consistently; the classic single-pair fast path
(StereoVolumeWidget/MeterWidget) is untouched, since it's not part of
a multi-row block that needs to agree with anything else.

Live-verified against the real Sonica Theater device: F L/R, R L/R,
FC, and LFE all show the same meter_channel glyph now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Full revert of the meter_channel_* concept (task tsowell#58 onward). Every
per-channel/per-row monitor gauge now calls the exact same
meter::render_mono/render_stereo the whole-node MeterWidget always
has, using meter_left/right/center_* - no distinct "channel" glyph, no
new char_set fields. ChannelRowWidget's meter is meter::render_mono;
RadiatingRowWidget's paired-row meter is meter::render_stereo, its
unpaired-row meter is meter::render_mono. Removed
meter_channel_inactive/active/overload from CharSet, CharSetOverlay,
all three built-in defaults, and wiremix.toml. Removed
render_mono_channel/render_stereo_channel/the *_with_chars core
functions from meter.rs, back to meter.rs's original shape.

Updated/removed tests whose premise was the distinct glyph:
channel_mode_meter_shows_each_channels_own_peak and
radiating_pair_row_honors_forced_mono_peaks now assert on stock
meter_left/right_active glyphs (using meter_center_left_active, which
only render_stereo ever emits, to distinguish "rendered as stereo"
from "rendered as mono" now that the active glyph itself is identical
either way in extracompat); paired_and_unpaired_rows_share_the_same_
monitor_glyph replaced with paired_row_meter_uses_the_same_stock_
glyphs_as_a_whole_node_meter, asserting stock reuse instead of a
distinct shared glyph.

Live-verified against the real Sonica Theater device: F L/R, R L/R,
FC, and LFE all show plain "┃" - the same character the classic
whole-node stereo meter has always used, nothing new anywhere near
monitoring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Simple cosmetic tweak per request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…optional meter_channel_* glyphs

Four fixes from a live-testing pass against real hardware (Sonica
Theater, HDA Intel PCH, SW_NULL_MIC):

- The monitor/meter section started 2 columns further right on
  Stacked/Radiating rows than on classic single-row nodes, even
  though their volume bars already lined up at the same column.
  Root cause: 3 of 4 structurally-identical row/meter `Layout`s had
  a stray `.spacing(1)` the 4th (classic) one lacked, and separately,
  the classic layout's leading `NODE_VOLUME_PADDING` constraint
  shrank its Fill budget relative to a split row's. Removed the
  stray `.spacing(1)` calls and folded `NODE_VOLUME_PADDING` into
  `VOLUME_LABEL_WIDTH`/`STEREO_LABEL_L_WIDTH` instead of keeping it
  as an external constraint, so the outer constraint list is now
  byte-for-byte identical between the two paths. Verified live: both
  volume bar (col 14) and meter (col 34) now start at the same
  column for every row, split or not.

- unified_imbalance = "cycle"'s label rendered the channel index and
  percentage as one right-aligned string, so the index digit's own
  column shifted depending on the percentage's digit count ("0 64%"
  vs "1 100%"). Split into independently right-aligned index/percent
  sub-columns, matching Channel mode's own label_col/percent_col
  pattern.

- A selected node's marker only appeared on its header row in Linked
  mode when split (Stacked/Radiating), leaving every row below it
  looking unselected. Now spans every row with a top/middle/.../
  bottom bracket, generalizing the classic 2-line node's own
  SelectorWidget shape to however many rows the block has.

- Added optional meter_channel_* char_set fields (mirroring
  meter_left/right/center_*) for per-row split monitors specifically.
  Unset by default (falls back to the stock glyph, so out-of-the-box
  rendering is unchanged) - a theme can opt in to a distinct
  split-row monitor look without affecting the classic whole-node
  MeterWidget, which never consults these fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The volume:meter split was hardcoded 50/50 (Fill(4)/Fill(4)) and every
bar/meter row reserved an unconfigurable trailing Fill(1) - proportional
to the row's own remaining width, so it grew into real wasted space on
a wide terminal. Both are now config settings:

- meter_width_percent (1-99, default 50): percentage of a row's
  combined volume+meter width given to the meter side, the rest to
  volume. Applies uniformly everywhere a meter is drawn - classic
  single-row nodes and every row of a Stacked/Radiating block alike,
  via a shared meter_split_weights() helper so they can't drift apart.

- right_margin (default 0): blank columns reserved at the right edge
  of every bar/meter row, peaks on or off. Defaults to 0 so content
  uses the full available width instead of an implicit margin -
  configurable back up if a margin is wanted.

Both plumbed through Config/ConfigFile the same way max_volume_percent
already is (validated Option<f32> for the percent, resolved to a plain
field), documented in wiremix.toml, and covered by new tests (config
validation/defaults, and rendering tests confirming both settings
actually move the meter column and shrink content on a real render).
237/237 tests. Live-verified: right_margin = 0 lets bars reach the
pane's last column; right_margin = 10 reserves exactly 10 blank
columns; meter_width_percent = 30 visibly widens the volume side and
narrows the meter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iew cycling

Two features from this session's live-testing pass:

- Theme gained meter_channel_inactive/active/overload/center_inactive/
  center_active (Option<Style>, mirroring CharSet's meter_channel_*
  glyph fields) - a theme that gives split rows a distinct glyph via
  meter_channel_* char_set fields can now also compensate its color if
  needed (e.g. a thin Dingbats-block glyph reading lighter than the
  classic box-drawing one at an identical RGB value - a font-rendering
  effect, not a color bug, verified by inspecting the raw ANSI codes
  wiremix sends). Unset by default, so out-of-the-box rendering is
  unaffected; meter.rs's render_channel_stereo/render_channel_mono
  resolve each color the same way they already resolve glyphs
  (`.unwrap_or(stock)`).

- New ChannelView enum (Unified/Linked/Channels) formalizes the three
  high-level ways the object list can display/target a node's volume,
  derived from channel_mode/channel_display rather than stored
  separately (ObjectList::channel_view) so it can't drift out of sync
  with them. Action::SelectView jumps directly to one; Action::CycleView
  steps through Config::view_cycle (default all three, in order),
  wrapping, landing on the cycle's first entry if the current view was
  reached via SelectView while excluded from the cycle. Space is now
  bound to CycleView by default (was ToggleChannelMode) - both
  ToggleChannelMode/CycleChannelDisplay remain available as the
  lower-level two-axis primitives CycleView/SelectView are built from.

244 tests (11 new: theme color fallback, config validation/defaults
for view_cycle, ObjectList's four new methods). Live-verified: Space
cycles Linked -> Channels -> Unified -> Linked in the live test
session, matching view_cycle's default order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Unified view (channel_mode off, channel_display = "unified") now uses
the literal pre-fork upstream layout instead of this branch's
alignment-driven one - checked directly against tsowell/wiremix's own
node_widget.rs (merge-base cbdc90f): Length(2) leading padding, Fill(4)/
Fill(1)/Fill(4)/Fill(1) (proportional, not the fixed-column
MIDSCREEN_GAP/right_margin Linked/Channels use), and VolumeWidget's own
5-wide "{percent}%" label - not the wider column-14-aligned one.

Linked/Channels rows never appear in a stock-settings Unified-view list
(no Stacked/Radiating row ever shows there under unified_imbalance =
"none"), so there was nothing for Unified view's own column to stay
aligned with in the first place - matching stock exactly costs nothing.
unified_imbalance = "cycle" is the one place Unified is allowed to
differ from true stock (label widens from 5 to 6 columns, to fit
"{index} {percent}%" without truncating) - a deliberate fork feature,
opted into explicitly, not something stock's own layout needs to
accommodate by default.

ChannelState::view() (used by ObjectList's own Action::CycleView this
session already added) is the discriminator: Linked/Channels share
ObjectList's existing aggressive/aligned layout unchanged;
VolumeWidget/the classic bar_area split both switch on it, so a
single-channel node picks the right layout depending on which of the
three views the whole list is currently in, not just its own per-node
display state.

246 tests (4 new: label width matches the stock formula exactly and
widens by exactly one column for cycle, Unified/Linked bar columns are
provably different, right_margin's test updated since it no longer
applies to Unified view by design). Live-verified: Unified view's bar
starts at column 10 (marker 1 + stock padding 2 + cycling-widened label
6 + spacing 1), matching the hand-derived formula exactly; Linked view
unchanged at its existing aligned column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the single global meter_width_percent/right_margin pair with
three independent MeterLayout settings, one per ChannelView, each with
its own optional meter_width_percent/gap/right_margin. Leaving a field
unset reproduces stock wiremix's own proportional layout for that
segment; setting it opts just that field, for just that view, into a
fixed-column override - so factory defaults closely match classic
spacing in all three views while keeping every prior customization
option.

Unset gap/margin weights are now derived from the current volume+meter
weight sum rather than a hardcoded Fill(1), fixing a bug where a custom
ratio away from 50/50 would silently shrink the gap to near nothing
(ratatui's Fill distributes proportionally across the whole constraint
list, not scoped pairs). Peaks::Off rows also now respect right_margin,
which they previously ignored.
… settings

gap/right_margin now default to small fixed values (1 and 3 columns,
about half of stock's own proportional right_margin) rather than
reproducing stock's wider proportional spacing, universally across all
three views - still fully overridable per view. meter_width_percent is
unaffected, still proportional by default. As a side effect, Unified
view's literal byte-for-byte stock fast path is no longer reachable via
any real config (every MeterLayout field now has a non-None default),
though the mechanism is left in place rather than removed.

Also builds out expand_unused_label_space and
expand_unpaired_channel_bars for real (previously just reserved
scaffolding): a lone stereo pair's row and a Unified-view row both
reclaim label space they aren't using this render into their bar(s),
and an unpaired channel's row in a radiating block can stretch across
the row's full width instead of matching a would-be paired row's half
width. Both remain opt-in, default off.
…from meter side

Five live-testing corrections:

- Unpaired channel rows in a radiating block now fill classic
  left-to-right (matching every other bar in the app) instead of
  reusing the paired branch's grow-from-center direction - was
  harmless at the old half-width, became obviously wrong once
  expand_unpaired_channel_bars widened the bar.

- Unified view's bar-start column no longer varies per row based on
  whether that row happens to be actively cycling - expand_unused_label_space
  no longer touches VolumeWidget/Unified view (only StereoVolumeWidget's
  lone-pair rows, where every row reclaims the same fixed amount
  unconditionally). Per-row column jitter while scrolling a list was
  worse than the space it reclaimed.

- gap/right_margin are now carved out of the meter side alone via a
  two-stage split (ratio applied first with nothing else in the
  list, then gap/margin taken from within the meter side's own
  share) rather than being Length siblings in the same Fill split as
  the volume:meter ratio - growing either no longer costs the volume
  side any width. Simplified from Option<u16> to plain u16 now that
  there's no meaningful "proportional" fallback left for either.
  gap's default bumped 1 -> 2. This also retires the now-semantically-
  broken unified_classic literal-stock-array fast path - every view
  uses the same generic split unconditionally now.

- Cycling display in Unified view shows a short channel label ("L"/
  "R", a real channel name, or the raw index as a last resort)
  instead of always showing the bare index.

- expand_unused_label_space now defaults to on.
…y visible

Two corrections from live testing:

- Unified view's cycling label width is decided per-row again
  (self.cycling_channel.is_some()) rather than by the config-level
  unified_imbalance flag - a balanced row's default width must stay
  untouched regardless of whether cycling is configured elsewhere in
  the list. This does reintroduce column variation between balanced
  and actively-cycling rows, a direct trade-off against the previous
  round's fix - the two pieces of feedback pull in opposite
  directions, and this is the more recent, more specific one.

- expand_unused_label_space's StereoVolumeWidget reclaim was only
  ever 1 column per side (preserving alignment with RadiatingRowWidget),
  which turned out to be imperceptible in practice. Now drops both
  labels straight to their true minimum content width, discarding
  the alignment fold entirely - an 8-column combined reclaim instead
  of 2, split into +4 bar width per side.
HoneyHazard and others added 12 commits August 12, 2026 22:07
Three fixes:

- Real bug: a sub-Layout::split used to lay out the cycling channel
  label + percentage left ratatui's default flex to strand 2 blank
  columns between "%" and the bar instead of before the label where
  the alignment fold belongs. Now computed as explicit Rects anchored
  to the label area's own right edge instead of trusting flex
  behavior - deterministic, and the "%"-to-bar gap is always exactly
  1 column.

- gap is now a floor, not a fixed override: split_meter_row scales it
  up with the meter side's own available width (1-in-8, matching
  stock's own historical gap proportion) once there's room for that
  to exceed the configured minimum, rather than staying visually
  cramped in a wide terminal. right_margin is unaffected.

- Mute placement rewritten across every widget. StereoVolumeWidget and
  RadiatingRowWidget both used to collapse their entire row into one
  centered "muted" string, blanking bars, labels, and dividers all at
  once; VolumeWidget's Unified-view mute overwrote the whole label
  area including a cycling channel's own short label. All three now
  render normally and substitute "muted" for just the percent text.
  Investigated first whether per-channel mute is possible at all
  (SPA_PROP_mute is a single node-level boolean, unlike
  SPA_PROP_channelVolumes' real per-channel array) before deciding
  scope - it isn't, so this only fixes placement, not per-channel
  targeting.

  Fixing mute's placement surfaced a second real bug: RadiatingRowWidget's
  label_l/label_r and VolumeWidget's cycling percent_col were both
  sized for a 4-character "100%" only, truncating "muted" (5 chars)
  to "uted" - invisible before since both widgets used to bypass
  these columns via the old centered-mute special case. Widened to 5
  (RADIATING_PERCENT_WIDTH), which in turn required bumping
  STEREO_LABEL_L_WIDTH and VOLUME_LABEL_WIDTH by the same 1 column to
  keep the "every node's bar starts at the same column" alignment
  invariant intact.
…ing the row

Every Unified-view row's bar now starts at exactly the same column
regardless of balance state, without widening the balanced default at
all. Simplified VolumeWidget back to a single label width
(STOCK_VOLUME_LABEL_WIDTH, unconditional) and draw the channel label
and percent text as two passes into the same Rect: label first,
left-aligned; percent (or "muted") second, right-aligned, on top. The
percent always wins overlapping cells, so a short label leaves a
natural gap and a long one silently loses its trailing character(s) to
whatever the percent needs - an accepted trade-off for holding the bar
to a fixed column instead of reserving dedicated width for the label.

STOCK_VOLUME_LABEL_WIDTH_CYCLING and CYCLING_CHANNEL_LABEL_WIDTH are
gone - there's no longer a separate wider case to compute.
…b-area

STOCK_VOLUME_LABEL_WIDTH grows from 7 to 9 (shifting the bar and
percent/mute text 2 columns right, uniformly across every Unified
row) to make room for a new dedicated UNIFIED_CHANNEL_LABEL_WIDTH (4)
sub-area, anchored at volume_label's own left edge - the label's
position doesn't move. The label now renders right-aligned within
that sub-area instead of left-aligned across the whole row, so a
short label sits closer to the percent instead of hugging the far
left.

The label and percent sub-areas can no longer overlap by construction
(4 + 5 = 9, sized for the percent's own worst case, "muted") - the
previous draw-then-overwrite approach could genuinely clip a label's
trailing characters when a long label collided with a wide percent.
The "every Unified row's bar lands on the same column regardless of
balance state" guarantee is untouched.
…ault

meter_channel_* is renamed to meter_split_* and its trigger moves from
"which widget struct is drawing" to "is the active view Unified or
not" - this closes a real gap where a lone stereo pair's classic
single-row meter kept stock ┃/█ glyphs in Linked/Channels view even
though its adjacent volume bar already looked split. render_stereo/
render_mono absorb render_channel_stereo/render_channel_mono entirely.

meter_split_* now ships ❘/▇ as CharSet::default()/compat()'s real
default (previously None, opt-in only); extracompat() stays None to
preserve its ASCII-only guarantee. Theme's meter_split_* colors stay
unset everywhere - only characters got a new default.

Also documents a pulsemixer-inspired volume_filled/volume_empty
example (▮/▯, no inserted spacing) in wiremix.toml, and drops the
user's now-superseded meter_channel_* block from their live config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This example was documented against volume_filled/volume_empty, but
it was meant to illustrate the peak meter's inactive/active glyphs
(hollow-vs-filled, same coloring split as the active portion already
uses) for a planned, separate off-by-default meter feature - not this
branch, and not the plain volume bar. Removing it here rather than
leaving incorrect documentation in place; the real example belongs on
that feature's own branch once it exists.
render_channel_rows built one Constraint::Length(1) per row (header +
every channel group) regardless of how much vertical space the object
list actually handed it. When the object list's own "partial node at
the bottom of the viewport" mechanism gives a Stacked block less height
than its full node_height (e.g. scrolling a 6-channel device like a 5.1
card into view a few rows short), ratatui's Layout::split can't satisfy
every Length(1) constraint - and instead of dropping only the trailing
rows, it spreads the shortfall across all of them. A 6-channel block
squeezed 2 rows short rendered header, then channels 2/3/4/6 - channels
1 and 5 silently vanished, leaving a channel row with no header above
it and a gap in the middle of the block.

Fix: clamp the constraint count to how many whole rows actually fit
(area.height - 1, capped at groups.len()) before building the Layout,
so ratatui always has exactly enough space for what it's asked to
render - a clean cut at the bottom instead of scattered gaps.
Shipping distinct "❘"/"▇" glyphs by default for Linked/Channels-view
meters made them visually clash with a lone stereo pair's own volume
bars (which already convey "this is split" via L|R layout) and, once
meter-zone-preview is merged alongside this branch, with its
always-on inactive-overload marker (which intentionally doesn't
follow meter_split_*). Reverting to None (falling back to the same
stock meter_left/right/center_* glyphs Unified view uses) keeps the
volume-bar split and per-channel label as the only visual cue a view
has changed, matching classic wiremix's own meter look everywhere.
The meter_split_* fields/mechanism themselves are untouched - still
fully overridable per-user, just no longer opinionated defaults.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The event loop only redraws in response to a real event (keypress or
PipeWire state change) - when nothing else needs a redraw it blocks
indefinitely on the next one. unified_imbalance = "cycle"'s display
depends purely on wall-clock time, with no event of its own, so a
node stayed pinned to whichever channel it happened to be showing the
moment the screen last redrew for some unrelated reason, instead of
advancing every ~1.5s as intended - reproduced live: a channel label
stuck on the same value for 6+ seconds with cycle configured.

Fix: whenever unified_imbalance = "cycle" is configured, cap the
otherwise-indefinite wait with a short timer (250ms, well under the
per-channel interval) and treat that wake-up itself as a reason to
redraw - handle_events correctly reports "no event handled" for a
timeout, so needs_render has to be set independently of its return
value here. Live-verified: the same node now visibly alternates
between channels every ~1.5s with no other activity in the terminal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Channel mode deliberately never marked the header row - only the
individually-targeted channel row got a marker, on the theory that
one marker was less cluttered than two. Live testing showed the real
effect: combined with row_selected_extend_above/below bleeding into
the gaps around the block, the selection read as three disconnected
highlighted segments (a sliver above, the one targeted row, a sliver
below) with unmarked gaps in between - not "this item is selected."

Fix: draw the same continuous top/middle/.../bottom bracket every
other split display already uses (Linked mode's Stacked blocks,
Radiating rows) regardless of channel_mode, so the whole block always
reads as one selected item. The individually-targeted channel is
still called out - reversed (fg/bg swapped) rather than a different
glyph, so it stands out without breaking the bracket's visual
continuity. Updated the two tests that encoded the old header-never-
marked, single-row-only behavior; added a small style-inspection
helper since the existing render_node_lines only returns glyphs, not
the modifier needed to check the reversed cell specifically.

Also fixes the meter_split_overrides_apply_wherever_view_is_not_
unified test, which needs meter_split_left/right_inactive and
meter_split_center_left/right_active explicitly set now that none of
the meter_split_* fields default to a real glyph (previous commit) -
otherwise the parts it didn't override correctly fall back to the
stock glyph, which the test's own assertion was written to reject.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CharSet::default()'s filled-block meter (already individually-inset
rectangles) correctly stays meter_split_* = None - Linked/Channels
render identically to Unified there, confirmed working as intended.

CharSet::compat()'s thin-bar/thick-center meter is different: its
Unified-view glyphs ("┃"/"█") render as a seamlessly connected line
with no gap between repeated cells, so reverting its meter_split_*
to None as well (067a90c) made split views visually indistinguishable
from Unified in that char_set specifically. Restore compat()'s
meter_split_* to the disjoint "❘"/"▇" pair - same thin-bar/block
family, but with a natural vertical gap between repeated cells,
so a split view still reads as visually distinct without introducing
an unrelated shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
render_stereo_core used Constraint::Fill(2)/Fill(2) for the left/right
meter halves - Ratatui's Fill remainder distribution isn't guaranteed
symmetric at odd leftover widths, so identical L/R peaks could render
a different number of characters on each side depending on terminal
width. Same bug class already fixed for the volume bar (see
StereoVolumeWidget's own bar_width). Fixed the same way: compute one
shared bar_width via explicit division and give both sides the same
Length(bar_width) instead of two independent Fill constraints. Since
both MeterWidget (Unified) and RadiatingRowWidget (Linked) render
through this same function, one fix covers both views. Added
stereo_meter_bars_have_symmetric_width_for_equal_peaks, sweeping
several widths - uses extracompat specifically, since default/compat
both reuse the identical glyph across active/inactive (color-only
distinction), which defeats counting by plain-text symbol.

Also fixes two tests the previous commit's revert (of the Channel-mode
selector's "continuous bracket" change) reintroduced from before that
change existed: paired_row_meter_uses_the_shared_meter_split_glyph_by_
default assumed "default" char_set ships a distinct meter_split_*
glyph, which is no longer true (only "compat" does, correctly - see
a9aad7a) - pinned it to char_set = "compat" explicitly.
meter_split_overrides_apply_wherever_view_is_not_unified only
overrode 2 of the 10 meter_split_* fields in its own test char_set,
so the unoverridden ones fell back to stock and broke its "no stock
glyph anywhere" assertion once "default"'s own meter_split_* correctly
went back to unset - added the missing overrides.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HoneyHazard added a commit to HoneyHazard/wiremix that referenced this pull request Aug 19, 2026
… binary

Previous captures used the combined wiremix-extras binary, which also has
meter-zone-preview and dark-theme merged in - their features (the dim-red
overload-preview marker, the redshift color palette) were bleeding into
screenshots for a PR that has nothing to do with either. Recaptured
everything with multichannel-pairing's own build (confirmed via source
grep to have no meter_inactive_overload at all), verified free of the
marker, with genuine live peak activity throughout (confirmed via
repeated frame diffing before each save, not static/idle captures).

Also dropped pulsemixer-zone-preview.png -> pulsemixer.png (the
zone-preview-specific framing didn't belong here - replaced with a clean
capture of just the volume_filled/volume_empty theming idea, which is
genuinely this branch's own content), and removed dither-zone-preview.png
and both redshift-theme-*.png screenshots entirely (zone-preview and
dark-theme content respectively, neither of which this PR touches).
…nelView

The two lower-level booleans/enums (channel_mode, channel_display) predate
the three named views (Unified/Linked/Channels) - they were the original
config surface, and the view abstraction was bolted on top of them a day
later without ever removing them. This left two overlapping, confusingly-
named ways to say the same thing at the top level.

Verified this collapse is lossless, not a trade-off: ChannelState::view()
already showed channel_mode always wins over channel_display whenever
it's true, meaning the two axes only ever produced 3 distinct outcomes
(mapping exactly onto Unified/Linked/Channels), never 4 - the 'individual
targeting with no visual split' combination some doc comments described
was never actually reachable.

- Config/wiremix.toml: channel_display + channel_mode -> initial_view.
- ObjectList: channel_mode + channel_display -> view: ChannelView, stored
  directly instead of two fields a widget had to reconcile.
- ChannelState: same collapse, channel_state.view() (a computed method)
  is now just channel_state.view (a plain field, matching every other
  field on the struct).
- Removed Action::ToggleChannelMode/CycleChannelDisplay and their
  keybindings - both fully superseded by the existing SelectView/
  CycleView, and neither had a default binding to begin with.
- opt.rs: --channel-display/--channel-mode/--no-channel-mode CLI flags
  collapse to a single --initial-view.
- wiremix.toml docs and the user's own multichannel-pairing-test.toml
  updated to match; the latter's 'v' keybinding (CycleChannelDisplay) is
  removed since that action no longer exists.

cargo test --release: 257/257 passing. cargo fmt/clippy clean.
wiremix has no imbalance handling at all today, so defaulting to "none"
(silently averaging an imbalanced node with no indication) just
reproduced that gap rather than improving on it. "cycle" costs nothing
when a node is actually balanced (the common case) and surfaces real
information the rest of the time, so it's a better default than a
config surface nobody would think to touch.

Also promoted the cycle interval (previously a hardcoded 1.5s constant)
to unified_imbalance_cycle_seconds, following the same Config/ConfigFile/
opt.rs/ChannelState plumbing as every other per-render setting here.
Validated > 0 at parse time; ChannelState/ObjectList carry it alongside
unified_imbalance so cycling_channel() reads it directly instead of a
module constant.

cargo test --release: 261/261 passing (new: interval TOML parse/
validation tests, a dedicated interval-respected test, plus a fix to
unified_view_renders_single_averaged_bar, which needs unified_imbalance
= "none" explicitly now that cycling is the default). fmt/clippy clean.
…docs

NOTES-multichannel.md never existed in this repo's git history - those
doc comments were citing a file that ships with nothing, misleading a
reader into thinking deeper rationale exists elsewhere. Rewrote each to
state its own rationale inline instead.

Also clarified ObjectList::view's doc comment: it's easy to misread as
related to the `view: &view::View` parameter several of this same
struct's own methods take (the live PipeWire object-graph snapshot) -
they're unrelated concepts that happen to share a name.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant