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 1/4] 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 2/4] 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 3/4] 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 4/4] 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 #