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..609691e 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,12 @@ impl<'a> App<'a> { return; } + if !self.config.capture_hidden + && self.hidden_instance.contains(&object_id) + { + return; + } + let Some(node) = self.state.nodes.get(&object_id) else { return; }; @@ -610,6 +627,43 @@ impl Handle for Action { Action::ToggleMute => { current_list!(app).toggle_mute(&app.view); } + Action::ToggleHiddenInstance => { + if let Some(object_id) = current_list!(app).selected { + if app.hidden_instance.remove(&object_id) { + // Unhidden - hold onto the selection (the user + // is still looking at this item), but nothing + // proactively resumes a capture just because + // eligibility didn't change, so re-trigger it + // here if it's still capturable. Skipped when + // capture_hidden is on, since the capture was + // never stopped in the first place. + if !app.config.capture_hidden + && app.capturable_objects.contains(&object_id) + { + app.start_capture(object_id); + } + } else { + app.hidden_instance.insert(object_id); + // start_capture()'s hidden_instance check only + // blocks new captures - an already-running one + // needs to be stopped explicitly here. Skipped + // when capture_hidden is on, since hidden items + // should keep being monitored like regular ones. + if !app.config.capture_hidden { + app.stop_capture(object_id); + } + // Release the selection rather than leave it + // pinned to an item that's about to sink to the + // bottom of the list - move it to whatever's + // next in line instead. + current_list!(app) + .release_hidden_selection(&app.view, object_id); + } + // Hiding/unhiding changes list ordering, which is + // computed in View::from() - force a rebuild. + app.state_dirty = true; + } + } Action::SetAbsoluteVolume(volume) => { let max = app .config @@ -732,6 +786,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 +852,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); @@ -881,6 +937,7 @@ mod tests { tab: 0, tabs: vec![TabKind::Playback], lazy_capture: Default::default(), + capture_hidden: true, filters: Default::default(), }; @@ -918,8 +975,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(); @@ -938,6 +1000,35 @@ mod tests { .unwrap(); } + /// Like `add_capturable_node`, but with everything `view::Node::from` + /// requires (via `?`) to actually appear in `app.view` - `node_name`, + /// `volumes`, `mute` - none of which `add_capturable_node` alone sets, + /// since capture-eligibility tests need a node in `app.state` but never + /// touch `app.view` at all. + fn add_playback_node(app: &mut App<'_>, object_id: ObjectId) { + let mut props = PropertyStore::default(); + props.set_node_description(String::from("Test node")); + props.set_media_class(String::from("Stream/Output/Audio")); + props.set_node_name(format!("node-{}", u32::from(object_id))); + props.set_object_serial(u32::from(object_id) as u64); + + StateEvent::NodeProperties { object_id, props } + .handle(app) + .unwrap(); + StateEvent::NodeVolumes { + object_id, + volumes: vec![1.0], + } + .handle(app) + .unwrap(); + StateEvent::NodeMute { + object_id, + mute: false, + } + .handle(app) + .unwrap(); + } + #[test] fn select_tab_bounds() { let wirehose = mock::WirehoseHandle::default(); @@ -982,6 +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); @@ -1271,4 +1363,218 @@ mod tests { Some(mock::MockCommand::NodeCaptureStop(id)) ); } + + #[test] + fn toggle_hidden_instance_hides_and_shows_selected_object() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + app.state_dirty = false; + + assert!(Action::ToggleHiddenInstance.handle(&mut app).unwrap()); + assert!(app.hidden_instance.contains(&id)); + assert!(app.state_dirty); + + // Hiding the only object releases the selection (see + // toggle_hidden_instance_clears_selection_when_hiding_only_item) - + // re-select it to verify toggling again un-hides it. + Action::SelectObject(id).handle(&mut app).unwrap(); + + app.state_dirty = false; + assert!(Action::ToggleHiddenInstance.handle(&mut app).unwrap()); + assert!(!app.hidden_instance.contains(&id)); + assert!(app.state_dirty); + } + + #[test] + fn toggle_hidden_instance_clears_selection_when_hiding_only_item() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + assert_eq!(current_list!(app).selected, Some(id)); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert_eq!(current_list!(app).selected, None); + } + + #[test] + fn toggle_hidden_instance_unhiding_keeps_selection() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + app.hidden_instance.insert(id); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert_eq!(current_list!(app).selected, Some(id)); + } + + #[test] + fn toggle_hidden_instance_selects_next_when_hiding_middle_item() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let id1 = ObjectId::from_raw_id(1); + let id2 = ObjectId::from_raw_id(2); + add_playback_node(&mut app, id1); + add_playback_node(&mut app, id2); + app.view = View::from( + app.wirehose, + &app.state, + &app.config.names, + &Vec::new(), + &app.hidden_instance, + ); + 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()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + app.config.capture_hidden = false; + let id = ObjectId::from_raw_id(0); + + app.capturable_objects.insert(id); + app.capturing_objects.insert(id); + commands.borrow_mut().clear(); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert!(app.hidden_instance.contains(&id)); + assert!(!app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStop(id)) + ); + } + + #[test] + fn toggle_hidden_instance_resumes_capture_when_unhiding() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + app.config.capture_hidden = false; + let id = ObjectId::from_raw_id(0); + + app.hidden_instance.insert(id); + app.capturable_objects.insert(id); + commands.borrow_mut().clear(); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert!(!app.hidden_instance.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStart(id)) + ); + } + + #[test] + fn toggle_hidden_instance_hiding_keeps_capture_by_default() { + // capture_hidden defaults to true - hiding an item shouldn't stop + // an already-running capture, and shouldn't try to start a + // redundant one either. + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let mut app = fixture(&wirehose); + let id = ObjectId::from_raw_id(0); + + app.capturable_objects.insert(id); + app.capturing_objects.insert(id); + commands.borrow_mut().clear(); + + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert!(app.hidden_instance.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert!(commands.borrow().is_empty()); + + // Hiding released the selection (only object in the list) - + // re-select it before toggling again. + Action::SelectObject(id).handle(&mut app).unwrap(); + Action::ToggleHiddenInstance.handle(&mut app).unwrap(); + + assert!(!app.hidden_instance.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert!(commands.borrow().is_empty()); + } + + #[test] + fn start_capture_skips_hidden_instance_objects() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let (_, event_rx) = mpsc::channel(); + let config = Config::from_toml_str( + "lazy_capture = false\ncapture_hidden = false", + ); + let mut app = App::new(&wirehose, event_rx, config); + + let id = ObjectId::from_raw_id(1); + add_capturable_node(&mut app, id); + // Reset state: node exists but isn't capturing yet + app.capturing_objects.clear(); + app.capturable_objects.clear(); + app.hidden_instance.insert(id); + commands.borrow_mut().clear(); + + app.set_capture_eligibility(CaptureEligibility::Eligible(id)); + + assert!(app.capturable_objects.contains(&id)); + assert!(!app.capturing_objects.contains(&id)); + assert!(commands.borrow().is_empty()); + } + + #[test] + fn start_capture_includes_hidden_instance_objects_by_default() { + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let (_, event_rx) = mpsc::channel(); + let config = Config::from_toml_str("lazy_capture = false"); + let mut app = App::new(&wirehose, event_rx, config); + + let id = ObjectId::from_raw_id(1); + add_capturable_node(&mut app, id); + app.capturing_objects.clear(); + app.capturable_objects.clear(); + app.hidden_instance.insert(id); + commands.borrow_mut().clear(); + + app.set_capture_eligibility(CaptureEligibility::Eligible(id)); + + assert!(app.capturable_objects.contains(&id)); + assert!(app.capturing_objects.contains(&id)); + assert_eq!( + commands.borrow_mut().pop_front(), + Some(mock::MockCommand::NodeCaptureStart(id)) + ); + } } diff --git a/src/config.rs b/src/config.rs index ac871b0..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, } @@ -145,6 +148,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 +197,7 @@ pub struct Theme { pub meter_center_active: Style, pub config_device: Style, pub config_profile: Style, + pub row_hidden: Style, pub dropdown_icon: Style, pub dropdown_border: Style, pub dropdown_item: Style, @@ -270,6 +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) { @@ -328,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; + } } } @@ -396,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, }) } @@ -481,6 +499,7 @@ pub mod strict { tab: Option, tabs: Vec, lazy_capture: bool, + capture_hidden: bool, filters: Vec, } @@ -502,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/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..c559e7a 100644 --- a/src/config/keybinding.rs +++ b/src/config/keybinding.rs @@ -17,6 +17,9 @@ 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)), (event(KeyCode::Right), Action::SetRelativeVolume(0.01)), diff --git a/src/config/theme.rs b/src/config/theme.rs index 6919078..282c2be 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -29,6 +29,7 @@ pub struct ThemeOverlay { meter_center_active: Option, config_device: Option, config_profile: Option, + row_hidden: Option, dropdown_icon: Option, dropdown_border: Option, dropdown_item: Option, @@ -108,6 +109,7 @@ impl TryFrom for Theme { set!(meter_center_active); set!(config_device); set!(config_profile); + set!(row_hidden); set!(dropdown_icon); set!(dropdown_border); set!(dropdown_item); @@ -143,6 +145,7 @@ impl Default for Theme { meter_center_active: Style::default().fg(Color::LightGreen), config_device: Style::default(), config_profile: Style::default(), + row_hidden: Style::default().fg(Color::DarkGray), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), @@ -187,6 +190,7 @@ impl Theme { meter_center_active: Style::default().add_modifier(Modifier::BOLD), config_device: Style::default(), config_profile: Style::default(), + row_hidden: Style::default().add_modifier(Modifier::DIM), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), @@ -220,6 +224,7 @@ impl Theme { meter_center_active: Style::default(), config_device: Style::default(), config_profile: Style::default(), + row_hidden: Style::default(), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), diff --git a/src/device_widget.rs b/src/device_widget.rs index f5235fa..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..3a10ca6 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) @@ -194,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); + } } } } @@ -240,6 +256,7 @@ struct HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, + hidden: bool, } impl<'a> HeaderWidget<'a> { @@ -247,34 +264,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 +309,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 +396,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 +434,14 @@ impl StatefulWidget for VolumeWidget<'_> { let volume = mean.cbrt(); let percent = (volume * 100.0).round() as u32; - Line::from(Span::styled( - format!("{percent}%"), - self.config.theme.volume, - )) - .alignment(Alignment::Right) - .render(volume_label, buf); + let volume_style = if self.hidden { + self.config.theme.volume.patch(self.config.theme.row_hidden) + } else { + self.config.theme.volume + }; + Line::from(Span::styled(format!("{percent}%"), volume_style)) + .alignment(Alignment::Right) + .render(volume_label, buf); let count = ((volume.clamp(0.0, max_volume) / max_volume) * volume_bar.width as f32) @@ -510,3 +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/object_list.rs b/src/object_list.rs index c43947c..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 @@ -315,6 +340,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 +374,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 +433,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 +671,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -668,6 +698,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -691,6 +722,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 +761,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -756,6 +819,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -815,6 +879,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -867,6 +932,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -936,6 +1002,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -1028,6 +1095,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); let height = NodeWidget::height() + NodeWidget::spacing(); @@ -1072,6 +1140,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); assert!(view.default_sink.is_some()); @@ -1117,6 +1186,7 @@ mod tests { &state, &config::Names::default(), &Vec::new(), + &HashSet::new(), ); assert!(view.default_source.is_some()); diff --git a/src/opt.rs b/src/opt.rs index 534d130..0e65a16 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -78,6 +78,16 @@ pub struct Opt { #[clap(long, conflicts_with = "no_lazy_capture")] pub lazy_capture: bool, + /// Exclude hidden items from peak monitoring (on top of, not instead of, + /// lazy-capture/other capture limits) + #[clap(long, conflicts_with = "capture_hidden")] + pub no_capture_hidden: bool, + + /// Apply the same peak monitoring rules to hidden items as regular ones + /// (the default) + #[clap(long, conflicts_with = "no_capture_hidden")] + pub capture_hidden: bool, + #[cfg(debug_assertions)] #[clap(short, long)] pub dump_events: bool, diff --git a/src/view.rs b/src/view.rs index 08877d8..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..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 # @@ -82,6 +90,9 @@ keybindings = [ { key = { Char = "q" }, action = "Exit" }, # Toggle mute for the selected item { key = { Char = "m" }, action = "ToggleMute" }, + # "t" for "toggle hide": hide/show the selected item for this instance + # only (not saved, not synced to other instances) + { key = { Char = "t" }, action = "ToggleHiddenInstance" }, # 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 +347,12 @@ 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. +row_hidden = { fg = "DarkGray" } # Dropdown marker next to the profiles in the Configuration tab dropdown_icon = { } # Border around dropdowns @@ -384,6 +401,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 +473,7 @@ meter_center_inactive = { add_modifier = "DIM" } meter_center_active = { add_modifier = "BOLD" } config_device = { } config_profile = { } +row_hidden = { add_modifier = "DIM" } dropdown_icon = { } dropdown_border = { } dropdown_item = { } @@ -481,6 +503,7 @@ meter_center_inactive = { } meter_center_active = { } config_device = { } config_profile = { } +row_hidden = { } dropdown_icon = { } dropdown_border = { } dropdown_item = { } @@ -493,6 +516,7 @@ help_more = { } [char_sets.compat] default_device = "◊" default_stream = "◊" +hidden_instance = "[hide] " selector_top = "░" selector_middle = "▒" selector_bottom = "░" @@ -521,6 +545,7 @@ help_border = "Plain" [char_sets.extracompat] default_device = "*" default_stream = "*" +hidden_instance = "[hide] " selector_top = "-" selector_middle = "=" selector_bottom = "-"