From c066c1fcf99f2b37d302bb25231149da3c57c240 Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:18:02 -0700 Subject: [PATCH 01/11] Add per-instance hide/show for list items (t key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. "🙈 "). --- README.md | 1 + src/app.rs | 129 ++++++++++++++++++++++++++++++++++++++- src/config.rs | 2 + src/config/char_set.rs | 5 ++ src/config/keybinding.rs | 1 + src/config/theme.rs | 5 ++ src/device_widget.rs | 42 +++++++++++-- src/node_widget.rs | 81 ++++++++++++++++-------- src/object_list.rs | 47 +++++++++++++- src/view.rs | 20 +++++- wiremix.toml | 18 ++++++ 11 files changed, 315 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 9d4bb10..3a4e8b8 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ less-intuitive mouse controls are: | ------------- | ----------------------- | | q | Quit | | m | Toggle mute | +| t | Hide/show (this instance only) | | 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..6395d02 100644 --- a/src/app.rs +++ b/src/app.rs @@ -50,6 +50,7 @@ pub enum Action { MoveUp, MoveDown, ToggleMute, + ToggleHiddenInstance, SetRelativeVolume(f32), SetDefault, ActivateDropdown, @@ -81,6 +82,9 @@ 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::SetAbsoluteVolume(vol) => { write!(f, "Set volume to {}%", Self::format_percentage(*vol)) } @@ -208,6 +212,10 @@ 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, /// Callback for peak ballistics. peak_processor: Arc, /// Objects eligible for capture. @@ -258,6 +266,7 @@ impl<'a> App<'a> { drag_row: None, help_position: None, visible_objects: HashSet::new(), + hidden_instance: HashSet::new(), peak_processor: Arc::new(peak_processor), capturable_objects: HashSet::new(), capturing_objects: HashSet::new(), @@ -286,6 +295,7 @@ impl<'a> App<'a> { &self.state, &self.config.names, &self.config.filters, + &self.hidden_instance, ); } self.state_dirty = false; @@ -326,6 +336,7 @@ impl<'a> App<'a> { current_tab_index: self.current_tab_index, view: &self.view, config: &self.config, + hidden_instance: &self.hidden_instance, }; let mut widget_state = AppWidgetState { mouse_areas: &mut self.mouse_areas, @@ -353,6 +364,10 @@ impl<'a> App<'a> { return; } + if self.hidden_instance.contains(&object_id) { + return; + } + let Some(node) = self.state.nodes.get(&object_id) else { return; }; @@ -610,6 +625,27 @@ 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 - nothing proactively resumes a + // capture just because eligibility didn't change, + // so re-trigger it here if it's still capturable. + if 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. + app.stop_capture(object_id); + } + // Hiding/unhiding changes list ordering, which is + // computed in View::from() - force a rebuild. + app.state_dirty = true; + } + } Action::SetAbsoluteVolume(volume) => { let max = app .config @@ -732,6 +768,7 @@ pub struct AppWidget<'a, 'b> { current_tab_index: usize, view: &'a View<'b>, config: &'a Config, + hidden_instance: &'a HashSet, } pub struct AppWidgetState<'a> { @@ -797,6 +834,7 @@ 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, }; widget.render(list_area, buf, state.mouse_areas); @@ -918,8 +956,13 @@ 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, + ); // Select the node Action::SelectObject(object_id).handle(&mut app).unwrap(); @@ -1271,4 +1314,86 @@ 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); + + 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_stops_capture_when_hiding() { + 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_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); + 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 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"); + 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()); + } } diff --git a/src/config.rs b/src/config.rs index ac871b0..56accc4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -145,6 +145,7 @@ pub struct NameOverride { pub struct CharSet { pub default_device: String, pub default_stream: String, + pub hidden_instance: String, pub selector_top: String, pub selector_middle: String, pub selector_bottom: String, @@ -193,6 +194,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, diff --git a/src/config/char_set.rs b/src/config/char_set.rs index f6d8a2b..b63e62c 100644 --- a/src/config/char_set.rs +++ b/src/config/char_set.rs @@ -15,6 +15,7 @@ pub struct CharSetOverlay { inherit: Option, default_device: Option, default_stream: Option, + hidden_instance: Option, selector_top: Option, selector_middle: Option, selector_bottom: Option, @@ -97,6 +98,7 @@ 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!(selector_top, 1); validate_and_set!(selector_middle, 1); validate_and_set!(selector_bottom, 1); @@ -137,6 +139,7 @@ impl Default for CharSet { Self { default_device: String::from("◇"), default_stream: String::from("◇"), + hidden_instance: String::from("[hide] "), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), @@ -178,6 +181,7 @@ impl CharSet { Self { default_device: String::from("◊"), default_stream: String::from("◊"), + hidden_instance: String::from("[hide] "), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), @@ -209,6 +213,7 @@ impl CharSet { Self { default_device: String::from("*"), default_stream: String::from("*"), + hidden_instance: 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..80f34e4 100644 --- a/src/config/keybinding.rs +++ b/src/config/keybinding.rs @@ -17,6 +17,7 @@ impl Keybinding { HashMap::from([ (event(KeyCode::Char('q')), Action::Exit), (event(KeyCode::Char('m')), Action::ToggleMute), + (event(KeyCode::Char('t')), Action::ToggleHiddenInstance), (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/theme.rs b/src/config/theme.rs index 6919078..1bf481b 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(), 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(), 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..9533c48 100644 --- a/src/device_widget.rs +++ b/src/device_widget.rs @@ -18,6 +18,7 @@ use crate::view; pub struct DeviceWidget<'a> { device: &'a view::Device, selected: bool, + hidden: bool, config: &'a Config, } @@ -25,11 +26,13 @@ impl<'a> DeviceWidget<'a> { pub fn new( device: &'a view::Device, selected: bool, + hidden: bool, config: &'a Config, ) -> Self { Self { device, selected, + hidden, config, } } @@ -132,23 +135,50 @@ 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 { + 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/node_widget.rs b/src/node_widget.rs index ed3f8cd..91fc883 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,7 @@ pub struct NodeWidget<'a> { device_kind: Option, node: &'a view::Node, selected: bool, + hidden: bool, } impl<'a> NodeWidget<'a> { @@ -40,12 +42,14 @@ impl<'a> NodeWidget<'a> { device_kind: Option, node: &'a view::Node, selected: bool, + hidden: bool, ) -> Self { Self { config, device_kind, node, selected, + hidden, } } @@ -156,14 +160,16 @@ 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, + ) + .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); if self.config.peaks == Peaks::Off { let layout = Layout::default() .direction(Direction::Horizontal) @@ -240,6 +246,7 @@ struct HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, + hidden: bool, } impl<'a> HeaderWidget<'a> { @@ -247,34 +254,44 @@ impl<'a> HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, + hidden: bool, ) -> Self { Self { config, device_kind, node, + hidden, + } + } + + /// 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 +299,22 @@ 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 { + 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 +386,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 +424,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) diff --git a/src/object_list.rs b/src/object_list.rs index c43947c..d23a023 100644 --- a/src/object_list.rs +++ b/src/object_list.rs @@ -315,6 +315,7 @@ 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, } struct ObjectListRenderContext<'a> { @@ -348,11 +349,13 @@ impl ObjectListWidget<'_, '_> { .selected .map(|id| id == object.object_id) .unwrap_or_default(); + let hidden = self.hidden_instance.contains(&object.object_id); NodeWidget::new( self.config, self.object_list.device_kind, object, selected, + hidden, ) .render(object_area, buf, mouse_areas); } @@ -405,7 +408,8 @@ impl ObjectListWidget<'_, '_> { .selected .map(|id| id == object.object_id) .unwrap_or_default(); - DeviceWidget::new(object, selected, self.config).render( + let hidden = self.hidden_instance.contains(&object.object_id); + DeviceWidget::new(object, selected, hidden, self.config).render( object_area, buf, mouse_areas, @@ -642,6 +646,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -668,6 +673,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -691,6 +697,37 @@ 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, + ); + + 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 visible_objects_changes_with_scroll() { let (state, wirehose) = init(); @@ -699,6 +736,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -756,6 +794,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -815,6 +854,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -867,6 +907,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -936,6 +977,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -1028,6 +1070,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -1072,6 +1115,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); assert!(view.default_sink.is_some()); @@ -1117,6 +1161,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); assert!(view.default_source.is_some()); diff --git a/src/view.rs b/src/view.rs index 08877d8..a57b8ee 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,7 @@ impl<'a> View<'a> { state: &state::State, names: &config::Names, filters: &[config::MatchCondition], + hidden_instance: &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 +596,32 @@ impl<'a> View<'a> { nodes_input.push(*id); } } + // Stable sort on hidden status only, after the object_serial sort + // above - preserves relative order within the visible and hidden + // groups, so hidden objects sink to the bottom 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(|id| hidden_instance.contains(id)); + } 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(|id| hidden_instance.contains(id)); + let devices_all = devices_all; Self { wirehose, diff --git a/wiremix.toml b/wiremix.toml index 218653b..edcf4b6 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -82,6 +82,9 @@ keybindings = [ { key = { Char = "q" }, action = "Exit" }, # Toggle mute for the selected item { key = { Char = "m" }, action = "ToggleMute" }, + # Hide/show the selected item for this instance only (not saved, not + # synced to other instances) + { key = { Char = "t" }, action = "ToggleHiddenInstance" }, # 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 +339,13 @@ 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. A faint tint +# (e.g. DarkGray) is a typical choice, to visually distinguish hidden +# items from normal ones without hiding them entirely. Empty ({ }) by +# default, i.e. no change from an item's normal look. +row_hidden = { } # Dropdown marker next to the profiles in the Configuration tab dropdown_icon = { } # Border around dropdowns @@ -384,6 +394,10 @@ 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] " # The selection indicator in a tab selector_top = "░" selector_middle = "▒" @@ -452,6 +466,7 @@ meter_center_inactive = { add_modifier = "DIM" } meter_center_active = { add_modifier = "BOLD" } config_device = { } config_profile = { } +row_hidden = { } dropdown_icon = { } dropdown_border = { } dropdown_item = { } @@ -481,6 +496,7 @@ meter_center_inactive = { } meter_center_active = { } config_device = { } config_profile = { } +row_hidden = { } dropdown_icon = { } dropdown_border = { } dropdown_item = { } @@ -493,6 +509,7 @@ help_more = { } [char_sets.compat] default_device = "◊" default_stream = "◊" +hidden_instance = "[hide] " selector_top = "░" selector_middle = "▒" selector_bottom = "░" @@ -521,6 +538,7 @@ help_border = "Plain" [char_sets.extracompat] default_device = "*" default_stream = "*" +hidden_instance = "[hide] " selector_top = "-" selector_middle = "=" selector_bottom = "-" From 2cd29756c62ed2da5505fcd855861f339eb8a768 Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:21:59 -0700 Subject: [PATCH 02/11] Give hidden rows a visible default style; release selection on hide 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. --- src/app.rs | 116 ++++++++++++++++++++++++++++++++++++++++++-- src/config/theme.rs | 4 +- src/object_list.rs | 25 ++++++++++ wiremix.toml | 7 ++- 4 files changed, 143 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6395d02..afb34fb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -628,9 +628,11 @@ impl Handle for Action { Action::ToggleHiddenInstance => { if let Some(object_id) = current_list!(app).selected { if app.hidden_instance.remove(&object_id) { - // Unhidden - nothing proactively resumes a - // capture just because eligibility didn't change, - // so re-trigger it here if it's still capturable. + // 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. if app.capturable_objects.contains(&object_id) { app.start_capture(object_id); } @@ -640,6 +642,12 @@ impl Handle for Action { // blocks new captures - an already-running one // needs to be stopped explicitly here. 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. @@ -981,6 +989,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(); @@ -1326,12 +1363,85 @@ mod tests { 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, + ); + 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, + ); + 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()); diff --git a/src/config/theme.rs b/src/config/theme.rs index 1bf481b..282c2be 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -145,7 +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(), + row_hidden: Style::default().fg(Color::DarkGray), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), @@ -190,7 +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(), + row_hidden: Style::default().add_modifier(Modifier::DIM), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), diff --git a/src/object_list.rs b/src/object_list.rs index d23a023..edca807 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 diff --git a/wiremix.toml b/wiremix.toml index edcf4b6..0237f8c 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -343,9 +343,8 @@ config_profile = { } # volume, config_device, config_profile) - only whichever of # fg/bg/add_modifier you set here override the base style. A faint tint # (e.g. DarkGray) is a typical choice, to visually distinguish hidden -# items from normal ones without hiding them entirely. Empty ({ }) by -# default, i.e. no change from an item's normal look. -row_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 @@ -466,7 +465,7 @@ meter_center_inactive = { add_modifier = "DIM" } meter_center_active = { add_modifier = "BOLD" } config_device = { } config_profile = { } -row_hidden = { } +row_hidden = { add_modifier = "DIM" } dropdown_icon = { } dropdown_border = { } dropdown_item = { } From 10f65fa8951ad823b992f34b5236dbf0fda9fe9f Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:37:08 -0700 Subject: [PATCH 03/11] Document t as a mnemonic for "toggle hide" --- src/config/keybinding.rs | 2 ++ wiremix.toml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config/keybinding.rs b/src/config/keybinding.rs index 80f34e4..c559e7a 100644 --- a/src/config/keybinding.rs +++ b/src/config/keybinding.rs @@ -17,6 +17,8 @@ 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. (event(KeyCode::Char('t')), Action::ToggleHiddenInstance), (event(KeyCode::Char('d')), Action::SetDefault), (event(KeyCode::Char('l')), Action::SetRelativeVolume(0.01)), diff --git a/wiremix.toml b/wiremix.toml index 0237f8c..93be939 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -82,8 +82,8 @@ keybindings = [ { key = { Char = "q" }, action = "Exit" }, # Toggle mute for the selected item { key = { Char = "m" }, action = "ToggleMute" }, - # Hide/show the selected item for this instance only (not saved, not - # synced to other instances) + # "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" }, # Make the selected item in Input/Output Devices the default endpoint { key = { Char = "d" }, action = "SetDefault" }, From 0decbd1d3212e580b40655f419356dbc046cda5c Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:43:53 -0700 Subject: [PATCH 04/11] Add capture_hidden option to filter peak monitoring for hidden items 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. --- src/app.rs | 83 ++++++++++++++++++++++++++++++++++++++++++---- src/config.rs | 18 ++++++++++ src/node_widget.rs | 78 ++++++++++++++++++++++++++++++++++++++++++- src/opt.rs | 10 ++++++ wiremix.toml | 8 +++++ 5 files changed, 190 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index afb34fb..609691e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -364,7 +364,9 @@ impl<'a> App<'a> { return; } - if self.hidden_instance.contains(&object_id) { + if !self.config.capture_hidden + && self.hidden_instance.contains(&object_id) + { return; } @@ -632,16 +634,24 @@ impl Handle for Action { // 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. - if app.capturable_objects.contains(&object_id) { + // 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. - app.stop_capture(object_id); + // 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 @@ -927,6 +937,7 @@ mod tests { tab: 0, tabs: vec![TabKind::Playback], lazy_capture: Default::default(), + capture_hidden: true, filters: Default::default(), }; @@ -1062,6 +1073,7 @@ mod tests { TabKind::Configuration, ], lazy_capture: Default::default(), + capture_hidden: true, filters: Default::default(), }; let mut app = App::new(&wirehose, event_rx, config); @@ -1447,6 +1459,7 @@ mod tests { 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); @@ -1468,6 +1481,7 @@ mod tests { 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); @@ -1484,12 +1498,44 @@ mod tests { ); } + #[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"); + 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); @@ -1506,4 +1552,29 @@ mod tests { 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)) + ); + } } diff --git a/src/config.rs b/src/config.rs index 56accc4..abe0700 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, } @@ -272,6 +275,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) { @@ -330,6 +337,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; + } } } @@ -398,6 +413,7 @@ impl TryFrom for Config { tab, tabs: config_file.tabs, lazy_capture: config_file.lazy_capture, + capture_hidden: config_file.capture_hidden, filters, }) } @@ -483,6 +499,7 @@ pub mod strict { tab: Option, tabs: Vec, lazy_capture: bool, + capture_hidden: bool, filters: Vec, } @@ -504,6 +521,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/node_widget.rs b/src/node_widget.rs index 91fc883..3a10ca6 100644 --- a/src/node_widget.rs +++ b/src/node_widget.rs @@ -200,7 +200,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 monitoring_suspended = + self.hidden && !self.config.capture_hidden; + if !monitoring_suspended { + MeterWidget::new(self.config, self.node) + .render(meter_area, buf); + } } } } @@ -541,3 +551,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 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).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/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/wiremix.toml b/wiremix.toml index 93be939..3601cf1 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 # From 4543350c91b64c6918635d276534bfb5074ea40f Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:28:52 -0700 Subject: [PATCH 05/11] Add permanent, cross-instance-synced hide/show for list items (Ctrl+t) 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). --- README.md | 1 + src/app.rs | 344 ++++++++++++++++++++++++++++++++++++- src/config.rs | 1 + src/config/char_set.rs | 5 + src/config/keybinding.rs | 4 + src/config/matching.rs | 94 +++++++++- src/config/property_key.rs | 25 ++- src/device_widget.rs | 23 ++- src/hidden_state.rs | 123 +++++++++++++ src/lib.rs | 1 + src/main.rs | 6 +- src/node_widget.rs | 37 ++-- src/object_list.rs | 70 +++++++- src/view.rs | 23 ++- wiremix.toml | 18 +- 15 files changed, 720 insertions(+), 55 deletions(-) create mode 100644 src/hidden_state.rs diff --git a/README.md b/README.md index 3a4e8b8..a688186 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ 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 609691e..756bbe2 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, @@ -51,6 +53,7 @@ pub enum Action { MoveDown, ToggleMute, ToggleHiddenInstance, + ToggleHiddenPermanent, SetRelativeVolume(f32), SetDefault, ActivateDropdown, @@ -85,6 +88,9 @@ impl std::fmt::Display for Action { 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)) } @@ -216,6 +222,25 @@ pub struct App<'a> { /// 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. @@ -267,12 +292,72 @@ impl<'a> App<'a> { 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); + } + pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> { // Wait until we've received all initial data from PipeWire let _ = terminal.draw(|frame| { @@ -290,12 +375,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; @@ -337,6 +424,7 @@ impl<'a> App<'a> { 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, @@ -374,6 +462,21 @@ impl<'a> App<'a> { 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 + .hidden_permanent_matchers + .iter() + .any(|matcher| matcher.matches(&self.state, node)) + { + return; + } + if self .config .filters @@ -664,6 +767,43 @@ impl Handle for Action { 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) { + app.stop_capture(object_id); + } else if app.capturable_objects.contains(&object_id) { + app.start_capture(object_id); + } + + app.save_hidden_state(); + app.state_dirty = true; + } + } Action::SetAbsoluteVolume(volume) => { let max = app .config @@ -787,6 +927,7 @@ pub struct AppWidget<'a, 'b> { view: &'a View<'b>, config: &'a Config, hidden_instance: &'a HashSet, + hidden_permanent: &'a HashSet, } pub struct AppWidgetState<'a> { @@ -853,6 +994,7 @@ impl<'a> StatefulWidget for AppWidget<'a, '_> { view: self.view, config: self.config, hidden_instance: self.hidden_instance, + hidden_permanent: self.hidden_permanent, }; widget.render(list_area, buf, state.mouse_areas); @@ -981,6 +1123,7 @@ mod tests { &app.config.names, &Vec::new(), &app.hidden_instance, + &app.hidden_permanent, ); // Select the node @@ -1577,4 +1720,203 @@ mod tests { 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); + 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); + 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 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"); + 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); + } } diff --git a/src/config.rs b/src/config.rs index abe0700..a06aa9c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -149,6 +149,7 @@ 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, diff --git a/src/config/char_set.rs b/src/config/char_set.rs index b63e62c..d52ab9c 100644 --- a/src/config/char_set.rs +++ b/src/config/char_set.rs @@ -16,6 +16,7 @@ pub struct CharSetOverlay { default_device: Option, default_stream: Option, hidden_instance: Option, + hidden_permanent: Option, selector_top: Option, selector_middle: Option, selector_bottom: Option, @@ -99,6 +100,7 @@ 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); @@ -140,6 +142,7 @@ impl Default for CharSet { default_device: String::from("◇"), default_stream: String::from("◇"), hidden_instance: String::from("[hide] "), + hidden_permanent: String::from("[perm-hide] "), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), @@ -182,6 +185,7 @@ impl CharSet { default_device: String::from("◊"), default_stream: String::from("◊"), hidden_instance: String::from("[hide] "), + hidden_permanent: String::from("[perm-hide] "), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), @@ -214,6 +218,7 @@ impl CharSet { default_device: String::from("*"), default_stream: String::from("*"), hidden_instance: String::from("[hide] "), + hidden_permanent: String::from("[perm-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 c559e7a..a24f6cb 100644 --- a/src/config/keybinding.rs +++ b/src/config/keybinding.rs @@ -20,6 +20,10 @@ impl Keybinding { // `t` for "toggle hide": toggles hiding the selected item for // this instance only. (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/device_widget.rs b/src/device_widget.rs index 9533c48..2cef5b0 100644 --- a/src/device_widget.rs +++ b/src/device_widget.rs @@ -18,7 +18,8 @@ use crate::view; pub struct DeviceWidget<'a> { device: &'a view::Device, selected: bool, - hidden: bool, + hidden_instance: bool, + hidden_permanent: bool, config: &'a Config, } @@ -26,17 +27,23 @@ impl<'a> DeviceWidget<'a> { pub fn new( device: &'a view::Device, selected: bool, - hidden: bool, + hidden_instance: bool, + hidden_permanent: bool, config: &'a Config, ) -> Self { Self { device, selected, - hidden, + 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 @@ -135,7 +142,7 @@ impl StatefulWidget for DeviceWidget<'_> { let title_area = layout[0]; let target_area = layout[1]; - let title_style = if self.hidden { + let title_style = if self.hidden() { self.config .theme .config_device @@ -143,7 +150,9 @@ impl StatefulWidget for DeviceWidget<'_> { } else { self.config.theme.config_device }; - let hidden_prefix = if self.hidden { + 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("") @@ -155,7 +164,7 @@ impl StatefulWidget for DeviceWidget<'_> { ]) .render(title_area, buf); - let profile_style = if self.hidden { + let profile_style = if self.hidden() { self.config .theme .config_profile @@ -163,7 +172,7 @@ impl StatefulWidget for DeviceWidget<'_> { } else { self.config.theme.config_profile }; - let dropdown_icon_style = if self.hidden { + let dropdown_icon_style = if self.hidden() { self.config .theme .dropdown_icon diff --git a/src/hidden_state.rs b/src/hidden_state.rs new file mode 100644 index 0000000..740bb7a --- /dev/null +++ b/src/hidden_state.rs @@ -0,0 +1,123 @@ +//! 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 { + /// 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/hidden.toml")); + } + + if let Ok(home) = env::var("HOME") { + return Some( + Path::new(&home).join(".local/state/wiremix/hidden.toml"), + ); + } + + 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 3a10ca6..4952ab4 100644 --- a/src/node_widget.rs +++ b/src/node_widget.rs @@ -33,7 +33,8 @@ pub struct NodeWidget<'a> { device_kind: Option, node: &'a view::Node, selected: bool, - hidden: bool, + hidden_instance: bool, + hidden_permanent: bool, } impl<'a> NodeWidget<'a> { @@ -42,14 +43,16 @@ impl<'a> NodeWidget<'a> { device_kind: Option, node: &'a view::Node, selected: bool, - hidden: bool, + hidden_instance: bool, + hidden_permanent: bool, ) -> Self { Self { config, device_kind, node, selected, - hidden, + hidden_instance, + hidden_permanent, } } @@ -164,12 +167,17 @@ impl StatefulWidget for NodeWidget<'_> { self.config, self.device_kind, self.node, - self.hidden, + 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, self.hidden); + 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) @@ -256,7 +264,8 @@ struct HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, - hidden: bool, + hidden_instance: bool, + hidden_permanent: bool, } impl<'a> HeaderWidget<'a> { @@ -264,21 +273,27 @@ impl<'a> HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, - hidden: bool, + hidden_instance: bool, + hidden_permanent: bool, ) -> Self { Self { config, device_kind, node, - hidden, + 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 { + if self.hidden() { base.patch(self.config.theme.row_hidden) } else { base @@ -315,7 +330,9 @@ impl<'a> HeaderWidget<'a> { Span::from(" ") }; let title_style = self.hidden_style(self.config.theme.node_title); - let hidden_prefix = if self.hidden { + 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("") diff --git a/src/object_list.rs b/src/object_list.rs index edca807..8696166 100644 --- a/src/object_list.rs +++ b/src/object_list.rs @@ -341,6 +341,7 @@ pub struct ObjectListWidget<'a, 'b> { pub view: &'a view::View<'b>, pub config: &'a Config, pub hidden_instance: &'a HashSet, + pub hidden_permanent: &'a HashSet, } struct ObjectListRenderContext<'a> { @@ -374,13 +375,17 @@ impl ObjectListWidget<'_, '_> { .selected .map(|id| id == object.object_id) .unwrap_or_default(); - let hidden = self.hidden_instance.contains(&object.object_id); + 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, + hidden_instance, + hidden_permanent, ) .render(object_area, buf, mouse_areas); } @@ -433,12 +438,18 @@ impl ObjectListWidget<'_, '_> { .selected .map(|id| id == object.object_id) .unwrap_or_default(); - let hidden = self.hidden_instance.contains(&object.object_id); - DeviceWidget::new(object, selected, hidden, 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? @@ -672,6 +683,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -699,6 +711,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -738,6 +751,7 @@ mod tests { &config::Names::default(), &Vec::new(), &hidden, + &HashSet::new(), ); let ids: Vec = view @@ -753,6 +767,38 @@ mod tests { 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(); @@ -762,6 +808,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -820,6 +867,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -880,6 +928,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -933,6 +982,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -1003,6 +1053,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -1096,6 +1147,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -1141,6 +1193,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); assert!(view.default_sink.is_some()); @@ -1187,6 +1240,7 @@ mod tests { &config::Names::default(), &Vec::new(), &HashSet::new(), + &HashSet::new(), ); assert!(view.default_source.is_some()); diff --git a/src/view.rs b/src/view.rs index a57b8ee..a926bd0 100644 --- a/src/view.rs +++ b/src/view.rs @@ -473,6 +473,7 @@ impl<'a> View<'a> { 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"); @@ -596,9 +597,21 @@ impl<'a> View<'a> { nodes_input.push(*id); } } - // Stable sort on hidden status only, after the object_serial sort - // above - preserves relative order within the visible and hidden - // groups, so hidden objects sink to the bottom without otherwise + // 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, @@ -607,7 +620,7 @@ impl<'a> View<'a> { &mut nodes_output, &mut nodes_input, ] { - list.sort_by_key(|id| hidden_instance.contains(id)); + list.sort_by_key(&hidden_rank); } let nodes_all = nodes_all; let nodes_playback = nodes_playback; @@ -620,7 +633,7 @@ impl<'a> View<'a> { .sorted_by_key(|(_, device)| device.object_serial) .map(|(&id, _)| id) .collect(); - devices_all.sort_by_key(|id| hidden_instance.contains(id)); + devices_all.sort_by_key(&hidden_rank); let devices_all = devices_all; Self { diff --git a/wiremix.toml b/wiremix.toml index 3601cf1..12cbf7b 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -93,6 +93,9 @@ keybindings = [ # "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" }, + # Hide/show the selected item 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% @@ -349,9 +352,12 @@ config_device = { } 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. A faint tint -# (e.g. DarkGray) is a typical choice, to visually distinguish hidden -# items from normal ones without hiding them entirely. +# 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 = { } @@ -405,6 +411,10 @@ default_stream = "◇" # 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 = "[perm-hide] " # The selection indicator in a tab selector_top = "░" selector_middle = "▒" @@ -517,6 +527,7 @@ help_more = { } default_device = "◊" default_stream = "◊" hidden_instance = "[hide] " +hidden_permanent = "[perm-hide] " selector_top = "░" selector_middle = "▒" selector_bottom = "░" @@ -546,6 +557,7 @@ help_border = "Plain" default_device = "*" default_stream = "*" hidden_instance = "[hide] " +hidden_permanent = "[perm-hide] " selector_top = "-" selector_middle = "=" selector_bottom = "-" From 277e5014554353b001c35f6b52f4529aa1de3c8e Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:35:02 -0700 Subject: [PATCH 06/11] Sync permanent hide/show live across instances via inotify 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. --- Cargo.toml | 2 +- src/app.rs | 177 +++++++++++++++++++++++++++++++++++ src/hidden_state.rs | 14 ++- src/wirehose/event.rs | 6 ++ src/wirehose/event_sender.rs | 12 +++ src/wirehose/session.rs | 65 +++++++++++++ 6 files changed, 273 insertions(+), 3 deletions(-) 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/src/app.rs b/src/app.rs index 756bbe2..93f03b4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -358,6 +358,63 @@ impl<'a> App<'a> { 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; + + 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| { @@ -883,6 +940,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) + } } } } @@ -1919,4 +1980,120 @@ mod tests { 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); + 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_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/hidden_state.rs b/src/hidden_state.rs index 740bb7a..d107c7d 100644 --- a/src/hidden_state.rs +++ b/src/hidden_state.rs @@ -21,18 +21,28 @@ pub struct HiddenState { } 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/hidden.toml")); + 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/hidden.toml"), + Path::new(&home) + .join(".local/state/wiremix") + .join(Self::FILENAME), ); } 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 From 94c0d2e307556a0619795b45cea64842df196d13 Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:34:57 -0700 Subject: [PATCH 07/11] Release selection when permanently hiding the selected item 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. --- src/app.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app.rs b/src/app.rs index 93f03b4..0673da2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -853,6 +853,10 @@ impl Handle for Action { if app.hidden_permanent.contains(&object_id) { 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.capturable_objects.contains(&object_id) { app.start_capture(object_id); } @@ -1628,6 +1632,7 @@ mod tests { &app.config.names, &Vec::new(), &app.hidden_instance, + &app.hidden_permanent, ); Action::SelectObject(id1).handle(&mut app).unwrap(); @@ -1650,6 +1655,7 @@ mod tests { &app.config.names, &Vec::new(), &app.hidden_instance, + &app.hidden_permanent, ); Action::SelectObject(id2).handle(&mut app).unwrap(); From 430585164e2f8d614d9938663826f98d413a1632 Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:27:01 -0700 Subject: [PATCH 08/11] Rename hidden_permanent default prefix from [perm-hide] to [HIDE] 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. --- src/config/char_set.rs | 6 +++--- wiremix.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/config/char_set.rs b/src/config/char_set.rs index d52ab9c..eac2e31 100644 --- a/src/config/char_set.rs +++ b/src/config/char_set.rs @@ -142,7 +142,7 @@ impl Default for CharSet { default_device: String::from("◇"), default_stream: String::from("◇"), hidden_instance: String::from("[hide] "), - hidden_permanent: String::from("[perm-hide] "), + hidden_permanent: String::from("[HIDE] "), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), @@ -185,7 +185,7 @@ impl CharSet { default_device: String::from("◊"), default_stream: String::from("◊"), hidden_instance: String::from("[hide] "), - hidden_permanent: String::from("[perm-hide] "), + hidden_permanent: String::from("[HIDE] "), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), @@ -218,7 +218,7 @@ impl CharSet { default_device: String::from("*"), default_stream: String::from("*"), hidden_instance: String::from("[hide] "), - hidden_permanent: String::from("[perm-hide] "), + hidden_permanent: String::from("[HIDE] "), selector_top: String::from("-"), selector_middle: String::from("="), selector_bottom: String::from("-"), diff --git a/wiremix.toml b/wiremix.toml index 12cbf7b..964d3ba 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -414,7 +414,7 @@ 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 = "[perm-hide] " +hidden_permanent = "[HIDE] " # The selection indicator in a tab selector_top = "░" selector_middle = "▒" @@ -527,7 +527,7 @@ help_more = { } default_device = "◊" default_stream = "◊" hidden_instance = "[hide] " -hidden_permanent = "[perm-hide] " +hidden_permanent = "[HIDE] " selector_top = "░" selector_middle = "▒" selector_bottom = "░" @@ -557,7 +557,7 @@ help_border = "Plain" default_device = "*" default_stream = "*" hidden_instance = "[hide] " -hidden_permanent = "[perm-hide] " +hidden_permanent = "[HIDE] " selector_top = "-" selector_middle = "=" selector_bottom = "-" From bc22053de0f087c04c10a28f67e5ef5c9b03b989 Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:37:32 -0700 Subject: [PATCH 09/11] Extend "toggle hide" mnemonic doc to Ctrl+t --- src/config/keybinding.rs | 4 +++- wiremix.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config/keybinding.rs b/src/config/keybinding.rs index a24f6cb..1570dff 100644 --- a/src/config/keybinding.rs +++ b/src/config/keybinding.rs @@ -18,7 +18,9 @@ impl Keybinding { (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. + // 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), diff --git a/wiremix.toml b/wiremix.toml index 964d3ba..4d0df19 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -93,7 +93,7 @@ keybindings = [ # "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" }, - # Hide/show the selected item permanently (saved to disk, synced to + # 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 From 952f05fd0a85d53afc0a59307bbc9f1b364c0b2d Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:57:28 -0700 Subject: [PATCH 10/11] Fix capture_hidden meter-blanking to cover both hide tiers 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. --- src/node_widget.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/node_widget.rs b/src/node_widget.rs index 4952ab4..898e831 100644 --- a/src/node_widget.rs +++ b/src/node_widget.rs @@ -213,8 +213,8 @@ impl StatefulWidget for NodeWidget<'_> { // inactive-looking placeholder even though nothing is actually // being sampled, which reads as broken rather than intentionally // off. Leave meter_area untouched instead. - let monitoring_suspended = - self.hidden && !self.config.capture_hidden; + 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); @@ -601,10 +601,10 @@ mod tests { 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 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).render( + // 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(), From c426ba7b8c492146ac0d025f76d0c8c1c247bdbf Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:02:56 -0700 Subject: [PATCH 11/11] Extend capture_hidden to cover permanent hide and cross-instance sync 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. --- src/app.rs | 90 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0673da2..566353e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -394,14 +394,16 @@ impl<'a> App<'a> { self.recompute_hidden_permanent(); self.state_dirty = true; - 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); + 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 @@ -526,10 +528,11 @@ impl<'a> App<'a> { // 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 - .hidden_permanent_matchers - .iter() - .any(|matcher| matcher.matches(&self.state, node)) + if !self.config.capture_hidden + && self + .hidden_permanent_matchers + .iter() + .any(|matcher| matcher.matches(&self.state, node)) { return; } @@ -852,12 +855,20 @@ impl Handle for Action { app.recompute_hidden_permanent(); if app.hidden_permanent.contains(&object_id) { - app.stop_capture(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.capturable_objects.contains(&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); } @@ -1866,6 +1877,7 @@ mod tests { 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); @@ -1887,6 +1899,7 @@ mod tests { 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(); @@ -1907,12 +1920,32 @@ mod tests { ); } + #[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"); + 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); @@ -2002,6 +2035,7 @@ mod tests { 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()); @@ -2026,6 +2060,32 @@ mod tests { 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());