Skip to content

Add permanent, cross-instance-synced hide/show for list items (Ctrl+t) - #72

Open
HoneyHazard wants to merge 11 commits into
tsowell:mainfrom
HoneyHazard:hide-items-permanent
Open

Add permanent, cross-instance-synced hide/show for list items (Ctrl+t)#72
HoneyHazard wants to merge 11 commits into
tsowell:mainfrom
HoneyHazard:hide-items-permanent

Conversation

@HoneyHazard

@HoneyHazard HoneyHazard commented Aug 7, 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.


Depends on the per-instance hide PR - this branch is stacked on top of it, so the diff shown here includes its changes too until that one merges. Please review/merge that one first if possible; happy to rebase this once it lands.

The previous PR added t ("toggle hide") to hide an item for the current session only - useful, but it's forgotten the moment wiremix restarts. This adds a second, independent tier on Ctrl+t - the same "toggle hide" mnemonic, just permanent: a permanent hide that's saved to disk and rediscovered on every future launch, including live-syncing to any other wiremix instances that are already running against the same state file - no restart needed on either side.

  • Items are identified by node.name (via a new MatchCondition::from_node_name() constructor), reusing the exact same matching engine [[filters]] already uses - not raw object IDs, which never survive a restart.
  • A small $XDG_STATE_HOME/wiremix/hidden.toml file holds the list of hidden matchers, written atomically (temp file + rename) so a crash - or another instance saving at the same moment - can't leave a half-written file behind.
  • Other running instances pick up a change the moment it's saved via an inotify watch on the file's directory (not the file itself - a direct file watch would go stale the moment save()'s temp-file-then-rename replaces the underlying inode). Linux-only, gated behind target_os = "linux" with a no-op fallback elsewhere (a no-op is a safe fallback here since wiremix already depends unconditionally on Linux-only things like nix and has no PipeWire port to other platforms); everywhere else, sync still happens on next restart via the existing load-at-startup path.
  • The list becomes three-tier: visible items first, then instance-hidden, then permanently-hidden at the very end - both hidden groups keep their own relative order among themselves.
  • Reuses the row_hidden theme key from the previous PR for both tiers' text styling (now defaulting to a visible DarkGray/DIM tint rather than empty - see that PR for details), and adds one new char_set key, hidden_permanent (default "[perm-hide] "), so the two tiers stay visually distinguishable by their title prefix even when styled identically.
  • Permanently hiding the selected item releases the selection the same way the per-instance hide PR does - moves to whatever's next in line, falling back to the previous item, or clearing entirely if it was the only one. Un-hiding keeps the selection where it is.
  • Picks up the capture_hidden option from the per-instance hide PR automatically (this branch stacks on top of it) and extends the same filtering to this tier: permanently-hidden items are captured under the same rules as regular ones by default, or excluded - with a blank peak meter - via --no-capture-hidden. The exclusion applies to the cross-instance file-sync path too, so another instance's hidden-state change stops (or resumes) capture here exactly the way toggling Ctrl+t locally would.

A visible item, an instance-hidden item, and a permanently-hidden item, each with its own distinct prefix

Default theme with just row_hidden set. [hide] pw-cat is instance-hidden (t); [perm-hide] defcon is permanently hidden (Ctrl+t) and ranks below it.

Same permanently-hidden item side by side: peak meter still animating under the default capture_hidden=true, completely blank under --no-capture-hidden

Both instances show the same [HIDE] SW_MICS_PRE item, permanently hidden with Ctrl+t. Left: default capture_hidden = true - the meter keeps reporting real levels. Right: --no-capture-hidden - the meter is blank because the capture stream itself is stopped.

[themes.default]
row_hidden = { fg = "DarkGray" }

[char_sets.default]
hidden_instance = "🙈 "
hidden_permanent = "🚫 "

A real bug this surfaced, worth calling out explicitly: the capture-eligibility gate can't just check a cached "is this object currently permanently hidden" set - on startup, PipeWire delivers an initial flood of eligibility events before there's ever been a chance to compute that cache from the freshly-loaded state file, so a node that's genuinely supposed to stay permanently hidden would start being captured again on every fresh launch. Fixed by evaluating the capture gate directly against the durable matcher list instead of the cache (the cache is still used for the cheap, non-critical stuff - sort order and styling). Found this via live pw-dump verification, not the unit tests - the mocked test setup doesn't reproduce the real startup event ordering that exposed it.

On the live-sync design: an earlier version of this used a PipeWire Metadata object to broadcast changes between instances, but that approach was abandoned after causing a real, live production issue, so it's not part of this PR's history. The inotify-based approach here has no PipeWire object creation or lifecycle involved at all - it's purely local filesystem event watching, reusing the same directory HiddenState::save() already writes to for restart-persistence. That keeps the blast radius of a bug in the sync path limited to "this instance's hidden list doesn't update live," never "this instance corrupts or confuses the shared PipeWire graph."

Tested:

  • cargo test --release: 181/181 passing (includes the same selection-release edge-case tests as the per-instance hide PR, applied to Ctrl+t, plus capture_hidden coverage for the permanent tier: eligibility checks, explicit toggling, and the file-sync path, in both directions)
  • cargo fmt --check / cargo clippy --release --all-targets -- -D warnings / cargo doc --no-deps (matching this repo's CI): all clean
  • Live-verified against a real PipeWire graph with an isolated $XDG_STATE_HOME: permanently hiding a node stops its capture stream immediately; a full process restart rediscovers it as hidden from the saved file and it stays uncaptured (this is what caught the bug above - an earlier version of this branch showed the item as hidden in the UI on restart, but was still silently capturing it); un-hiding resumes the capture and clears the file entry
  • Live-verified the new cross-instance sync specifically: with two real wiremix processes sharing an $XDG_STATE_HOME, hiding a node in one instance stops capture in both within about a second, with no restart of either; un-hiding does the same in reverse. Verified via pw-dump, checking both instances' PipeWire client nodes directly, since simultaneous real background audio activity made the on-screen list too noisy to eyeball reliably
  • Verified leaving Ctrl+t unused (and hidden_permanent unset in config) never touches the state file or changes any existing behavior
  • Manually verified in tmux that capture_hidden's default (permanently-hidden items keep reporting levels) and --no-capture-hidden (blank meter, immediately on toggle and on file-sync from another instance) both work for this tier the same way they do for the per-instance one

Lets a user hide individual nodes/devices from their own running
instance with the t key (toggle). Hidden state is purely local:
never persisted to disk, never synced to other running instances -
that's covered by a planned follow-up perma-hide tier (Ctrl+t).

- Hidden items sink to the bottom of their list, in a stable sort
  that otherwise preserves the existing object_serial order - both
  the visible group and the hidden group keep their relative order.
- Excluded from peak capture: start_capture() gates on
  hidden_instance the same way it already gates on lazy_capture's
  visible_objects, and toggling hidden explicitly stops/resumes an
  in-flight capture immediately rather than waiting for the next
  eligibility event.
- New row_hidden theme key, patched onto a hidden row's text spans
  (title, target, volume%, and Configuration tab's device/profile)
  using the same Style::patch() no-op-by-default mechanism as the
  rest of the theme system.
- New hidden_instance char_set key (default "[hide] ") prepended to
  a hidden item's title, so hidden items are identifiable even with
  row_hidden left unset - built-in char_sets and wiremix.toml's docs
  suggest an emoji alternative (e.g. "🙈 ").
Two fixes to the per-instance hide feature:

- row_hidden defaulted to an empty style in every built-in theme, so the
  only visual difference for a hidden row was its char_set prefix
  ("[hide] "/emoji) - easy to miss at a glance. default now dims it with
  DarkGray, nocolor with the DIM modifier (matching that theme's existing
  "dim = inactive" convention elsewhere). plain is left alone on purpose -
  it's styled nowhere else either, relying entirely on the prefix text.

- Hiding the selected item left the selection pinned to it. Since hidden
  items sink to the bottom of the list, this meant the cursor stayed on a
  now-relocated, visually de-emphasized row instead of following what the
  user was actually looking at. ObjectList::release_hidden_selection()
  moves the selection to whatever comes right after the hidden item in
  the list's current (pre-sink) order, falling back to whatever comes
  before it if it was last, or to no selection at all if it was the only
  item. Unhiding intentionally keeps the selection where it is - the user
  is still looking at that item, there's nothing to release.
Hiding an item for this instance currently always stops its peak
capture unconditionally, with no way to opt out. That's a hardcoded
behavior, not a configurable one - hiding is about decluttering the
visible list, and some users may want hidden items to keep reporting
levels exactly like any other item rather than having that tied to
visibility.

Adds capture_hidden: bool (default true), also available as
--capture-hidden/--no-capture-hidden on the command line. It's a
filter layered on top of the existing capture-eligibility rules
(lazy_capture, filters, etc.), never a replacement for them - when
true (the default), hidden items are captured under exactly the same
rules as regular ones, reproducing today's non-hidden-item behavior
for hidden items too. When false, hidden items are excluded from
capture in addition to whatever else already excludes non-hidden
items, matching the previous hardcoded-exclusion behavior exactly.

- start_capture()'s existing hidden_instance check is now gated behind
  !capture_hidden instead of always applying.
- ToggleHiddenInstance's explicit stop_capture() call on hide (and the
  resuming start_capture() call on unhide) are similarly gated -
  important not just for correctness under the new default, but to
  avoid double-starting an already-running capture stream when
  capture_hidden is true and hiding never stopped it in the first
  place.
- When monitoring actually is suspended for a hidden item (hidden +
  capture_hidden = false), NodeWidget now leaves the peak meter
  completely blank instead of drawing the usual inactive-looking
  placeholder over a stream that will never receive samples, which
  otherwise reads as broken rather than intentionally off.
Extends the per-instance hide from the previous branch with a second,
independent tier: Ctrl+t hides/shows the selected item durably -
saved to disk, and rediscovered against live nodes on every future
launch (including other already-running instances that reload the
same file, though nothing here makes that reload happen live yet -
see the follow-up branch for cross-instance sync via PipeWire
Metadata).

- Items are identified by node.name via a new MatchCondition::from_node_name()
  constructor, reusing the existing [[filters]] matching engine rather
  than raw object IDs, which don't survive a restart.
- New src/hidden_state.rs module handles load/save of a small
  $XDG_STATE_HOME/wiremix/hidden.toml file (mirroring the existing
  $XDG_CONFIG_HOME resolution for the main config file), with an
  atomic temp-file-then-rename write.
- Adds Serialize support to MatchCondition/MatchValue/PropertyKey
  (previously deserialize-only, since nothing needed to write a
  matcher back out before this), implemented as the exact inverse of
  their existing FromStr parsing.
- Sort order becomes three-tier: visible, then instance-hidden, then
  permanently-hidden at the very end - both hidden groups keep their
  own relative order.
- Reuses the row_hidden theme key from the previous branch (same
  faint-text treatment for both hidden tiers) and adds a second
  char_set key, hidden_permanent (default "[perm-hide] "), so the
  two tiers stay visually distinguishable by their title prefix even
  when styled identically.
- Capture gating gate is evaluated directly against the durable
  matcher list, not a cached per-object-ID set - the initial flood of
  capture-eligibility events on startup is handled before the first
  opportunity to recompute that cache, so relying on the cache alone
  let an already-permanently-hidden node start capturing again on
  every fresh launch (caught via live pw-dump verification, not the
  unit tests, which mock past the real startup ordering).
Watches the permanent-hide state file's directory for changes made by
other wiremix instances (e.g. another instance's own Ctrl+t), so the
change takes effect immediately everywhere instead of only on each
instance's next restart. Uses inotify on the directory rather than the
file itself, since HiddenState::save() writes via temp file + rename
and a direct file watch would go stale after the first rename. Linux-only,
gated behind target_os = "linux" with a no-op fallback elsewhere, matching
wiremix's existing de facto Linux-only scope (unconditional nix dependency,
no PipeWire port to other platforms).

This replaces an earlier PipeWire-Metadata-based approach for live sync
that was abandoned after a production incident; this design has no
PipeWire object creation/lifecycle involved at all, only local filesystem
event watching reusing the same directory HiddenState::save() already
writes to.
Same fix as the per-instance hide/show branch, applied to Ctrl+t:
hiding the selected item now moves the selection to whatever's next in
line (falling back to the previous item, or no selection at all if it
was the only one) instead of leaving it pinned to an item that's about
to sink to the bottom of the list. Reuses release_hidden_selection(),
already added for the instance-hide case. Un-hiding still keeps the
selection where it is.
Shorter and reads more clearly at a glance than "perm-hide" - "[hide]"
(instance) vs "[HIDE]" (permanent) distinguishes the two by case alone
rather than needing an extra word.
NodeWidget's hidden field was split into hidden_instance/
hidden_permanent by the permanent-hide work, but the capture_hidden
meter-blanking check from the earlier commit still referenced the old
single hidden field. Combine both tiers, matching the pattern already
used elsewhere in this widget for hidden-item styling.
The capture_hidden filter introduced alongside per-instance hide only
covered hidden_instance. Extend the same !capture_hidden gating to:

- start_capture()'s hidden_permanent_matchers check
- ToggleHiddenPermanent's stop_capture()/start_capture() calls, with
  the same double-start guard as the instance case
- apply_file_hidden_state_change()'s stop sweep, so another instance's
  hidden-state file change respects the setting too

By default (capture_hidden = true), permanently-hidden items keep
being monitored exactly like regular ones, matching the per-instance
behavior from the previous commit.
@HoneyHazard
HoneyHazard force-pushed the hide-items-permanent branch from a4fd80c to c426ba7 Compare August 9, 2026 11:03
@HoneyHazard
HoneyHazard marked this pull request as ready for review August 10, 2026 05:19
HoneyHazard added a commit to HoneyHazard/wiremix that referenced this pull request Aug 10, 2026
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