From 8cc35b3a03f74ee655aaf0124ee85f362e3873fd Mon Sep 17 00:00:00 2001 From: Xartoks Date: Sat, 4 Jul 2026 18:04:20 +0200 Subject: [PATCH 1/3] feat: add option to show or hide empty tabs --- src/app.rs | 99 +++++++++++++++++++++++++++++++++++++++------- src/config.rs | 18 +++++++++ src/object_list.rs | 2 +- src/opt.rs | 8 ++++ wiremix.toml | 3 ++ 5 files changed, 114 insertions(+), 16 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6c973d2..faaa7e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,6 +13,7 @@ use crate::wirehose::{ use anyhow::{anyhow, Result}; +use ratatui::style::Stylize; use ratatui::{ layout::Flex, prelude::{Buffer, Constraint, Direction, Layout, Position, Rect}, @@ -184,7 +185,9 @@ pub struct App<'a> { error_message: Option, /// The main tabs tabs: Vec, - /// The index of the currently-visible tab + /// Indices into `tabs` for tabs that currently have visible content. + visible_tabs: Vec, + /// Index into `tabs` of the currently selected visible tab. current_tab_index: usize, /// Areas populated during rendering which define actions corresponding to /// mouse activity @@ -228,7 +231,9 @@ impl<'a> App<'a> { rx: mpsc::Receiver, config: Config, ) -> Self { - let tabs = config.tabs.iter().copied().map(Tab::from).collect(); + let tabs: Vec = + config.tabs.iter().copied().map(Tab::from).collect(); + let visible_tabs = (0..tabs.len()).collect(); // Update peaks with VU-meter-style ballistics let peak_processor = |new_peak, current_peak, samples, rate| { @@ -248,6 +253,7 @@ impl<'a> App<'a> { rx, error_message: None, tabs, + visible_tabs: visible_tabs, current_tab_index: config.tab, mouse_areas: Vec::new(), is_ready: false, @@ -288,6 +294,9 @@ impl<'a> App<'a> { &self.config.filters, ); } + if !self.config.show_empty_tabs { + self.update_visible_tabs(); + } self.state_dirty = false; let frame = terminal.get_frame(); @@ -330,6 +339,7 @@ impl<'a> App<'a> { let mut widget_state = AppWidgetState { mouse_areas: &mut self.mouse_areas, tabs: &mut self.tabs, + visible_tabs: &self.visible_tabs, help_position: &mut self.help_position, }; @@ -341,6 +351,32 @@ impl<'a> App<'a> { self.error_message = error_message; } + fn update_visible_tabs(&mut self) { + self.visible_tabs = self + .tabs + .iter() + .enumerate() + .filter_map(|(index, tab)| { + if self.view.len(tab.list.list_kind) > 0 { + Some(index) + } else { + None + } + }) + .collect(); + + if self.visible_tabs.is_empty() { + return; + } + + self.current_tab_index = + match self.visible_tabs.binary_search(&self.current_tab_index) { + Ok(idx) => self.visible_tabs[idx], + Err(0) => self.visible_tabs[0], + Err(idx) => self.visible_tabs[idx - 1], + }; + } + fn stop_capture(&mut self, object_id: ObjectId) { self.capturing_objects.remove(&object_id); self.wirehose.node_capture_stop(object_id); @@ -575,8 +611,8 @@ impl Handle for Action { match self { Action::SelectTab(index) => { - if index < app.tabs.len() { - app.current_tab_index = index; + if let Some(&tab_index) = app.visible_tabs.get(index) { + app.current_tab_index = tab_index; } } Action::MoveDown => { @@ -586,14 +622,32 @@ impl Handle for Action { current_list!(app).up(&app.view); } Action::TabLeft => { - app.current_tab_index = app - .current_tab_index - .checked_sub(1) - .unwrap_or(app.tabs.len() - 1) + if app.visible_tabs.is_empty() { + return Ok(false); + } + app.current_tab_index = match app + .visible_tabs + .binary_search(&app.current_tab_index) + { + Ok(idx) | Err(idx) => { + app.visible_tabs[idx + .checked_sub(1) + .unwrap_or(app.visible_tabs.len() - 1)] + } + }; } Action::TabRight => { - app.current_tab_index = - (app.current_tab_index + 1) % app.tabs.len() + if app.visible_tabs.is_empty() { + return Ok(false); + } + app.current_tab_index = match app + .visible_tabs + .binary_search(&app.current_tab_index) + { + Ok(idx) | Err(idx) => { + app.visible_tabs[(idx + 1) % app.visible_tabs.len()] + } + }; } Action::CloseDropdown => { current_list!(app).dropdown_close(); @@ -737,6 +791,7 @@ pub struct AppWidget<'a, 'b> { pub struct AppWidgetState<'a> { mouse_areas: &'a mut Vec, tabs: &'a mut Vec, + visible_tabs: &'a [usize], help_position: &'a mut Option, } @@ -744,6 +799,15 @@ impl<'a> StatefulWidget for AppWidget<'a, '_> { type State = AppWidgetState<'a>; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + if state.visible_tabs.is_empty() { + let line = Line::from("All tabs are empty".red().bold()).centered(); + let [area_centered] = Layout::vertical([Constraint::Length(1)]) + .flex(Flex::Center) + .areas(area); + line.render(area_centered, buf); + return; + } + let layout = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -755,9 +819,12 @@ impl<'a> StatefulWidget for AppWidget<'a, '_> { let menu_area = layout[1]; let constraints: Vec<_> = state - .tabs + .visible_tabs .iter() - .map(|tab| Constraint::Length(tab.title.len() as u16 + 2)) + .map(|&tab_index| { + let tab = &state.tabs[tab_index]; + Constraint::Length(tab.title.len() as u16 + 2) + }) .collect(); let menu_areas = Layout::default() @@ -765,8 +832,9 @@ impl<'a> StatefulWidget for AppWidget<'a, '_> { .constraints(constraints) .split(menu_area); - for (i, tab) in state.tabs.iter().enumerate() { - let title_line = if i == self.current_tab_index { + for (i, &tab_index) in state.visible_tabs.iter().enumerate() { + let tab = &state.tabs[tab_index]; + let title_line = if tab_index == self.current_tab_index { Line::from(vec![ Span::styled( &self.config.char_set.tab_marker_left, @@ -785,7 +853,6 @@ impl<'a> StatefulWidget for AppWidget<'a, '_> { )) }; title_line.render(menu_areas[i], buf); - state.mouse_areas.push(( menu_areas[i], smallvec![MouseEventKind::Down(MouseButton::Left)], @@ -880,6 +947,7 @@ mod tests { names: Default::default(), tab: 0, tabs: vec![TabKind::Playback], + show_empty_tabs: true, lazy_capture: Default::default(), filters: Default::default(), }; @@ -981,6 +1049,7 @@ mod tests { TabKind::Input, TabKind::Configuration, ], + show_empty_tabs: true, lazy_capture: Default::default(), filters: Default::default(), }; diff --git a/src/config.rs b/src/config.rs index ac871b0..45abf7e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,6 +43,7 @@ pub struct Config { pub names: Names, pub tab: usize, pub tabs: Vec, + pub show_empty_tabs: bool, pub lazy_capture: bool, pub filters: Vec, } @@ -86,6 +87,8 @@ struct ConfigFile { tab: Option, #[serde(default = "default_tabs")] tabs: Vec, + #[serde(default = "default_show_empty_tabs")] + show_empty_tabs: bool, #[serde(default = "default_lazy_capture")] lazy_capture: bool, #[serde(default = "Filter::defaults", deserialize_with = "Filter::merge")] @@ -250,6 +253,10 @@ fn default_tabs() -> Vec { ] } +fn default_show_empty_tabs() -> bool { + false +} + fn default_char_set_name() -> String { String::from("default") } @@ -309,6 +316,14 @@ impl ConfigFile { self.tabs = tabs.clone(); } + if opt.show_empty_tabs { + self.show_empty_tabs = true + } + + if opt.no_empty_tabs { + self.show_empty_tabs = false + } + if let Some(max_volume_percent) = &opt.max_volume_percent { self.max_volume_percent = Some(*max_volume_percent); } @@ -395,6 +410,7 @@ impl TryFrom for Config { names: config_file.names, tab, tabs: config_file.tabs, + show_empty_tabs: config_file.show_empty_tabs, lazy_capture: config_file.lazy_capture, filters, }) @@ -480,6 +496,7 @@ pub mod strict { themes: HashMap, tab: Option, tabs: Vec, + show_empty_tabs: bool, lazy_capture: bool, filters: Vec, } @@ -501,6 +518,7 @@ pub mod strict { themes: strict.themes, tab: strict.tab, tabs: strict.tabs, + show_empty_tabs: strict.show_empty_tabs, lazy_capture: strict.lazy_capture, filters: strict.filters, } diff --git a/src/object_list.rs b/src/object_list.rs index c43947c..e5ba1c4 100644 --- a/src/object_list.rs +++ b/src/object_list.rs @@ -32,7 +32,7 @@ pub struct ObjectList { /// ID of the currently selected object pub selected: Option, /// Which set of objects to use from the View - list_kind: ListKind, + pub list_kind: ListKind, /// Default device type to use for defaults and node rendering device_kind: Option, /// Target dropdown state diff --git a/src/opt.rs b/src/opt.rs index 534d130..2a91fff 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -57,6 +57,14 @@ pub struct Opt { #[clap(short = 'T', long, num_args = 1.., value_enum)] pub tabs: Option>, + /// Hide tabs that have no visible entries. + #[clap(long, conflicts_with = "show_empty_tabs")] + pub no_empty_tabs: bool, + + /// Show tabs even when they have no visible entries. + #[clap(long, conflicts_with = "no_empty_tabs")] + pub show_empty_tabs: bool, + /// Maximum volume for volume sliders #[clap(short = 'm', long, value_name = "PERCENT")] pub max_volume_percent: Option, diff --git a/wiremix.toml b/wiremix.toml index 218653b..efe1130 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -35,6 +35,9 @@ tab = "playback" # Which tabs are present and their order tabs = [ "playback", "recording", "output", "input", "configuration" ] +# Whether to show tabs even when their corresponding list is empty. +show_empty_tabs = true + # Maximum percentage for volume sliders max_volume_percent = 150.0 From d7ef2796539eda4393078e6a2d118612034cfefd Mon Sep 17 00:00:00 2001 From: Xartoks Date: Sat, 4 Jul 2026 18:24:38 +0200 Subject: [PATCH 2/3] fix: set show_empty_tabs to false by default --- wiremix.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wiremix.toml b/wiremix.toml index efe1130..7175659 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -36,7 +36,7 @@ tab = "playback" tabs = [ "playback", "recording", "output", "input", "configuration" ] # Whether to show tabs even when their corresponding list is empty. -show_empty_tabs = true +show_empty_tabs = false # Maximum percentage for volume sliders max_volume_percent = 150.0 From 5c775618f8b0752b22348a6378c54a140af387be Mon Sep 17 00:00:00 2001 From: Xartoks Date: Sat, 4 Jul 2026 18:33:18 +0200 Subject: [PATCH 3/3] fix clippy error --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index faaa7e1..f5e3801 100644 --- a/src/app.rs +++ b/src/app.rs @@ -253,7 +253,7 @@ impl<'a> App<'a> { rx, error_message: None, tabs, - visible_tabs: visible_tabs, + visible_tabs, current_tab_index: config.tab, mouse_areas: Vec::new(), is_ready: false,