diff --git a/src/app.rs b/src/app.rs index 6c973d2..36f2966 100644 --- a/src/app.rs +++ b/src/app.rs @@ -882,6 +882,8 @@ mod tests { tabs: vec![TabKind::Playback], lazy_capture: Default::default(), filters: Default::default(), + row_selected_extend_above: Default::default(), + row_selected_extend_below: Default::default(), }; let mut app = App::new(wirehose, event_rx, config); @@ -983,6 +985,8 @@ mod tests { ], lazy_capture: Default::default(), filters: Default::default(), + row_selected_extend_above: Default::default(), + row_selected_extend_below: Default::default(), }; let mut app = App::new(&wirehose, event_rx, config); diff --git a/src/config.rs b/src/config.rs index ac871b0..8eb701b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,6 +36,8 @@ pub struct Config { pub peaks: Peaks, pub char_set: CharSet, pub theme: Theme, + pub row_selected_extend_above: bool, + pub row_selected_extend_below: bool, pub max_volume_percent: f32, pub enforce_max_volume: bool, pub keybindings: HashMap, @@ -64,6 +66,10 @@ struct ConfigFile { char_set: String, #[serde(default = "default_theme_name")] theme: String, + #[serde(default = "default_row_selected_extend")] + row_selected_extend_above: bool, + #[serde(default = "default_row_selected_extend")] + row_selected_extend_below: bool, #[serde(default = "default_max_volume_percent")] max_volume_percent: Option, #[serde(default = "default_enforce_max_volume")] @@ -193,6 +199,8 @@ pub struct Theme { pub meter_center_active: Style, pub config_device: Style, pub config_profile: Style, + pub row_selected: Style, + pub row_unselected: Style, pub dropdown_icon: Style, pub dropdown_border: Style, pub dropdown_item: Style, @@ -270,6 +278,10 @@ fn default_lazy_capture() -> bool { false } +fn default_row_selected_extend() -> bool { + false +} + impl ConfigFile { /// Override configuration with command-line arguments. pub fn apply_opt(&mut self, opt: &Opt) { @@ -390,6 +402,8 @@ impl TryFrom for Config { enforce_max_volume: config_file.enforce_max_volume, char_set, theme, + row_selected_extend_above: config_file.row_selected_extend_above, + row_selected_extend_below: config_file.row_selected_extend_below, keybindings: config_file.keybindings, help, names: config_file.names, @@ -469,6 +483,8 @@ pub mod strict { peaks: Option, char_set: String, theme: String, + row_selected_extend_above: bool, + row_selected_extend_below: bool, max_volume_percent: Option, enforce_max_volume: bool, #[serde(deserialize_with = "keybindings")] @@ -493,6 +509,8 @@ pub mod strict { peaks: strict.peaks, char_set: strict.char_set, theme: strict.theme, + row_selected_extend_above: strict.row_selected_extend_above, + row_selected_extend_below: strict.row_selected_extend_below, max_volume_percent: strict.max_volume_percent, enforce_max_volume: strict.enforce_max_volume, keybindings: strict.keybindings, diff --git a/src/config/theme.rs b/src/config/theme.rs index 6919078..7dca321 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -29,6 +29,10 @@ pub struct ThemeOverlay { meter_center_active: Option, config_device: Option, config_profile: Option, + // Whole-row overlays: span everything above, from node_title/config_device + // through config_profile, in both the node list and the Configuration tab. + row_selected: Option, + row_unselected: Option, dropdown_icon: Option, dropdown_border: Option, dropdown_item: Option, @@ -108,6 +112,8 @@ impl TryFrom for Theme { set!(meter_center_active); set!(config_device); set!(config_profile); + set!(row_selected); + set!(row_unselected); set!(dropdown_icon); set!(dropdown_border); set!(dropdown_item); @@ -143,6 +149,8 @@ impl Default for Theme { meter_center_active: Style::default().fg(Color::LightGreen), config_device: Style::default(), config_profile: Style::default(), + row_selected: Style::default(), + row_unselected: Style::default(), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), @@ -187,6 +195,8 @@ impl Theme { meter_center_active: Style::default().add_modifier(Modifier::BOLD), config_device: Style::default(), config_profile: Style::default(), + row_selected: Style::default(), + row_unselected: Style::default(), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), @@ -220,6 +230,8 @@ impl Theme { meter_center_active: Style::default(), config_device: Style::default(), config_profile: Style::default(), + row_selected: Style::default(), + row_unselected: 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..77747fd 100644 --- a/src/device_widget.rs +++ b/src/device_widget.rs @@ -3,6 +3,7 @@ use ratatui::{ layout::Flex, prelude::{Buffer, Constraint, Direction, Layout, Rect}, + style::Style, text::{Line, Span}, widgets::{StatefulWidget, Widget}, }; @@ -12,6 +13,7 @@ use smallvec::smallvec; use crate::app::{Action, MouseArea}; use crate::config::Config; +use crate::node_widget::row_text_style; use crate::object_list::ObjectList; use crate::view; @@ -70,12 +72,25 @@ impl<'a> DeviceWidget<'a> { Rect::new(x, y, width, height) } + + /// See `node_widget::row_text_style`. + fn text_style(&self, style: Style) -> Style { + row_text_style(self.selected, style, self.config) + } } impl StatefulWidget for DeviceWidget<'_> { type State = Vec; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + // See node_widget::NodeWidget::render - same whole-row background + // fill so the Configuration tab gets the same selected-row + // highlight as the other tabs, covering blank space too rather + // than just the text glyphs. + if self.selected { + buf.set_style(area, self.config.theme.row_selected); + } + let mouse_areas = state; mouse_areas.push(( @@ -134,7 +149,10 @@ impl StatefulWidget for DeviceWidget<'_> { Line::from(vec![ Span::from(" "), - Span::styled(&self.device.title, self.config.theme.config_device), + Span::styled( + &self.device.title, + self.text_style(self.config.theme.config_device), + ), ]) .render(title_area, buf); @@ -147,7 +165,7 @@ impl StatefulWidget for DeviceWidget<'_> { Span::from(" "), Span::styled( &self.device.target_title, - self.config.theme.config_profile, + self.text_style(self.config.theme.config_profile), ), ]) .render(target_area, buf); diff --git a/src/node_widget.rs b/src/node_widget.rs index ed3f8cd..a1920f2 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}, }; @@ -27,6 +28,25 @@ fn is_default(node: &view::Node, device_kind: Option) -> bool { } } +/// Patches `row_unselected` on top of `style` whenever `selected` is +/// false. Unlike `row_selected` (a whole-row background fill applied +/// unconditionally by the row's own top-level widget), this only ever +/// touches text spans, so it's applied per-span rather than as a single +/// area fill. Shared by every per-row widget that renders text spans +/// (`HeaderWidget`, `VolumeWidget`, `device_widget::DeviceWidget`) +/// instead of each carrying its own copy of the same two-line branch. +pub(crate) fn row_text_style( + selected: bool, + style: Style, + config: &Config, +) -> Style { + if selected { + style + } else { + style.patch(config.theme.row_unselected) + } +} + pub struct NodeWidget<'a> { config: &'a Config, device_kind: Option, @@ -93,6 +113,18 @@ impl StatefulWidget for NodeWidget<'_> { type State = Vec; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + // Fill the whole row's background first (not just under the text) + // when selected. ratatui's Cell::set_style only overwrites fg/bg + // when the incoming style has Some(...) for that field - unstyled + // spans (node_title etc. default to `{ }`) leave this fill alone, + // while spans that set their own color explicitly (meter_active, + // volume_filled...) still override it for their own glyphs. So a + // single fill here covers blank padding/gaps that per-span styling + // could never reach, while every other color stays meaningful. + if self.selected { + buf.set_style(area, self.config.theme.row_selected); + } + let mouse_areas = state; mouse_areas.extend([ @@ -156,14 +188,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.selected, + ) + .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.selected); if self.config.peaks == Peaks::Off { let layout = Layout::default() .direction(Direction::Horizontal) @@ -240,6 +274,7 @@ struct HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, + selected: bool, } impl<'a> HeaderWidget<'a> { @@ -247,14 +282,21 @@ impl<'a> HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, + selected: bool, ) -> Self { Self { config, device_kind, node, + selected, } } + /// See `row_text_style`. + fn text_style(&self, style: Style) -> Style { + row_text_style(self.selected, style, self.config) + } + fn target_line(&self) -> Line<'_> { match self.node.target { Some(view::Target::Default) => { @@ -267,13 +309,13 @@ impl<'a> HeaderWidget<'a> { Span::from(" "), Span::styled( &self.node.target_title, - self.config.theme.node_target, + self.text_style(self.config.theme.node_target), ), ]) } _ => Line::from(Span::styled( &self.node.target_title, - self.config.theme.node_target, + self.text_style(self.config.theme.node_target), )), } } @@ -290,7 +332,10 @@ impl<'a> HeaderWidget<'a> { Line::from(vec![ default_span, Span::from(" "), - Span::styled(&self.node.title, self.config.theme.node_title), + Span::styled( + &self.node.title, + self.text_style(self.config.theme.node_title), + ), ]) } } @@ -337,7 +382,7 @@ impl StatefulWidget for HeaderWidget<'_> { let ellipses_area = layout[1]; target_area = layout[3]; - Span::styled("...", self.config.theme.node_title) + Span::styled("...", self.text_style(self.config.theme.node_title)) .render(ellipses_area, buf); } let (title_area, target_area) = (title_area, target_area); @@ -362,11 +407,21 @@ impl StatefulWidget for HeaderWidget<'_> { struct VolumeWidget<'a> { config: &'a Config, node: &'a view::Node, + selected: 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, selected: bool) -> Self { + Self { + config, + node, + selected, + } + } + + /// See `row_text_style`. + fn text_style(&self, style: Style) -> Style { + row_text_style(self.selected, style, self.config) } } @@ -397,7 +452,7 @@ impl StatefulWidget for VolumeWidget<'_> { Line::from(Span::styled( format!("{percent}%"), - self.config.theme.volume, + self.text_style(self.config.theme.volume), )) .alignment(Alignment::Right) .render(volume_label, buf); @@ -419,7 +474,11 @@ impl StatefulWidget for VolumeWidget<'_> { .render(volume_bar, buf); } if self.node.mute { - Line::from("muted").render(volume_label, buf); + Line::from(Span::styled( + "muted", + self.text_style(Style::default()), + )) + .render(volume_label, buf); } mouse_areas.push(( diff --git a/src/object_list.rs b/src/object_list.rs index c43947c..2805d3d 100644 --- a/src/object_list.rs +++ b/src/object_list.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; use ratatui::{ prelude::{Alignment, Buffer, Constraint, Direction, Layout, Rect}, + style::Style, text::{Line, Span}, widgets::{ListState, StatefulWidget, Widget}, }; @@ -318,11 +319,94 @@ pub struct ObjectListWidget<'a, 'b> { } struct ObjectListRenderContext<'a> { + header_area: Rect, list_area: Rect, + footer_area: Rect, objects_layout: &'a [Rect], objects_visible: usize, } +/// Extends a selected row's `row_selected` background one row above +/// and/or one row below it (independently, per `row_selected_extend_above`/ +/// `row_selected_extend_below`), so the highlight doesn't cut off abruptly +/// right at the row's own edges. Clipped to `above_clip`/`below_clip` +/// respectively so it can never bleed into a neighboring item. +/// +/// For most objects both clip areas are just `list_area`, but the very +/// first/last object in the whole list has no list row of its own to +/// extend into on that side - the row directly above the first object (or +/// below the last) is `header_area`/`footer_area`, a different `Rect` +/// reserved for the scroll indicator. Callers pass a clip area that +/// includes the header/footer specifically for that edge object, but only +/// when the corresponding scroll indicator isn't being drawn there (see +/// the call sites in `render_node_list`/`render_device_list`) - otherwise +/// the highlight would paint over the "more items" indicator. +/// +/// Also extends the selector marker (the left gutter column) to match, +/// reusing the `selector_middle` glyph for the extra row - but only when +/// `row_selected` is actually customized away from the default empty +/// `{ }`. Drawing extra marker glyphs is real cell content, not a style +/// patch, so unlike the background fill (where an empty Style is +/// naturally a no-op via `Cell::set_style`'s `Some(..)`-only overwrite), +/// it can't rely on being inert by default - it needs its own explicit +/// check so the marker's height stays exactly what it's always been for +/// every theme that doesn't opt into `row_selected`. +/// +/// Both `row_selected_extend_above`/`_below` default to `false`, so this +/// whole function is a no-op - no size or height change to the marker or +/// background - unless a config explicitly turns one or both on. +fn extend_selected_row( + buf: &mut Buffer, + config: &Config, + object_area: Rect, + above_clip: Rect, + below_clip: Rect, + spacing: u16, +) { + let max_extend = spacing.min(1); + + let extend_one_side = |buf: &mut Buffer, y: u16, clip: Rect| { + let extend_area = Rect { + x: object_area.x, + y, + width: object_area.width, + height: max_extend, + }; + let clipped = clip.intersection(extend_area); + buf.set_style(clipped, config.theme.row_selected); + + if config.theme.row_selected == Style::default() { + return; + } + + let marker_area = Rect { + width: 1, + ..extend_area + }; + Line::from(Span::styled( + &config.char_set.selector_middle, + config.theme.selector, + )) + .render(clip.intersection(marker_area), buf); + }; + + if config.row_selected_extend_above { + extend_one_side( + buf, + object_area.y.saturating_sub(max_extend), + above_clip, + ); + } + + if config.row_selected_extend_below { + extend_one_side( + buf, + object_area.y.saturating_add(object_area.height), + below_clip, + ); + } +} + impl ObjectListWidget<'_, '_> { fn render_node_list( &mut self, @@ -333,6 +417,7 @@ impl ObjectListWidget<'_, '_> { mouse_areas: &mut Vec, ) { let all_objects = self.view.full_nodes(node_kind); + let total_objects = all_objects.len(); let objects = all_objects .iter() .skip(self.object_list.top) @@ -342,7 +427,8 @@ impl ObjectListWidget<'_, '_> { let objects_and_areas: Vec<(&&view::Node, &Rect)> = objects.zip(context.objects_layout.iter()).collect(); - for (object, &object_area) in &objects_and_areas { + for (i, (object, &object_area)) in objects_and_areas.iter().enumerate() + { let selected = self .object_list .selected @@ -355,6 +441,32 @@ impl ObjectListWidget<'_, '_> { selected, ) .render(object_area, buf, mouse_areas); + + if selected { + // No scroll-up indicator competing for header_area when + // this is truly the first object in the list (not just + // the first one currently visible) - safe to extend into + // it. Same idea for footer_area/the last object below. + let above_clip = if i == 0 && self.object_list.top == 0 { + context.header_area.union(context.list_area) + } else { + context.list_area + }; + let below_clip = + if self.object_list.top + i + 1 == total_objects { + context.list_area.union(context.footer_area) + } else { + context.list_area + }; + extend_selected_row( + buf, + self.config, + object_area, + above_clip, + below_clip, + NodeWidget::spacing(), + ); + } } // Show the target dropdown? @@ -390,6 +502,7 @@ impl ObjectListWidget<'_, '_> { mouse_areas: &mut Vec, ) { let all_objects = self.view.full_devices(); + let total_objects = all_objects.len(); let objects = all_objects .iter() .skip(self.object_list.top) @@ -399,7 +512,8 @@ impl ObjectListWidget<'_, '_> { let objects_and_areas: Vec<(&&view::Device, &Rect)> = objects.zip(context.objects_layout.iter()).collect(); - for (object, &object_area) in &objects_and_areas { + for (i, (object, &object_area)) in objects_and_areas.iter().enumerate() + { let selected = self .object_list .selected @@ -410,6 +524,29 @@ impl ObjectListWidget<'_, '_> { buf, mouse_areas, ); + + if selected { + // See the matching comment in render_node_list(). + let above_clip = if i == 0 && self.object_list.top == 0 { + context.header_area.union(context.list_area) + } else { + context.list_area + }; + let below_clip = + if self.object_list.top + i + 1 == total_objects { + context.list_area.union(context.footer_area) + } else { + context.list_area + }; + extend_selected_row( + buf, + self.config, + object_area, + above_clip, + below_clip, + DeviceWidget::spacing(), + ); + } } // Show the target dropdown? @@ -533,7 +670,9 @@ impl StatefulWidget for &mut ObjectListWidget<'_, '_> { self.render_node_list( node_kind, ObjectListRenderContext { + header_area, list_area, + footer_area, objects_layout: &objects_layout, objects_visible, }, @@ -545,7 +684,9 @@ impl StatefulWidget for &mut ObjectListWidget<'_, '_> { ListKind::Device => { self.render_device_list( ObjectListRenderContext { + header_area, list_area, + footer_area, objects_layout: &objects_layout, objects_visible, }, @@ -1130,4 +1271,83 @@ mod tests { assert!(visible.contains(&stream_id)); assert!(visible.contains(&source_id)); } + + #[test] + fn extend_selected_row_above_reaches_into_widened_clip() { + let config = config::Config::from_toml_str( + "row_selected_extend_above = true\n\ + [themes.default]\n\ + row_selected = { bg = \"Blue\" }", + ); + let object_area = Rect::new(0, 1, 20, 3); + // The row directly above object_area (y = 0) - out of bounds for + // list_area (which starts at object_area's own top edge, as it + // does for the first visible object), but in bounds once widened + // to include header_area, matching what render_node_list()/ + // render_device_list() pass for the true first object in the list. + let list_area = Rect::new(0, 1, 20, 10); + let widened = Rect::new(0, 0, 20, 11); + let blank = Buffer::empty(Rect::new(0, 0, 20, 11)); + + let mut buf = blank.clone(); + extend_selected_row( + &mut buf, + &config, + object_area, + list_area, + list_area, + NodeWidget::spacing(), + ); + assert_eq!(buf[(0, 0)].style(), blank[(0, 0)].style()); + + let mut buf = blank.clone(); + extend_selected_row( + &mut buf, + &config, + object_area, + widened, + list_area, + NodeWidget::spacing(), + ); + assert_ne!(buf[(0, 0)].style(), blank[(0, 0)].style()); + } + + #[test] + fn extend_selected_row_below_reaches_into_widened_clip() { + let config = config::Config::from_toml_str( + "row_selected_extend_below = true\n\ + [themes.default]\n\ + row_selected = { bg = \"Blue\" }", + ); + let object_area = Rect::new(0, 0, 20, 3); + // The row directly below object_area (y = 3) - out of bounds for + // a list_area that ends exactly at object_area's bottom edge, as + // it does for the true last object in the list, but in bounds once + // widened to include footer_area. + let list_area = Rect::new(0, 0, 20, 3); + let widened = Rect::new(0, 0, 20, 4); + let blank = Buffer::empty(Rect::new(0, 0, 20, 4)); + + let mut buf = blank.clone(); + extend_selected_row( + &mut buf, + &config, + object_area, + list_area, + list_area, + NodeWidget::spacing(), + ); + assert_eq!(buf[(0, 3)].style(), blank[(0, 3)].style()); + + let mut buf = blank.clone(); + extend_selected_row( + &mut buf, + &config, + object_area, + list_area, + widened, + NodeWidget::spacing(), + ); + assert_ne!(buf[(0, 3)].style(), blank[(0, 3)].style()); + } } diff --git a/wiremix.toml b/wiremix.toml index 218653b..1cd4967 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -29,6 +29,14 @@ char_set = "default" # Theme to use (see Themes section) theme = "default" +# If true, the selected row's background (and, if the current theme sets +# row_selected, its selector marker) extends one row above and/or below +# the row itself, into the gap between items. No effect unless the +# theme's row_selected is also set to something other than the default +# empty style. +row_selected_extend_above = false +row_selected_extend_below = false + # Initial tab tab = "playback" @@ -336,6 +344,26 @@ meter_center_active = { fg = "LightGreen" } config_device = { } # The name of the selected profile in the Configuration tab config_profile = { } +# Fills the selected row's whole background (in the Playback/Recording/ +# Output/Input Devices and Configuration tabs), not just its text - see +# row_selected_extend_above/_below above for extending this into the gap +# above/below the row too. Empty ({ }) by default, i.e. no extra +# highlight beyond the selector column. +row_selected = { } +# Patched on top of an unselected row's own text styles (node_title, +# node_target, volume, the "muted" label, config_device, config_profile). +# Those styles are normally the same regardless of selection - this is +# the only way to make non-selected rows look different from selected +# ones by text color, complementing row_selected's own highlight (e.g. +# dim everything else so the selected row stands out by contrast from +# both directions, not just because it's brighter). Only whichever of +# fg/bg/add_modifier you set here override the base style, so it layers +# on top rather than replacing it. Unlike row_selected, this only +# affects text, not the row's full background - useful for a faint tint +# that distinguishes item rows from the gaps between them without +# competing with the selected row's highlight. Empty ({ }) by default, +# i.e. no change from an item's normal look. +row_unselected = { } # Dropdown marker next to the profiles in the Configuration tab dropdown_icon = { } # Border around dropdowns @@ -452,6 +480,8 @@ meter_center_inactive = { add_modifier = "DIM" } meter_center_active = { add_modifier = "BOLD" } config_device = { } config_profile = { } +row_selected = { } +row_unselected = { } dropdown_icon = { } dropdown_border = { } dropdown_item = { } @@ -481,6 +511,8 @@ meter_center_inactive = { } meter_center_active = { } config_device = { } config_profile = { } +row_selected = { } +row_unselected = { } dropdown_icon = { } dropdown_border = { } dropdown_item = { }