diff --git a/Cargo.toml b/Cargo.toml index 7962fc6..b5540ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ itertools = "0.14.0" libspa = "0.9.2" libspa-sys = "0.9.2" log = "0.4.24" -nix = { version = "0.29.0", features = ["event", "term"] } +nix = { version = "0.29.0", features = ["event", "term", "inotify"] } pipewire = { version = "0.9.2", features = ["v0_3_44"] } pulp = "0.22.2" ratatui = { version = "0.29.0", features = ["serde"] } diff --git a/README.md b/README.md index 9d4bb10..a688186 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ less-intuitive mouse controls are: | ------------- | ----------------------- | | q | Quit | | m | Toggle mute | +| t | Hide/show (this instance only) | +| Ctrl+t | Hide/show (permanent, synced) | | d | Set default source/sink | | l/Right arrow | Increment volume | | h/Left arrow | Decrement volume | diff --git a/src/app.rs b/src/app.rs index 6c973d2..566353e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,10 +1,12 @@ //! Main rendering and event processing for the application. use std::collections::HashSet; +use std::path::PathBuf; use std::sync::{mpsc, Arc}; use std::time::{Duration, Instant}; -use crate::config::{Config, Peaks, TabKind}; +use crate::config::{Config, MatchCondition, Peaks, TabKind}; +use crate::hidden_state::HiddenState; use crate::wirehose::state::CaptureEligibility; use crate::wirehose::{ media_class, CommandSender, Event as PipewireEvent, PeakProcessor, @@ -50,6 +52,8 @@ pub enum Action { MoveUp, MoveDown, ToggleMute, + ToggleHiddenInstance, + ToggleHiddenPermanent, SetRelativeVolume(f32), SetDefault, ActivateDropdown, @@ -81,6 +85,12 @@ impl std::fmt::Display for Action { } Action::SetTarget(_) => write!(f, "Set target"), Action::ToggleMute => write!(f, "Toggle mute"), + Action::ToggleHiddenInstance => { + write!(f, "Hide/show for this instance only") + } + Action::ToggleHiddenPermanent => { + write!(f, "Hide/show permanently, synced to other instances") + } Action::SetAbsoluteVolume(vol) => { write!(f, "Set volume to {}%", Self::format_percentage(*vol)) } @@ -208,6 +218,29 @@ pub struct App<'a> { /// Object IDs that are currently visible (including any display /// dependencies) visible_objects: HashSet, + /// Object IDs hidden for this instance only - never persisted, never + /// synced to other instances. Hidden objects sink to the bottom of + /// their list and are excluded from capture. + hidden_instance: HashSet, + /// Durable matchers for permanently-hidden items, loaded from (and + /// saved back to) `hidden_state_path`. Source of truth for + /// `hidden_permanent` - re-evaluated against live state whenever it + /// changes, since object IDs in `hidden_permanent` don't survive a + /// restart but these matchers do. + hidden_permanent_matchers: Vec, + /// Object IDs currently matching a `hidden_permanent_matchers` entry. + /// Recomputed from `hidden_permanent_matchers` whenever state changes, + /// same recompute rather than incremental-update-per-event choice + /// `capturable_objects` etc. don't need since matcher content can + /// change independently of any single state event. Excluded from + /// capture and sinks below `hidden_instance` in the list, same as + /// instance-hidden objects but ranked lower. + hidden_permanent: HashSet, + /// Where to load/save `hidden_permanent_matchers`. `None` if + /// `HiddenState::default_path()` couldn't resolve one (no `$HOME`) - + /// permanent hide then behaves like instance hide for the rest of the + /// session, since there's nowhere to persist it. + hidden_state_path: Option, /// Callback for peak ballistics. peak_processor: Arc, /// Objects eligible for capture. @@ -258,12 +291,132 @@ impl<'a> App<'a> { drag_row: None, help_position: None, visible_objects: HashSet::new(), + hidden_instance: HashSet::new(), + hidden_permanent_matchers: Vec::new(), + hidden_permanent: HashSet::new(), + hidden_state_path: None, peak_processor: Arc::new(peak_processor), capturable_objects: HashSet::new(), capturing_objects: HashSet::new(), } } + /// Loads persisted permanently-hidden-item matchers from `path` and + /// remembers `path` for future saves. Call once after construction and + /// before [`Self::run`] - matching entries won't take effect against + /// live nodes until the first state update recomputes + /// `hidden_permanent`. A no-op if `path` is `None` (no resolvable + /// state directory) or nothing has ever been saved there yet. + pub fn load_hidden_state(&mut self, path: Option) { + let Some(path) = path else { + return; + }; + + if let Ok(hidden_state) = HiddenState::load(&path) { + self.hidden_permanent_matchers = hidden_state.hidden; + } + + self.hidden_state_path = Some(path); + } + + /// Re-evaluates `hidden_permanent_matchers` against every currently + /// live node, since a node's `object_id` in `hidden_permanent` doesn't + /// survive a restart but the matchers persisted to + /// `hidden_state_path` do - and matcher content can change (a + /// permanent hide/unhide) independently of any single state event, so + /// this can't be updated incrementally the way `capturable_objects` + /// etc. are. + fn recompute_hidden_permanent(&mut self) { + self.hidden_permanent = self + .state + .nodes + .values() + .filter(|node| { + self.hidden_permanent_matchers + .iter() + .any(|matcher| matcher.matches(&self.state, *node)) + }) + .map(|node| node.object_id) + .collect(); + } + + /// Persists `hidden_permanent_matchers` to `hidden_state_path`. A + /// no-op if no path was resolved at startup. Errors are swallowed - + /// same tradeoff `HiddenState::save`'s own doc comment already + /// describes for a racing concurrent save, extended here to cover + /// any other save failure (e.g. a read-only filesystem): permanent + /// hide still works for the rest of this session even if it can't be + /// written to disk, rather than crashing the whole UI over it. + fn save_hidden_state(&self) { + let Some(path) = &self.hidden_state_path else { + return; + }; + + let hidden_state = HiddenState { + hidden: self.hidden_permanent_matchers.clone(), + }; + let _ = hidden_state.save(path); + } + + /// Reacts to `hidden_state_path` having changed on disk - most likely + /// another wiremix instance's own `save_hidden_state()`, delivered via + /// the inotify watch in `wirehose::session` (see its doc comment for + /// why the watch itself lives there). Deliberately does not call + /// `save_hidden_state()` itself: this instance is *reading* a change + /// something else already wrote, not originating one - re-saving here + /// would just write the same content straight back and cause another + /// spurious watch trigger for every real one. + /// + /// The content comparison below also means saving our own change here + /// harmlessly no-ops if this fires for our own just-written file + /// (which it will, since the watch is on the directory, not scoped by + /// writer) - not required for correctness the way the equivalent guard + /// was for the PipeWire-metadata design this replaced (no re-save here + /// means no risk of an actual feedback loop either way), but it's + /// nearly free and avoids a redundant recompute + capture-diff pass. + fn apply_file_hidden_state_change(&mut self) { + let Some(path) = self.hidden_state_path.clone() else { + return; + }; + + let Ok(hidden_state) = HiddenState::load(&path) else { + return; + }; + + let current_json = + serde_json::to_string(&self.hidden_permanent_matchers).ok(); + let new_json = serde_json::to_string(&hidden_state.hidden).ok(); + if current_json == new_json { + return; + } + + self.hidden_permanent_matchers = hidden_state.hidden; + self.recompute_hidden_permanent(); + self.state_dirty = true; + + if !self.config.capture_hidden { + let need_to_stop: Vec<_> = self + .capturing_objects + .iter() + .copied() + .filter(|id| self.hidden_permanent.contains(id)) + .collect(); + for object_id in need_to_stop { + self.stop_capture(object_id); + } + } + + let need_to_start: Vec<_> = self + .capturable_objects + .iter() + .copied() + .filter(|id| !self.capturing_objects.contains(id)) + .collect(); + for object_id in need_to_start { + self.start_capture(object_id); + } + } + pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> { // Wait until we've received all initial data from PipeWire let _ = terminal.draw(|frame| { @@ -281,11 +434,14 @@ impl<'a> App<'a> { while !self.exit { // Update view if needed if self.state_dirty { + self.recompute_hidden_permanent(); self.view = View::from( self.wirehose, &self.state, &self.config.names, &self.config.filters, + &self.hidden_instance, + &self.hidden_permanent, ); } self.state_dirty = false; @@ -326,6 +482,8 @@ impl<'a> App<'a> { current_tab_index: self.current_tab_index, view: &self.view, config: &self.config, + hidden_instance: &self.hidden_instance, + hidden_permanent: &self.hidden_permanent, }; let mut widget_state = AppWidgetState { mouse_areas: &mut self.mouse_areas, @@ -353,10 +511,32 @@ impl<'a> App<'a> { return; } + if !self.config.capture_hidden + && self.hidden_instance.contains(&object_id) + { + return; + } + let Some(node) = self.state.nodes.get(&object_id) else { return; }; + // Evaluated directly against hidden_permanent_matchers, not the + // cached hidden_permanent set: during startup, the initial flood + // of CaptureEligibility::Eligible events is handled before the + // main loop's first state_dirty pass ever recomputes + // hidden_permanent (see Self::run's "wait until ready" loop), so + // relying on the cache here would let an already-permanently- + // hidden node start capturing anyway on every fresh launch. + if !self.config.capture_hidden + && self + .hidden_permanent_matchers + .iter() + .any(|matcher| matcher.matches(&self.state, node)) + { + return; + } + if self .config .filters @@ -610,6 +790,92 @@ impl Handle for Action { Action::ToggleMute => { current_list!(app).toggle_mute(&app.view); } + Action::ToggleHiddenInstance => { + if let Some(object_id) = current_list!(app).selected { + if app.hidden_instance.remove(&object_id) { + // Unhidden - hold onto the selection (the user + // is still looking at this item), but nothing + // proactively resumes a capture just because + // eligibility didn't change, so re-trigger it + // here if it's still capturable. Skipped when + // capture_hidden is on, since the capture was + // never stopped in the first place. + if !app.config.capture_hidden + && app.capturable_objects.contains(&object_id) + { + app.start_capture(object_id); + } + } else { + app.hidden_instance.insert(object_id); + // start_capture()'s hidden_instance check only + // blocks new captures - an already-running one + // needs to be stopped explicitly here. Skipped + // when capture_hidden is on, since hidden items + // should keep being monitored like regular ones. + if !app.config.capture_hidden { + app.stop_capture(object_id); + } + // Release the selection rather than leave it + // pinned to an item that's about to sink to the + // bottom of the list - move it to whatever's + // next in line instead. + current_list!(app) + .release_hidden_selection(&app.view, object_id); + } + // Hiding/unhiding changes list ordering, which is + // computed in View::from() - force a rebuild. + app.state_dirty = true; + } + } + Action::ToggleHiddenPermanent => { + if let Some(object_id) = current_list!(app).selected { + let Some(node) = app.state.nodes.get(&object_id) else { + return Ok(false); + }; + + if app.hidden_permanent.contains(&object_id) { + // Unhiding: drop any matcher(s) that currently + // match this node, rather than tracking which + // matcher created which hide (there's no 1:1 + // mapping once matchers can be hand-edited in the + // state file, or match more than one node). + app.hidden_permanent_matchers.retain(|matcher| { + !matcher.matches(&app.state, node) + }); + } else if let Some(name) = node.props.node_name() { + app.hidden_permanent_matchers + .push(MatchCondition::from_node_name(name)); + } else { + // No node.name to build a durable matcher from - + // nothing to persist, so there's nothing this + // action can do for this node. + return Ok(false); + } + + app.recompute_hidden_permanent(); + + if app.hidden_permanent.contains(&object_id) { + // See the equivalent comment in ToggleHiddenInstance + // above. + if !app.config.capture_hidden { + app.stop_capture(object_id); + } + // Release the selection - see the equivalent + // comment in ToggleHiddenInstance above. + current_list!(app) + .release_hidden_selection(&app.view, object_id); + } else if !app.config.capture_hidden + && app.capturable_objects.contains(&object_id) + { + // See the equivalent comment in + // ToggleHiddenInstance above. + app.start_capture(object_id); + } + + app.save_hidden_state(); + app.state_dirty = true; + } + } Action::SetAbsoluteVolume(volume) => { let max = app .config @@ -689,6 +955,10 @@ impl Handle for PipewireEvent { } PipewireEvent::Error(message) => message.handle(app), PipewireEvent::State(event) => event.handle(app), + PipewireEvent::HiddenStateChanged => { + app.apply_file_hidden_state_change(); + Ok(true) + } } } } @@ -732,6 +1002,8 @@ pub struct AppWidget<'a, 'b> { current_tab_index: usize, view: &'a View<'b>, config: &'a Config, + hidden_instance: &'a HashSet, + hidden_permanent: &'a HashSet, } pub struct AppWidgetState<'a> { @@ -797,6 +1069,8 @@ impl<'a> StatefulWidget for AppWidget<'a, '_> { object_list: &mut state.tabs[self.current_tab_index].list, view: self.view, config: self.config, + hidden_instance: self.hidden_instance, + hidden_permanent: self.hidden_permanent, }; widget.render(list_area, buf, state.mouse_areas); @@ -881,6 +1155,7 @@ mod tests { tab: 0, tabs: vec![TabKind::Playback], lazy_capture: Default::default(), + capture_hidden: true, filters: Default::default(), }; @@ -918,8 +1193,14 @@ mod tests { for event in events { event.handle(&mut app).unwrap(); } - app.view = - View::from(wirehose, &app.state, &app.config.names, &Vec::new()); + app.view = View::from( + wirehose, + &app.state, + &app.config.names, + &Vec::new(), + &app.hidden_instance, + &app.hidden_permanent, + ); // Select the node Action::SelectObject(object_id).handle(&mut app).unwrap(); @@ -938,6 +1219,35 @@ mod tests { .unwrap(); } + /// Like `add_capturable_node`, but with everything `view::Node::from` + /// requires (via `?`) to actually appear in `app.view` - `node_name`, + /// `volumes`, `mute` - none of which `add_capturable_node` alone sets, + /// since capture-eligibility tests need a node in `app.state` but never + /// touch `app.view` at all. + fn add_playback_node(app: &mut App<'_>, object_id: ObjectId) { + let mut props = PropertyStore::default(); + props.set_node_description(String::from("Test node")); + props.set_media_class(String::from("Stream/Output/Audio")); + props.set_node_name(format!("node-{}", u32::from(object_id))); + props.set_object_serial(u32::from(object_id) as u64); + + StateEvent::NodeProperties { object_id, props } + .handle(app) + .unwrap(); + StateEvent::NodeVolumes { + object_id, + volumes: vec![1.0], + } + .handle(app) + .unwrap(); + StateEvent::NodeMute { + object_id, + mute: false, + } + .handle(app) + .unwrap(); + } + #[test] fn select_tab_bounds() { let wirehose = mock::WirehoseHandle::default(); @@ -982,6 +1292,7 @@ mod tests { TabKind::Configuration, ], lazy_capture: Default::default(), + capture_hidden: true, filters: Default::default(), }; let mut app = App::new(&wirehose, event_rx, config); @@ -1271,4 +1582,584 @@ mod tests { Some(mock::MockCommand::NodeCaptureStop(id)) ); } + + #[test] + fn toggle_hidden_instance_hides_and_shows_selected_object() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + app.state_dirty = false; + + assert!(Action::ToggleHiddenInstance.handle(&mut app).unwrap()); + assert!(app.hidden_instance.contains(&id)); + assert!(app.state_dirty); + + // Hiding the only object releases the selection (see + // toggle_hidden_instance_clears_selection_when_hiding_only_item) - + // re-select it to verify toggling again un-hides it. + Action::SelectObject(id).handle(&mut app).unwrap(); + + app.state_dirty = false; + assert!(Action::ToggleHiddenInstance.handle(&mut app).unwrap()); + assert!(!app.hidden_instance.contains(&id)); + assert!(app.state_dirty); + } + + #[test] + fn toggle_hidden_instance_clears_selection_when_hiding_only_item() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + assert_eq!(current_list!(app).selected, Some(id)); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert_eq!(current_list!(app).selected, None); + } + + #[test] + fn toggle_hidden_instance_unhiding_keeps_selection() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + app.hidden_instance.insert(id); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert_eq!(current_list!(app).selected, Some(id)); + } + + #[test] + fn toggle_hidden_instance_selects_next_when_hiding_middle_item() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id1 = ObjectId::from_raw_id(1); + let id2 = ObjectId::from_raw_id(2); + add_playback_node(&mut app, id1); + add_playback_node(&mut app, id2); + app.view = View::from( + app.wirehose, + &app.state, + &app.config.names, + &Vec::new(), + &app.hidden_instance, + &app.hidden_permanent, + ); + Action::SelectObject(id1).handle(&mut app).unwrap(); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert_eq!(current_list!(app).selected, Some(id2)); + } + + #[test] + fn toggle_hidden_instance_selects_previous_when_hiding_last_item() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id1 = ObjectId::from_raw_id(1); + let id2 = ObjectId::from_raw_id(2); + add_playback_node(&mut app, id1); + add_playback_node(&mut app, id2); + app.view = View::from( + app.wirehose, + &app.state, + &app.config.names, + &Vec::new(), + &app.hidden_instance, + &app.hidden_permanent, + ); + Action::SelectObject(id2).handle(&mut app).unwrap(); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert_eq!(current_list!(app).selected, Some(id1)); + } + + #[test] + fn toggle_hidden_instance_stops_capture_when_hiding() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + app.config.capture_hidden = false; + let id = ObjectId::from_raw_id(0); + + app.capturable_objects.insert(id); + app.capturing_objects.insert(id); + commands.borrow_mut().clear(); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert!(app.hidden_instance.contains(&id)); + assert!(!app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStop(id)) + ); + } + + #[test] + fn toggle_hidden_instance_resumes_capture_when_unhiding() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + app.config.capture_hidden = false; + let id = ObjectId::from_raw_id(0); + + app.hidden_instance.insert(id); + app.capturable_objects.insert(id); + commands.borrow_mut().clear(); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert!(!app.hidden_instance.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStart(id)) + ); + } + + #[test] + fn toggle_hidden_instance_hiding_keeps_capture_by_default() { + // capture_hidden defaults to true - hiding an item shouldn't stop + // an already-running capture, and shouldn't try to start a + // redundant one either. + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + + app.capturable_objects.insert(id); + app.capturing_objects.insert(id); + commands.borrow_mut().clear(); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert!(app.hidden_instance.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert!(commands.borrow().is_empty()); + + // Hiding released the selection (only object in the list) - + // re-select it before toggling again. + Action::SelectObject(id).handle(&mut app).unwrap(); + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert!(!app.hidden_instance.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert!(commands.borrow().is_empty()); + } + + #[test] + fn start_capture_skips_hidden_instance_objects() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let (_, event_rx) = mpsc::channel(); + let config = Config::from_toml_str( + "lazy_capture = false\ncapture_hidden = false", + ); + let mut app = App::new(&wirehose, event_rx, config); + + let id = ObjectId::from_raw_id(1); + add_capturable_node(&mut app, id); + // Reset state: node exists but isn't capturing yet + app.capturing_objects.clear(); + app.capturable_objects.clear(); + app.hidden_instance.insert(id); + commands.borrow_mut().clear(); + + app.set_capture_eligibility(CaptureEligibility::Eligible(id)); + + assert!(app.capturable_objects.contains(&id)); + assert!(!app.capturing_objects.contains(&id)); + assert!(commands.borrow().is_empty()); + } + + #[test] + fn start_capture_includes_hidden_instance_objects_by_default() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let (_, event_rx) = mpsc::channel(); + let config = Config::from_toml_str("lazy_capture = false"); + let mut app = App::new(&wirehose, event_rx, config); + + let id = ObjectId::from_raw_id(1); + add_capturable_node(&mut app, id); + app.capturing_objects.clear(); + app.capturable_objects.clear(); + app.hidden_instance.insert(id); + commands.borrow_mut().clear(); + + app.set_capture_eligibility(CaptureEligibility::Eligible(id)); + + assert!(app.capturable_objects.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStart(id)) + ); + } + + #[test] + fn toggle_hidden_permanent_hides_and_shows_selected_object() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + app.state_dirty = false; + + assert!(Action::ToggleHiddenPermanent.handle(&mut app).unwrap()); + assert!(app.hidden_permanent.contains(&id)); + assert_eq!(app.hidden_permanent_matchers.len(), 1); + assert!(app.state_dirty); + + // Hiding the only object releases the selection (see + // toggle_hidden_permanent_clears_selection_when_hiding_only_item) - + // re-select it to verify toggling again un-hides it. + Action::SelectObject(id).handle(&mut app).unwrap(); + + app.state_dirty = false; + assert!(Action::ToggleHiddenPermanent.handle(&mut app).unwrap()); + assert!(!app.hidden_permanent.contains(&id)); + assert!(app.hidden_permanent_matchers.is_empty()); + assert!(app.state_dirty); + } + + #[test] + fn toggle_hidden_permanent_clears_selection_when_hiding_only_item() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + assert_eq!(current_list!(app).selected, Some(id)); + + Action::ToggleHiddenPermanent.handle(&mut app).unwrap(); + + assert_eq!(current_list!(app).selected, None); + } + + #[test] + fn toggle_hidden_permanent_unhiding_keeps_selection() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + + Action::ToggleHiddenPermanent.handle(&mut app).unwrap(); + Action::SelectObject(id).handle(&mut app).unwrap(); + + Action::ToggleHiddenPermanent.handle(&mut app).unwrap(); + + assert_eq!(current_list!(app).selected, Some(id)); + } + + #[test] + fn toggle_hidden_permanent_selects_next_when_hiding_middle_item() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id1 = ObjectId::from_raw_id(1); + let id2 = ObjectId::from_raw_id(2); + add_playback_node(&mut app, id1); + add_playback_node(&mut app, id2); + app.view = View::from( + app.wirehose, + &app.state, + &app.config.names, + &Vec::new(), + &app.hidden_instance, + &app.hidden_permanent, + ); + Action::SelectObject(id1).handle(&mut app).unwrap(); + + Action::ToggleHiddenPermanent.handle(&mut app).unwrap(); + + assert_eq!(current_list!(app).selected, Some(id2)); + } + + #[test] + fn toggle_hidden_permanent_stops_capture_when_hiding() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + app.config.capture_hidden = false; + let id = ObjectId::from_raw_id(0); + + app.capturable_objects.insert(id); + app.capturing_objects.insert(id); + commands.borrow_mut().clear(); + + Action::ToggleHiddenPermanent.handle(&mut app).unwrap(); + + assert!(app.hidden_permanent.contains(&id)); + assert!(!app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStop(id)) + ); + } + + #[test] + fn toggle_hidden_permanent_resumes_capture_when_unhiding() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + app.config.capture_hidden = false; + let id = ObjectId::from_raw_id(0); + + Action::ToggleHiddenPermanent.handle(&mut app).unwrap(); + assert!(app.hidden_permanent.contains(&id)); + // Hiding released the selection (only object in the list) - + // re-select it before toggling again. + Action::SelectObject(id).handle(&mut app).unwrap(); + app.capturable_objects.insert(id); + commands.borrow_mut().clear(); + + Action::ToggleHiddenPermanent.handle(&mut app).unwrap(); + + assert!(!app.hidden_permanent.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStart(id)) + ); + } + + #[test] + fn toggle_hidden_permanent_hiding_keeps_capture_by_default() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + + app.capturable_objects.insert(id); + app.capturing_objects.insert(id); + commands.borrow_mut().clear(); + + Action::ToggleHiddenPermanent.handle(&mut app).unwrap(); + + assert!(app.hidden_permanent.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert!(commands.borrow().is_empty()); + } + + #[test] + fn start_capture_skips_hidden_permanent_objects() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let (_, event_rx) = mpsc::channel(); + let config = Config::from_toml_str( + "lazy_capture = false\ncapture_hidden = false", + ); + let mut app = App::new(&wirehose, event_rx, config); + + let id = ObjectId::from_raw_id(1); + let mut props = PropertyStore::default(); + props.set_node_description(String::from("Test node")); + props.set_media_class(String::from("Stream/Output/Audio")); + props.set_node_name(String::from("hidden-node")); + props.set_object_serial(u32::from(id) as u64); + StateEvent::NodeProperties { + object_id: id, + props, + } + .handle(&mut app) + .unwrap(); + // Reset state: node exists but isn't capturing yet + app.capturing_objects.clear(); + app.capturable_objects.clear(); + // Gated directly against hidden_permanent_matchers (not the + // hidden_permanent cache - see the comment on that check in + // start_capture() for why), so that's what needs to be set here. + app.hidden_permanent_matchers + .push(MatchCondition::from_node_name("hidden-node")); + commands.borrow_mut().clear(); + + app.set_capture_eligibility(CaptureEligibility::Eligible(id)); + + assert!(app.capturable_objects.contains(&id)); + assert!(!app.capturing_objects.contains(&id)); + assert!(commands.borrow().is_empty()); + } + + #[test] + fn toggle_hidden_permanent_persists_and_reloads() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + + let path = std::env::temp_dir().join(format!( + "wiremix-app-test-hidden-{}.toml", + std::process::id() + )); + app.hidden_state_path = Some(path.clone()); + + Action::ToggleHiddenPermanent.handle(&mut app).unwrap(); + assert!(app.hidden_permanent.contains(&id)); + + // A fresh App, as if wiremix had just been relaunched, loading the + // same state file and observing the same live node should + // rediscover it as hidden - object IDs don't survive a restart, + // but the persisted node.name matcher does. + let (_, event_rx2) = mpsc::channel(); + let mut app2 = + App::new(&wirehose, event_rx2, Config::from_toml_str("")); + app2.load_hidden_state(Some(path.clone())); + assert_eq!(app2.hidden_permanent_matchers.len(), 1); + + let mut props = PropertyStore::default(); + props.set_node_description(String::from("Test node")); + props.set_media_class(String::from("Stream/Output/Audio")); + props.set_node_name(String::from("Node name")); + props.set_object_serial(0); + StateEvent::NodeProperties { + object_id: id, + props, + } + .handle(&mut app2) + .unwrap(); + app2.recompute_hidden_permanent(); + + assert!(app2.hidden_permanent.contains(&id)); + + let _ = std::fs::remove_file(&path); + } + + fn hidden_state_test_path() -> std::path::PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "wiremix-app-test-file-watch-hidden-{}-{n}.toml", + std::process::id(), + )) + } + + #[test] + fn apply_file_hidden_state_change_hides_matching_object() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + app.config.capture_hidden = false; + let id = ObjectId::from_raw_id(0); + let path = hidden_state_test_path(); + app.hidden_state_path = Some(path.clone()); + app.capturable_objects.insert(id); + app.capturing_objects.insert(id); + commands.borrow_mut().clear(); + + let hidden_state = HiddenState { + hidden: vec![MatchCondition::from_node_name("Node name")], + }; + hidden_state.save(&path).unwrap(); + + app.apply_file_hidden_state_change(); + + assert!(app.hidden_permanent.contains(&id)); + assert!(!app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStop(id)) + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn apply_file_hidden_state_change_keeps_capture_by_default() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + let path = hidden_state_test_path(); + app.hidden_state_path = Some(path.clone()); + app.capturable_objects.insert(id); + app.capturing_objects.insert(id); + commands.borrow_mut().clear(); + + let hidden_state = HiddenState { + hidden: vec![MatchCondition::from_node_name("Node name")], + }; + hidden_state.save(&path).unwrap(); + + app.apply_file_hidden_state_change(); + + assert!(app.hidden_permanent.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert!(commands.borrow().is_empty()); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn apply_file_hidden_state_change_resumes_capture_when_cleared() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + let path = hidden_state_test_path(); + app.hidden_state_path = Some(path.clone()); + app.hidden_permanent_matchers = + vec![MatchCondition::from_node_name("Node name")]; + app.recompute_hidden_permanent(); + app.capturable_objects.insert(id); + app.capturing_objects.remove(&id); + assert!(app.hidden_permanent.contains(&id)); + commands.borrow_mut().clear(); + + let hidden_state = HiddenState { hidden: Vec::new() }; + hidden_state.save(&path).unwrap(); + + app.apply_file_hidden_state_change(); + + assert!(!app.hidden_permanent.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStart(id)) + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn apply_file_hidden_state_change_ignores_malformed_file() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let path = hidden_state_test_path(); + app.hidden_state_path = Some(path.clone()); + + std::fs::write(&path, "not valid toml {{{").unwrap(); + + app.apply_file_hidden_state_change(); + + assert!(app.hidden_permanent_matchers.is_empty()); + assert!(app.hidden_permanent.is_empty()); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn apply_file_hidden_state_change_noop_when_unchanged() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + let path = hidden_state_test_path(); + app.hidden_state_path = Some(path.clone()); + app.hidden_permanent_matchers = + vec![MatchCondition::from_node_name("Node name")]; + app.recompute_hidden_permanent(); + commands.borrow_mut().clear(); + + // Same content this instance already has in memory - as if the + // file changed underneath it but round-tripped to the exact same + // matchers (including this instance's own save() landing on disk, + // which the shared directory watch can't distinguish from anyone + // else's write). + let hidden_state = HiddenState { + hidden: vec![MatchCondition::from_node_name("Node name")], + }; + hidden_state.save(&path).unwrap(); + + app.apply_file_hidden_state_change(); + + assert!(commands.borrow_mut().is_empty()); + + let _ = std::fs::remove_file(&path); + } } diff --git a/src/config.rs b/src/config.rs index ac871b0..a06aa9c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,6 +44,7 @@ pub struct Config { pub tab: usize, pub tabs: Vec, pub lazy_capture: bool, + pub capture_hidden: bool, pub filters: Vec, } @@ -88,6 +89,8 @@ struct ConfigFile { tabs: Vec, #[serde(default = "default_lazy_capture")] lazy_capture: bool, + #[serde(default = "default_capture_hidden")] + capture_hidden: bool, #[serde(default = "Filter::defaults", deserialize_with = "Filter::merge")] filters: Vec, } @@ -145,6 +148,8 @@ pub struct NameOverride { pub struct CharSet { pub default_device: String, pub default_stream: String, + pub hidden_instance: String, + pub hidden_permanent: String, pub selector_top: String, pub selector_middle: String, pub selector_bottom: String, @@ -193,6 +198,7 @@ pub struct Theme { pub meter_center_active: Style, pub config_device: Style, pub config_profile: Style, + pub row_hidden: Style, pub dropdown_icon: Style, pub dropdown_border: Style, pub dropdown_item: Style, @@ -270,6 +276,10 @@ fn default_lazy_capture() -> bool { false } +fn default_capture_hidden() -> bool { + true +} + impl ConfigFile { /// Override configuration with command-line arguments. pub fn apply_opt(&mut self, opt: &Opt) { @@ -328,6 +338,14 @@ impl ConfigFile { if opt.lazy_capture { self.lazy_capture = true; } + + if opt.no_capture_hidden { + self.capture_hidden = false; + } + + if opt.capture_hidden { + self.capture_hidden = true; + } } } @@ -396,6 +414,7 @@ impl TryFrom for Config { tab, tabs: config_file.tabs, lazy_capture: config_file.lazy_capture, + capture_hidden: config_file.capture_hidden, filters, }) } @@ -481,6 +500,7 @@ pub mod strict { tab: Option, tabs: Vec, lazy_capture: bool, + capture_hidden: bool, filters: Vec, } @@ -502,6 +522,7 @@ pub mod strict { tab: strict.tab, tabs: strict.tabs, lazy_capture: strict.lazy_capture, + capture_hidden: strict.capture_hidden, filters: strict.filters, } } diff --git a/src/config/char_set.rs b/src/config/char_set.rs index f6d8a2b..eac2e31 100644 --- a/src/config/char_set.rs +++ b/src/config/char_set.rs @@ -15,6 +15,8 @@ pub struct CharSetOverlay { inherit: Option, default_device: Option, default_stream: Option, + hidden_instance: Option, + hidden_permanent: Option, selector_top: Option, selector_middle: Option, selector_bottom: Option, @@ -97,6 +99,8 @@ impl TryFrom for CharSet { validate_and_set!(default_device, 1); validate_and_set!(default_stream, 1); + validate_and_set!(hidden_instance, 0); + validate_and_set!(hidden_permanent, 0); validate_and_set!(selector_top, 1); validate_and_set!(selector_middle, 1); validate_and_set!(selector_bottom, 1); @@ -137,6 +141,8 @@ impl Default for CharSet { Self { default_device: String::from("◇"), default_stream: String::from("◇"), + hidden_instance: String::from("[hide] "), + hidden_permanent: String::from("[HIDE] "), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), @@ -178,6 +184,8 @@ impl CharSet { Self { default_device: String::from("◊"), default_stream: String::from("◊"), + hidden_instance: String::from("[hide] "), + hidden_permanent: String::from("[HIDE] "), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), @@ -209,6 +217,8 @@ impl CharSet { Self { default_device: String::from("*"), default_stream: String::from("*"), + hidden_instance: String::from("[hide] "), + hidden_permanent: String::from("[HIDE] "), selector_top: String::from("-"), selector_middle: String::from("="), selector_bottom: String::from("-"), diff --git a/src/config/keybinding.rs b/src/config/keybinding.rs index 73334e7..1570dff 100644 --- a/src/config/keybinding.rs +++ b/src/config/keybinding.rs @@ -17,6 +17,15 @@ impl Keybinding { HashMap::from([ (event(KeyCode::Char('q')), Action::Exit), (event(KeyCode::Char('m')), Action::ToggleMute), + // `t` for "toggle hide": toggles hiding the selected item for + // this instance only. Ctrl+t is the same "toggle hide" mnemonic, + // but permanently (saved to disk and synced to other running + // instances). + (event(KeyCode::Char('t')), Action::ToggleHiddenInstance), + ( + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL), + Action::ToggleHiddenPermanent, + ), (event(KeyCode::Char('d')), Action::SetDefault), (event(KeyCode::Char('l')), Action::SetRelativeVolume(0.01)), (event(KeyCode::Right), Action::SetRelativeVolume(0.01)), diff --git a/src/config/matching.rs b/src/config/matching.rs index eed6eeb..617421e 100644 --- a/src/config/matching.rs +++ b/src/config/matching.rs @@ -1,13 +1,13 @@ use std::collections::HashMap; use regex::Regex; -use serde::Deserialize; -use serde_with::DeserializeFromStr; +use serde::{Deserialize, Serialize}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; use crate::config::property_key::{PropertyKey, PropertyResolver}; use crate::wirehose::state; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(test, derive(PartialEq))] pub struct MatchCondition(pub HashMap); @@ -21,9 +21,24 @@ impl MatchCondition { .iter() .all(|(key, value)| value.matches(resolver.resolve_key(state, key))) } + + /// Builds a matcher against a node's exact `node.name` - used to + /// durably identify a node for hide-items persistence. `node.name` is + /// the same property `Filter::defaults()` already relies on to + /// identify wiremix's own capture streams by name, the established + /// "stable enough" identifier for a node in this codebase. Not + /// perfectly stable for every kind of stream (an app that varies its + /// own `node.name` between launches won't re-match), the same + /// limitation any hand-written `[[filters]]` entry already has. + pub fn from_node_name(name: &str) -> Self { + Self(HashMap::from([( + PropertyKey::Bare(String::from("node.name")), + MatchValue::Literal(String::from(name)), + )])) + } } -#[derive(Debug, DeserializeFromStr)] +#[derive(Debug, Clone, DeserializeFromStr, SerializeDisplay)] pub enum MatchValue { Literal(String), NegatedLiteral(String), @@ -33,6 +48,30 @@ pub enum MatchValue { NotNull, } +impl std::fmt::Display for MatchValue { + /// Inverse of `FromStr` - only round-trips values `FromStr` can + /// actually parse back: the literal string `"null"` is escaped as + /// `"null"` (quoted) so it isn't read back as the `Null` variant on + /// reload. Literal strings that themselves start with `!` or `~` are + /// not escaped, since `FromStr` has no quoting mechanism for that - + /// same pre-existing limitation as any hand-written `[[filters]]` + /// entry. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MatchValue::Null => write!(f, "null"), + MatchValue::NotNull => write!(f, "!null"), + MatchValue::Literal(s) if s == "null" => write!(f, "\"null\""), + MatchValue::Literal(s) => write!(f, "{s}"), + MatchValue::NegatedLiteral(s) if s == "null" => { + write!(f, "!\"null\"") + } + MatchValue::NegatedLiteral(s) => write!(f, "!{s}"), + MatchValue::Regex(re) => write!(f, "~{}", re.as_str()), + MatchValue::NegatedRegex(re) => write!(f, "!~{}", re.as_str()), + } + } +} + #[cfg(test)] impl PartialEq for MatchValue { fn eq(&self, other: &Self) -> bool { @@ -213,4 +252,51 @@ mod tests { assert!(val.matches(Some("other"))); assert!(val.matches(None)); } + + fn round_trip(s: &str) { + let val = s.parse::().unwrap(); + assert_eq!(val.to_string(), s); + let reparsed = val.to_string().parse::().unwrap(); + assert_eq!(val, reparsed); + } + + #[test] + fn display_round_trips_null() { + round_trip("null"); + } + + #[test] + fn display_round_trips_not_null() { + round_trip("!null"); + } + + #[test] + fn display_round_trips_quoted_null_literal() { + round_trip("\"null\""); + } + + #[test] + fn display_round_trips_negated_quoted_null_literal() { + round_trip("!\"null\""); + } + + #[test] + fn display_round_trips_literal() { + round_trip("hello"); + } + + #[test] + fn display_round_trips_negated_literal() { + round_trip("!hello"); + } + + #[test] + fn display_round_trips_regex() { + round_trip("~^foo.*bar$"); + } + + #[test] + fn display_round_trips_negated_regex() { + round_trip("!~^foo.*bar$"); + } } diff --git a/src/config/property_key.rs b/src/config/property_key.rs index d1e5ba4..b7d7bd7 100644 --- a/src/config/property_key.rs +++ b/src/config/property_key.rs @@ -1,10 +1,12 @@ //! An identifier for a property on an object or a linked object -use serde_with::DeserializeFromStr; +use serde_with::{DeserializeFromStr, SerializeDisplay}; use crate::wirehose::state; -#[derive(Debug, Clone, Hash, PartialEq, Eq, DeserializeFromStr)] +#[derive( + Debug, Clone, Hash, PartialEq, Eq, DeserializeFromStr, SerializeDisplay, +)] pub enum PropertyKey { Device(String), Node(String), @@ -12,20 +14,13 @@ pub enum PropertyKey { Bare(String), } -#[allow(clippy::to_string_trait_impl)] // This is not for display. -impl ToString for PropertyKey { - fn to_string(&self) -> String { +impl std::fmt::Display for PropertyKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PropertyKey::Device(s) => { - format!("device:{s}") - } - PropertyKey::Node(s) => { - format!("node:{s}") - } - PropertyKey::Client(s) => { - format!("client:{s}") - } - PropertyKey::Bare(s) => s.to_string(), + PropertyKey::Device(s) => write!(f, "device:{s}"), + PropertyKey::Node(s) => write!(f, "node:{s}"), + PropertyKey::Client(s) => write!(f, "client:{s}"), + PropertyKey::Bare(s) => write!(f, "{s}"), } } } diff --git a/src/config/theme.rs b/src/config/theme.rs index 6919078..282c2be 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -29,6 +29,7 @@ pub struct ThemeOverlay { meter_center_active: Option, config_device: Option, config_profile: Option, + row_hidden: Option, dropdown_icon: Option, dropdown_border: Option, dropdown_item: Option, @@ -108,6 +109,7 @@ impl TryFrom for Theme { set!(meter_center_active); set!(config_device); set!(config_profile); + set!(row_hidden); set!(dropdown_icon); set!(dropdown_border); set!(dropdown_item); @@ -143,6 +145,7 @@ impl Default for Theme { meter_center_active: Style::default().fg(Color::LightGreen), config_device: Style::default(), config_profile: Style::default(), + row_hidden: Style::default().fg(Color::DarkGray), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), @@ -187,6 +190,7 @@ impl Theme { meter_center_active: Style::default().add_modifier(Modifier::BOLD), config_device: Style::default(), config_profile: Style::default(), + row_hidden: Style::default().add_modifier(Modifier::DIM), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), @@ -220,6 +224,7 @@ impl Theme { meter_center_active: Style::default(), config_device: Style::default(), config_profile: Style::default(), + row_hidden: Style::default(), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), diff --git a/src/device_widget.rs b/src/device_widget.rs index f5235fa..2cef5b0 100644 --- a/src/device_widget.rs +++ b/src/device_widget.rs @@ -18,6 +18,8 @@ use crate::view; pub struct DeviceWidget<'a> { device: &'a view::Device, selected: bool, + hidden_instance: bool, + hidden_permanent: bool, config: &'a Config, } @@ -25,15 +27,23 @@ impl<'a> DeviceWidget<'a> { pub fn new( device: &'a view::Device, selected: bool, + hidden_instance: bool, + hidden_permanent: bool, config: &'a Config, ) -> Self { Self { device, selected, + hidden_instance, + hidden_permanent, config, } } + fn hidden(&self) -> bool { + self.hidden_instance || self.hidden_permanent + } + /// Height of a full device display. pub fn height() -> u16 { 3 @@ -132,23 +142,52 @@ impl StatefulWidget for DeviceWidget<'_> { let title_area = layout[0]; let target_area = layout[1]; + let title_style = if self.hidden() { + self.config + .theme + .config_device + .patch(self.config.theme.row_hidden) + } else { + self.config.theme.config_device + }; + let hidden_prefix = if self.hidden_permanent { + Span::styled(&self.config.char_set.hidden_permanent, title_style) + } else if self.hidden_instance { + Span::styled(&self.config.char_set.hidden_instance, title_style) + } else { + Span::from("") + }; Line::from(vec![ Span::from(" "), - Span::styled(&self.device.title, self.config.theme.config_device), + hidden_prefix, + Span::styled(&self.device.title, title_style), ]) .render(title_area, buf); + let profile_style = if self.hidden() { + self.config + .theme + .config_profile + .patch(self.config.theme.row_hidden) + } else { + self.config.theme.config_profile + }; + let dropdown_icon_style = if self.hidden() { + self.config + .theme + .dropdown_icon + .patch(self.config.theme.row_hidden) + } else { + self.config.theme.dropdown_icon + }; Line::from(vec![ Span::from(" "), Span::styled( &self.config.char_set.dropdown_icon, - self.config.theme.dropdown_icon, + dropdown_icon_style, ), Span::from(" "), - Span::styled( - &self.device.target_title, - self.config.theme.config_profile, - ), + Span::styled(&self.device.target_title, profile_style), ]) .render(target_area, buf); diff --git a/src/hidden_state.rs b/src/hidden_state.rs new file mode 100644 index 0000000..d107c7d --- /dev/null +++ b/src/hidden_state.rs @@ -0,0 +1,133 @@ +//! Durable, cross-restart persistence for permanently-hidden items. +//! +//! Items are identified by [`MatchCondition`] (built via +//! [`MatchCondition::from_node_name`]) rather than raw object IDs, since +//! object IDs never survive a restart. This is the same matching engine +//! [`[[filters]]`](`crate::config::Filter`) already uses. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +use crate::config::MatchCondition; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct HiddenState { + #[serde(default)] + pub hidden: Vec, +} + +impl HiddenState { + /// Bare filename (no directory) the state file is saved under - shared + /// with the inotify-based live-sync watch (see + /// `wirehose::hidden_state_watch`), which needs to filter directory + /// events down to just this file without duplicating the string. + pub const FILENAME: &'static str = "hidden.toml"; + + /// Returns the default path for the hidden-item state file, following + /// the same `$XDG_STATE_HOME`/`$HOME` resolution + /// [`Config::default_path`](`crate::config::Config::default_path`) + /// already uses for the config file (mirroring `$XDG_CONFIG_HOME`). + pub fn default_path() -> Option { + if let Ok(xdg_state) = env::var("XDG_STATE_HOME") { + return Some( + Path::new(&xdg_state).join("wiremix").join(Self::FILENAME), + ); + } + + if let Ok(home) = env::var("HOME") { + return Some( + Path::new(&home) + .join(".local/state/wiremix") + .join(Self::FILENAME), + ); + } + + None + } + + /// Loads persisted hidden-item matchers from `path`. A missing file is + /// the normal case before anything has ever been permanently hidden, + /// not an error - returns an empty state instead. + pub fn load(path: &Path) -> anyhow::Result { + if !path.exists() { + return Ok(Self::default()); + } + + let context = || { + format!( + "Failed to read hidden-item state from file '{}'", + path.display() + ) + }; + + let toml_str = fs::read_to_string(path).with_context(context)?; + toml::from_str(&toml_str).with_context(context) + } + + /// Saves to `path`, creating its parent directory if needed. Writes to + /// a temp file in the same directory first and renames it into place, + /// so a crash - or another wiremix instance saving its own state at + /// the same moment - can never leave a half-written file behind. Two + /// instances racing to save at the same moment still means whichever + /// finishes last simply overwrites the other (no cross-process + /// locking), the same best-effort tradeoff already documented for + /// live metadata sync. + pub fn save(&self, path: &Path) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create directory '{}'", parent.display()) + })?; + } + + let toml_str = toml::to_string_pretty(self) + .context("Failed to serialize hidden-item state")?; + + let tmp_path = path.with_extension("toml.tmp"); + fs::write(&tmp_path, toml_str).with_context(|| { + format!("Failed to write '{}'", tmp_path.display()) + })?; + fs::rename(&tmp_path, path).with_context(|| { + format!( + "Failed to rename '{}' to '{}'", + tmp_path.display(), + path.display() + ) + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_missing_file_returns_empty() { + let state = + HiddenState::load(Path::new("/nonexistent/path/hidden.toml")) + .unwrap(); + assert!(state.hidden.is_empty()); + } + + #[test] + fn save_then_load_round_trips() { + let dir = std::env::temp_dir() + .join(format!("wiremix-hidden-state-test-{}", std::process::id())); + let path = dir.join("hidden.toml"); + + let state = HiddenState { + hidden: vec![MatchCondition::from_node_name("test-node")], + }; + state.save(&path).unwrap(); + + let loaded = HiddenState::load(&path).unwrap(); + assert_eq!(loaded.hidden.len(), 1); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6c615a4..3bc70eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod device_widget; pub mod dropdown_widget; pub mod event; pub mod help; +pub mod hidden_state; pub mod input; pub mod meter; pub mod node_widget; diff --git a/src/main.rs b/src/main.rs index 2e95ac1..873de43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use crossterm::{ use wiremix::app; use wiremix::config::Config; use wiremix::event::Event; +use wiremix::hidden_state::HiddenState; use wiremix::input; use wiremix::opt::Opt; use wiremix::wirehose::Session; @@ -61,8 +62,9 @@ fn main() -> Result<()> { } let mut terminal = ratatui::init(); terminal.clear()?; - let app_result = - app::App::new(&client, event_rx, config).run(&mut terminal); + let mut app = app::App::new(&client, event_rx, config); + app.load_hidden_state(HiddenState::default_path()); + let app_result = app.run(&mut terminal); ratatui::restore(); if support_mouse { stdout().execute(DisableMouseCapture)?; diff --git a/src/node_widget.rs b/src/node_widget.rs index ed3f8cd..898e831 100644 --- a/src/node_widget.rs +++ b/src/node_widget.rs @@ -5,6 +5,7 @@ use std::sync::atomic::Ordering; use ratatui::{ layout::Flex, prelude::{Alignment, Buffer, Constraint, Direction, Layout, Rect}, + style::Style, text::{Line, Span}, widgets::{StatefulWidget, Widget}, }; @@ -32,6 +33,8 @@ pub struct NodeWidget<'a> { device_kind: Option, node: &'a view::Node, selected: bool, + hidden_instance: bool, + hidden_permanent: bool, } impl<'a> NodeWidget<'a> { @@ -40,12 +43,16 @@ impl<'a> NodeWidget<'a> { device_kind: Option, node: &'a view::Node, selected: bool, + hidden_instance: bool, + hidden_permanent: bool, ) -> Self { Self { config, device_kind, node, selected, + hidden_instance, + hidden_permanent, } } @@ -156,14 +163,21 @@ impl StatefulWidget for NodeWidget<'_> { let header_area = layout[0]; let bar_area = layout[1]; - HeaderWidget::new(self.config, self.device_kind, self.node).render( - header_area, - buf, - mouse_areas, - ); + HeaderWidget::new( + self.config, + self.device_kind, + self.node, + self.hidden_instance, + self.hidden_permanent, + ) + .render(header_area, buf, mouse_areas); // Render volume bar and (if enabled) peak meter - let volume = VolumeWidget::new(self.config, self.node); + let volume = VolumeWidget::new( + self.config, + self.node, + self.hidden_instance || self.hidden_permanent, + ); if self.config.peaks == Peaks::Off { let layout = Layout::default() .direction(Direction::Horizontal) @@ -194,7 +208,17 @@ impl StatefulWidget for NodeWidget<'_> { let meter_area = layout[3]; volume.render(volume_area, buf, mouse_areas); - MeterWidget::new(self.config, self.node).render(meter_area, buf); + // Peak monitoring is suspended for this item (capture_hidden is + // off and it's hidden) - the meter would otherwise still show an + // inactive-looking placeholder even though nothing is actually + // being sampled, which reads as broken rather than intentionally + // off. Leave meter_area untouched instead. + let hidden = self.hidden_instance || self.hidden_permanent; + let monitoring_suspended = hidden && !self.config.capture_hidden; + if !monitoring_suspended { + MeterWidget::new(self.config, self.node) + .render(meter_area, buf); + } } } } @@ -240,6 +264,8 @@ struct HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, + hidden_instance: bool, + hidden_permanent: bool, } impl<'a> HeaderWidget<'a> { @@ -247,34 +273,50 @@ impl<'a> HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, + hidden_instance: bool, + hidden_permanent: bool, ) -> Self { Self { config, device_kind, node, + hidden_instance, + hidden_permanent, + } + } + + fn hidden(&self) -> bool { + self.hidden_instance || self.hidden_permanent + } + + /// Patches `row_hidden` onto `base` when this row is hidden - a no-op + /// (`row_hidden` defaults to an empty `Style`) unless a theme + /// explicitly sets it. + fn hidden_style(&self, base: Style) -> Style { + if self.hidden() { + base.patch(self.config.theme.row_hidden) + } else { + base } } fn target_line(&self) -> Line<'_> { + let target_style = self.hidden_style(self.config.theme.node_target); match self.node.target { Some(view::Target::Default) => { // Add the default target indicator Line::from(vec![ Span::styled( &self.config.char_set.default_stream, - self.config.theme.default_stream, + self.hidden_style(self.config.theme.default_stream), ), Span::from(" "), - Span::styled( - &self.node.target_title, - self.config.theme.node_target, - ), + Span::styled(&self.node.target_title, target_style), ]) } - _ => Line::from(Span::styled( - &self.node.target_title, - self.config.theme.node_target, - )), + _ => { + Line::from(Span::styled(&self.node.target_title, target_style)) + } } } @@ -282,15 +324,24 @@ impl<'a> HeaderWidget<'a> { let default_span = if is_default(self.node, self.device_kind) { Span::styled( &self.config.char_set.default_device, - self.config.theme.default_device, + self.hidden_style(self.config.theme.default_device), ) } else { Span::from(" ") }; + let title_style = self.hidden_style(self.config.theme.node_title); + let hidden_prefix = if self.hidden_permanent { + Span::styled(&self.config.char_set.hidden_permanent, title_style) + } else if self.hidden_instance { + Span::styled(&self.config.char_set.hidden_instance, title_style) + } else { + Span::from("") + }; Line::from(vec![ default_span, Span::from(" "), - Span::styled(&self.node.title, self.config.theme.node_title), + hidden_prefix, + Span::styled(&self.node.title, title_style), ]) } } @@ -362,11 +413,16 @@ impl StatefulWidget for HeaderWidget<'_> { struct VolumeWidget<'a> { config: &'a Config, node: &'a view::Node, + hidden: bool, } impl<'a> VolumeWidget<'a> { - fn new(config: &'a Config, node: &'a view::Node) -> Self { - Self { config, node } + fn new(config: &'a Config, node: &'a view::Node, hidden: bool) -> Self { + Self { + config, + node, + hidden, + } } } @@ -395,12 +451,14 @@ impl StatefulWidget for VolumeWidget<'_> { let volume = mean.cbrt(); let percent = (volume * 100.0).round() as u32; - Line::from(Span::styled( - format!("{percent}%"), - self.config.theme.volume, - )) - .alignment(Alignment::Right) - .render(volume_label, buf); + let volume_style = if self.hidden { + self.config.theme.volume.patch(self.config.theme.row_hidden) + } else { + self.config.theme.volume + }; + Line::from(Span::styled(format!("{percent}%"), volume_style)) + .alignment(Alignment::Right) + .render(volume_label, buf); let count = ((volume.clamp(0.0, max_volume) / max_volume) * volume_bar.width as f32) @@ -510,3 +568,69 @@ impl Widget for MeterWidget<'_> { self.node.peaks_dirty.store(false, Ordering::Relaxed); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config; + use crate::wirehose::ObjectId; + use std::sync::atomic::AtomicBool; + + fn test_node() -> view::Node { + view::Node { + object_id: ObjectId::from_raw_id(1), + object_serial: 1, + name: String::from("Test node"), + title: String::from("Test node"), + media_class: String::from("Stream/Output/Audio"), + routes: None, + target_title: String::new(), + target: None, + volumes: vec![1.0], + mute: false, + peaks: None, + peaks_dirty: std::sync::Arc::new(AtomicBool::new(false)), + positions: None, + device_info: None, + is_default_sink: false, + is_default_source: false, + client_id: None, + } + } + + fn non_blank_cells(config: &Config, node: &view::Node) -> usize { + let area = Rect::new(0, 0, 20, 3); + let mut buf = Buffer::empty(area); + // hidden_instance is true in both compared renders below, so the + // "[hide] " title prefix is present either way - only + // capture_hidden differs, isolating the meter's own contribution. + NodeWidget::new(config, None, node, false, true, false).render( + area, + &mut buf, + &mut Vec::new(), + ); + buf.content + .iter() + .filter(|cell| cell.symbol() != " ") + .count() + } + + #[test] + fn meter_hidden_when_monitoring_suspended() { + let node = test_node(); + + let capture_hidden_true = + config::Config::from_toml_str("peaks = \"mono\""); + let capture_hidden_false = config::Config::from_toml_str( + "peaks = \"mono\"\ncapture_hidden = false", + ); + + // Same hidden item either way (title prefix unchanged) - fewer + // non-blank cells with capture_hidden off confirms the meter + // placeholder was skipped entirely, not just drawn over blank + // space. + let shown = non_blank_cells(&capture_hidden_true, &node); + let suspended = non_blank_cells(&capture_hidden_false, &node); + assert!(suspended < shown); + } +} diff --git a/src/object_list.rs b/src/object_list.rs index c43947c..8696166 100644 --- a/src/object_list.rs +++ b/src/object_list.rs @@ -74,6 +74,31 @@ impl ObjectList { } } + /// Releases the selection from `object_id`, which has just been + /// hidden, moving it to whatever comes right after it in `view`'s + /// current order (still the pre-hide order - the hidden item hasn't + /// sunk to the bottom yet, since that resorting only happens on the + /// next `View::from()` rebuild). Falls back to whatever comes right + /// before it if it was the last item, or to no selection at all if + /// it was the only item. Doesn't touch dropdown state, unlike + /// `down()`/`up()` - this is a reaction to hiding the selected item, + /// not a navigation keypress, so a dropdown being open isn't + /// relevant here. + pub fn release_hidden_selection( + &mut self, + view: &view::View, + object_id: ObjectId, + ) { + let candidate = view + .next_id(self.list_kind, Some(object_id)) + .filter(|&id| id != object_id) + .or_else(|| { + view.previous_id(self.list_kind, Some(object_id)) + .filter(|&id| id != object_id) + }); + self.select(candidate); + } + fn dropdown_open(&mut self, view: &view::View) { let targets = match self.list_kind { ListKind::Node(_) => self @@ -315,6 +340,8 @@ pub struct ObjectListWidget<'a, 'b> { pub object_list: &'a mut ObjectList, pub view: &'a view::View<'b>, pub config: &'a Config, + pub hidden_instance: &'a HashSet, + pub hidden_permanent: &'a HashSet, } struct ObjectListRenderContext<'a> { @@ -348,11 +375,17 @@ impl ObjectListWidget<'_, '_> { .selected .map(|id| id == object.object_id) .unwrap_or_default(); + let hidden_instance = + self.hidden_instance.contains(&object.object_id); + let hidden_permanent = + self.hidden_permanent.contains(&object.object_id); NodeWidget::new( self.config, self.object_list.device_kind, object, selected, + hidden_instance, + hidden_permanent, ) .render(object_area, buf, mouse_areas); } @@ -405,11 +438,18 @@ impl ObjectListWidget<'_, '_> { .selected .map(|id| id == object.object_id) .unwrap_or_default(); - DeviceWidget::new(object, selected, self.config).render( - object_area, - buf, - mouse_areas, - ); + let hidden_instance = + self.hidden_instance.contains(&object.object_id); + let hidden_permanent = + self.hidden_permanent.contains(&object.object_id); + DeviceWidget::new( + object, + selected, + hidden_instance, + hidden_permanent, + self.config, + ) + .render(object_area, buf, mouse_areas); } // Show the target dropdown? @@ -642,6 +682,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -668,6 +710,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -691,6 +735,70 @@ mod tests { assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(10))); } + #[test] + fn hidden_instance_objects_sink_to_bottom() { + let (state, wirehose) = init(); + let mut hidden = HashSet::new(); + // Hide two objects out of order - the sunk objects should still + // come out in their original relative (object_serial) order at + // the bottom, not the order they were inserted into the set. + hidden.insert(ObjectId::from_raw_id(5)); + hidden.insert(ObjectId::from_raw_id(2)); + + let view = View::from( + &wirehose, + &state, + &config::Names::default(), + &Vec::new(), + &hidden, + &HashSet::new(), + ); + + let ids: Vec = view + .full_nodes(NodeKind::All) + .iter() + .map(|node| node.object_id) + .collect(); + + let expected: Vec = [1, 3, 4, 6, 7, 8, 9, 10, 2, 5] + .into_iter() + .map(ObjectId::from_raw_id) + .collect(); + assert_eq!(ids, expected); + } + + #[test] + fn hidden_permanent_objects_rank_below_hidden_instance() { + let (state, wirehose) = init(); + let mut hidden_instance = HashSet::new(); + hidden_instance.insert(ObjectId::from_raw_id(5)); + let mut hidden_permanent = HashSet::new(); + // Permanent-hidden even though it comes first by object_serial - + // should still rank below the instance-hidden object. + hidden_permanent.insert(ObjectId::from_raw_id(1)); + + let view = View::from( + &wirehose, + &state, + &config::Names::default(), + &Vec::new(), + &hidden_instance, + &hidden_permanent, + ); + + let ids: Vec = view + .full_nodes(NodeKind::All) + .iter() + .map(|node| node.object_id) + .collect(); + + let expected: Vec = [2, 3, 4, 6, 7, 8, 9, 10, 5, 1] + .into_iter() + .map(ObjectId::from_raw_id) + .collect(); + assert_eq!(ids, expected); + } + #[test] fn visible_objects_changes_with_scroll() { let (state, wirehose) = init(); @@ -699,6 +807,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -756,6 +866,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -815,6 +927,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -867,6 +981,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -936,6 +1052,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -1028,6 +1146,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -1072,6 +1192,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); assert!(view.default_sink.is_some()); @@ -1117,6 +1239,8 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), + &HashSet::new(), ); assert!(view.default_source.is_some()); diff --git a/src/opt.rs b/src/opt.rs index 534d130..0e65a16 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -78,6 +78,16 @@ pub struct Opt { #[clap(long, conflicts_with = "no_lazy_capture")] pub lazy_capture: bool, + /// Exclude hidden items from peak monitoring (on top of, not instead of, + /// lazy-capture/other capture limits) + #[clap(long, conflicts_with = "capture_hidden")] + pub no_capture_hidden: bool, + + /// Apply the same peak monitoring rules to hidden items as regular ones + /// (the default) + #[clap(long, conflicts_with = "no_capture_hidden")] + pub capture_hidden: bool, + #[cfg(debug_assertions)] #[clap(short, long)] pub dump_events: bool, diff --git a/src/view.rs b/src/view.rs index 08877d8..a926bd0 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,7 +1,7 @@ //! View representing PipeWire state in a convenient format for rendering. use itertools::Itertools; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -472,6 +472,8 @@ impl<'a> View<'a> { state: &state::State, names: &config::Names, filters: &[config::MatchCondition], + hidden_instance: &HashSet, + hidden_permanent: &HashSet, ) -> View<'a> { let default_sink_name = default_for(state, "default.audio.sink"); let default_source_name = default_for(state, "default.audio.source"); @@ -595,17 +597,44 @@ impl<'a> View<'a> { nodes_input.push(*id); } } + // Rank visible objects first, instance-hidden ones next, and + // permanent-hidden ones last. + let hidden_rank = |id: &ObjectId| -> u8 { + if hidden_permanent.contains(id) { + 2 + } else if hidden_instance.contains(id) { + 1 + } else { + 0 + } + }; + + // Stable sort on hidden rank only, after the object_serial sort + // above - preserves relative order within each rank, so hiding + // something sinks it to the bottom of its group without otherwise + // reshuffling the list. + for list in [ + &mut nodes_all, + &mut nodes_playback, + &mut nodes_recording, + &mut nodes_output, + &mut nodes_input, + ] { + list.sort_by_key(&hidden_rank); + } let nodes_all = nodes_all; let nodes_playback = nodes_playback; let nodes_recording = nodes_recording; let nodes_output = nodes_output; let nodes_input = nodes_input; - let devices_all = devices + let mut devices_all: Vec = devices .iter() .sorted_by_key(|(_, device)| device.object_serial) .map(|(&id, _)| id) .collect(); + devices_all.sort_by_key(&hidden_rank); + let devices_all = devices_all; Self { wirehose, diff --git a/src/wirehose/event.rs b/src/wirehose/event.rs index 5e9aee9..acdb402 100644 --- a/src/wirehose/event.rs +++ b/src/wirehose/event.rs @@ -18,6 +18,12 @@ pub enum Event { /// The [StateEvent]s representing the PipeWire state at the time of /// connection have been sent. wirehose is listening for changes now. Ready, + /// The permanent-hide state file was changed on disk, most likely by + /// another wiremix instance saving its own change - see + /// `wirehose::hidden_state_watch`. Not a PipeWire event at all, but + /// carried on this same channel since it originates on the same + /// monitoring thread. + HiddenStateChanged, } #[derive(Debug)] diff --git a/src/wirehose/event_sender.rs b/src/wirehose/event_sender.rs index f9f3562..2c273c2 100644 --- a/src/wirehose/event_sender.rs +++ b/src/wirehose/event_sender.rs @@ -59,4 +59,16 @@ impl EventSender { } } } + + pub fn send_hidden_state_changed(&self) { + if !self + .handler + .borrow_mut() + .handle_event(Event::HiddenStateChanged) + { + if let Some(main_loop) = self.main_loop_weak.upgrade() { + main_loop.quit(); + } + } + } } diff --git a/src/wirehose/session.rs b/src/wirehose/session.rs index 63e7cb9..6fa6fbc 100644 --- a/src/wirehose/session.rs +++ b/src/wirehose/session.rs @@ -238,6 +238,71 @@ fn monitor_pipewire( } }); + // Watches the permanent-hide state file's directory for changes made by + // other wiremix instances, so Ctrl+t takes effect live everywhere + // instead of only on each instance's next restart + // (load_hidden_state() still covers that). Watches the *directory* + // rather than the file itself: HiddenState::save() writes via a temp + // file + rename, and a direct watch on the original path would go + // stale the first time that rename replaces its inode - filtering + // directory events down to just this filename sidesteps that entirely. + // Linux-only (inotify) - a no-op elsewhere, falling back to + // load-at-startup sync only. Best-effort: any failure along the way + // (can't resolve a path, can't create the directory, inotify_init + // fails) just leaves this instance without live-sync rather than + // erroring out, the same tradeoff already documented for + // HiddenState::save() itself. + #[cfg(target_os = "linux")] + let _hidden_state_watch = (|| { + use nix::sys::inotify::{AddWatchFlags, InitFlags, Inotify}; + use std::os::fd::AsFd; + + let path = crate::hidden_state::HiddenState::default_path()?; + let dir = path.parent()?.to_path_buf(); + let filename = path.file_name()?.to_owned(); + std::fs::create_dir_all(&dir).ok()?; + + let inotify = + Inotify::init(InitFlags::IN_NONBLOCK | InitFlags::IN_CLOEXEC) + .ok()?; + inotify + .add_watch( + &dir, + AddWatchFlags::IN_MOVED_TO + | AddWatchFlags::IN_CLOSE_WRITE + | AddWatchFlags::IN_CREATE, + ) + .ok()?; + + let fd = inotify.as_fd().as_raw_fd(); + let watch = main_loop.loop_().add_io( + fd, + libspa::support::system::IoFlags::IN, + { + let sender_weak = Rc::downgrade(&sender); + move |_status| { + let Ok(events) = inotify.read_events() else { + return; + }; + let Some(sender) = sender_weak.upgrade() else { + return; + }; + let changed = events.iter().any(|event| { + event.name.as_deref() == Some(filename.as_os_str()) + }); + if changed { + sender.send_hidden_state_changed(); + } + } + }, + ); + + Some(watch) + })(); + + #[cfg(not(target_os = "linux"))] + let _hidden_state_watch = (); + let syncs = Rc::new(RefCell::new(SyncRegistry::default())); let _core_listener = core diff --git a/wiremix.toml b/wiremix.toml index 218653b..4d0df19 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -44,6 +44,14 @@ enforce_max_volume = false # If true, only monitor peak levels of visible nodes lazy_capture = false +# If false, hidden items are excluded from peak monitoring - an additional +# filter on top of lazy_capture and any other capture limits, not a +# replacement for them. When monitoring is excluded this way, the peak +# meter is left blank for that item rather than showing an inactive-looking +# placeholder. True by default, meaning hidden items follow the same +# monitoring rules as regular ones. +capture_hidden = true + # Keybindings # @@ -82,6 +90,12 @@ keybindings = [ { key = { Char = "q" }, action = "Exit" }, # Toggle mute for the selected item { key = { Char = "m" }, action = "ToggleMute" }, + # "t" for "toggle hide": hide/show the selected item for this instance + # only (not saved, not synced to other instances) + { key = { Char = "t" }, action = "ToggleHiddenInstance" }, + # Same "toggle hide" mnemonic, but permanently (saved to disk, synced to + # other running instances) + { key = { Char = "t" }, modifiers = "CONTROL", action = "ToggleHiddenPermanent" }, # Make the selected item in Input/Output Devices the default endpoint { key = { Char = "d" }, action = "SetDefault" }, # Increase the volume of the selected item by 1% @@ -336,6 +350,15 @@ meter_center_active = { fg = "LightGreen" } config_device = { } # The name of the selected profile in the Configuration tab config_profile = { } +# Patched onto a hidden row's own text styles (node_title, node_target, +# volume, config_device, config_profile) - only whichever of +# fg/bg/add_modifier you set here override the base style. Used for both +# instance-hidden and permanently-hidden rows alike (they're +# distinguished from each other by the hidden_instance/hidden_permanent +# char_set prefixes instead). A faint tint (e.g. DarkGray) is a typical +# choice, to visually distinguish hidden items from normal ones without +# hiding them entirely. +row_hidden = { fg = "DarkGray" } # Dropdown marker next to the profiles in the Configuration tab dropdown_icon = { } # Border around dropdowns @@ -384,6 +407,14 @@ help_more = { fg = "DarkGray" } default_device = "◇" # Marks the default endpoint on the Playback/Recording tabs default_stream = "◇" +# Prefix prepended to a hidden item's title. Unconstrained width (unlike +# most glyphs above), since it's meant to be a short text tag rather than +# a single character - e.g. try "🙈 " for an emoji alternative. +hidden_instance = "[hide] " +# Same as hidden_instance, but for items permanently hidden (saved to +# disk, synced to other running instances) rather than just for this +# instance - e.g. try "🚫 " for an emoji alternative. +hidden_permanent = "[HIDE] " # The selection indicator in a tab selector_top = "░" selector_middle = "▒" @@ -452,6 +483,7 @@ meter_center_inactive = { add_modifier = "DIM" } meter_center_active = { add_modifier = "BOLD" } config_device = { } config_profile = { } +row_hidden = { add_modifier = "DIM" } dropdown_icon = { } dropdown_border = { } dropdown_item = { } @@ -481,6 +513,7 @@ meter_center_inactive = { } meter_center_active = { } config_device = { } config_profile = { } +row_hidden = { } dropdown_icon = { } dropdown_border = { } dropdown_item = { } @@ -493,6 +526,8 @@ help_more = { } [char_sets.compat] default_device = "◊" default_stream = "◊" +hidden_instance = "[hide] " +hidden_permanent = "[HIDE] " selector_top = "░" selector_middle = "▒" selector_bottom = "░" @@ -521,6 +556,8 @@ help_border = "Plain" [char_sets.extracompat] default_device = "*" default_stream = "*" +hidden_instance = "[hide] " +hidden_permanent = "[HIDE] " selector_top = "-" selector_middle = "=" selector_bottom = "-"