diff --git a/docs/wiki/Configuration:-Layout.md b/docs/wiki/Configuration:-Layout.md index 5e5e2a5c6c..dc0c3551d6 100644 --- a/docs/wiki/Configuration:-Layout.md +++ b/docs/wiki/Configuration:-Layout.md @@ -66,6 +66,7 @@ layout { // off on hide-when-single-tab + scroll-to-switch-tabs place-within-column gap 5 width 4 @@ -439,6 +440,8 @@ Set `off` to hide the tab indicator. Set `hide-when-single-tab` to hide the indicator for tabbed columns that only have a single window. +Set `scroll-to-switch-tabs` to switch tabs with by scrolling the mouse-wheel over the tab indicator. + Set `place-within-column` to put the tab indicator "within" the column, rather than outside. This will include it in column sizing and avoid overlaying adjacent columns. @@ -478,6 +481,7 @@ layout { length total-proportion=1.0 position "top" place-within-column + scroll-to-switch-tabs } } ``` diff --git a/docs/wiki/Tabs.md b/docs/wiki/Tabs.md index 1ef438b791..1d0532a546 100644 --- a/docs/wiki/Tabs.md +++ b/docs/wiki/Tabs.md @@ -23,6 +23,7 @@ Unlike regular columns, tabbed columns can go full-screen with multiple windows. Tabbed columns show a tab indicator on the side. You can click on the indicator to switch tabs. +You can also enable `scroll-to-switch-tabs` to switch tabs by scrolling over the indicator. See the [`tab-indicator` section in the layout section](./Configuration:-Layout.md#tab-indicator) to configure it. diff --git a/niri-config/src/appearance.rs b/niri-config/src/appearance.rs index a1c8327149..049ad97e7f 100644 --- a/niri-config/src/appearance.rs +++ b/niri-config/src/appearance.rs @@ -459,6 +459,7 @@ impl MergeWith for WorkspaceShadow { pub struct TabIndicator { pub off: bool, pub hide_when_single_tab: bool, + pub scroll_to_switch_tabs: bool, pub place_within_column: bool, pub gap: f64, pub width: f64, @@ -479,6 +480,7 @@ impl Default for TabIndicator { Self { off: false, hide_when_single_tab: false, + scroll_to_switch_tabs: false, place_within_column: false, gap: 5., width: 4., @@ -508,6 +510,7 @@ impl MergeWith for TabIndicator { merge!( (self, part), hide_when_single_tab, + scroll_to_switch_tabs, place_within_column, gap, width, @@ -535,6 +538,8 @@ pub struct TabIndicatorPart { #[knuffel(child)] pub hide_when_single_tab: Option, #[knuffel(child)] + pub scroll_to_switch_tabs: Option, + #[knuffel(child)] pub place_within_column: Option, #[knuffel(child, unwrap(argument))] pub gap: Option>, diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 329b76696d..8956c92e4f 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -783,6 +783,7 @@ mod tests { tab-indicator { width 10 position "top" + scroll-to-switch-tabs } preset-column-widths { @@ -1364,6 +1365,7 @@ mod tests { tab_indicator: TabIndicator { off: false, hide_when_single_tab: false, + scroll_to_switch_tabs: true, place_within_column: false, gap: 5.0, width: 10.0, diff --git a/resources/default-config.kdl b/resources/default-config.kdl index ccad1ac22e..256dbf69f4 100644 --- a/resources/default-config.kdl +++ b/resources/default-config.kdl @@ -142,6 +142,12 @@ layout { // If you leave the brackets empty, the windows themselves will decide their initial width. // default-column-width {} + // You can configure the widths, colors, and behavior of the tab indicator. + tab-indicator { + // Uncomment this line to make the mouse wheel switch tabs when hovering over the tab indicator. + // scroll-to-switch-tabs + } + // By default focus ring and border are rendered as a solid background rectangle // behind windows. That is, they will show up through semitransparent windows. // This is because windows using client-side decorations can have an arbitrary shape. diff --git a/src/input/mod.rs b/src/input/mod.rs index f1ad49320f..4c8cd55e20 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -48,7 +48,7 @@ use self::spatial_movement_grab::SpatialMovementGrab; #[cfg(feature = "dbus")] use crate::dbus::freedesktop_a11y::KbMonBlock; use crate::layout::scrolling::ScrollDirection; -use crate::layout::{ActivateWindow, LayoutElement as _}; +use crate::layout::{ActivateWindow, HitType, LayoutElement as _}; use crate::niri::{CastTarget, PointerVisibility, State}; use crate::ui::mru::{WindowMru, WindowMruUi}; use crate::ui::screenshot_ui::ScreenshotUi; @@ -3097,6 +3097,62 @@ impl State { let is_mru_open = self.niri.window_mru_ui.is_open(); + // Scroll over tab indicator to switch tabs in that column. + // Gated on config flag layout.tab-indicator.scroll-to-switch-tabs (default off). + // Does not intercept wheel scrolls in overview or MRU UI. + let scroll_to_switch_tabs = self + .niri + .config + .borrow() + .layout + .tab_indicator + .scroll_to_switch_tabs; + let should_handle_tab_indicator_scroll = scroll_to_switch_tabs + && source == AxisSource::Wheel + && !should_handle_in_overview + && !is_mru_open; + if should_handle_tab_indicator_scroll { + let pos = pointer.current_location(); + let is_over_tab_indicator = + self.niri + .contents_under(pos) + .window + .is_some_and(|(_, hit)| { + matches!( + hit, + HitType::Activate { + is_tab_indicator: true + } + ) + }); + let vertical = vertical_amount_v120.unwrap_or(0.); + if is_over_tab_indicator && vertical != 0. { + let ticks = self.niri.tab_indicator_wheel_tracker.accumulate(vertical); + if ticks != 0 { + if let Some((output, pos_within_output)) = self.niri.output_under(pos) { + let output = output.clone(); + if self.niri.layout.activate_tab_under( + &output, + pos_within_output, + i32::from(ticks), + ) { + self.maybe_warp_cursor_to_focus(); + self.niri.layer_shell_on_demand_focus = None; + self.niri.queue_redraw(&output); + } + } + } + // Don't let leftover state in the regular wheel trackers fire stale binds. + self.niri.horizontal_wheel_tracker.reset(); + self.niri.vertical_wheel_tracker.reset(); + return; + } + } + // If this axis frame was not consumed as vertical tab-indicator scrolling, discard any + // partial accumulation. This covers moving off the indicator, horizontal-only wheel frames, + // and config/overview/MRU changes that temporarily disable the feature path. + self.niri.tab_indicator_wheel_tracker.reset(); + // Handle wheel scroll bindings. if source == AxisSource::Wheel { // If we have a scroll bind with current modifiers, then accumulate and don't pass to diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 5c4dd639e4..53432eeb72 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -1511,10 +1511,10 @@ impl Layout { ws_idx == mon.active_workspace_idx } - pub fn activate_window(&mut self, window: &W::Id) { + pub fn activate_window(&mut self, window: &W::Id) -> bool { if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move { if move_.tile.window().id() == window { - return; + return true; } } @@ -1524,7 +1524,7 @@ impl Layout { .. } = &mut self.monitor_set else { - return; + return false; }; for (monitor_idx, mon) in monitors.iter_mut().enumerate() { @@ -1541,10 +1541,12 @@ impl Layout { _ => mon.switch_workspace(workspace_idx), } - return; + return true; } } } + + false } pub fn activate_window_without_raising(&mut self, window: &W::Id) { @@ -2332,6 +2334,24 @@ impl Layout { mon.window_under(pos_within_output) } + /// If pos_within_output is over a tabbed column's tab indicator, activates the window + /// that is offset tabs away from that column's currently active tile (clamped, no wrap). + /// Returns true if the active tab changed. + pub fn activate_tab_under( + &mut self, + output: &Output, + pos_within_output: Point, + offset: i32, + ) -> bool { + let Some(mon) = self.monitor_for_output(output) else { + return false; + }; + let Some(window_id) = mon.tab_under_offset(pos_within_output, offset) else { + return false; + }; + self.activate_window(&window_id) + } + pub fn resize_edges_under( &self, output: &Output, diff --git a/src/layout/monitor.rs b/src/layout/monitor.rs index 53c0606988..2a92436870 100644 --- a/src/layout/monitor.rs +++ b/src/layout/monitor.rs @@ -1575,6 +1575,19 @@ impl Monitor { } } + pub fn tab_under_offset( + &self, + pos_within_output: Point, + offset: i32, + ) -> Option { + // Not supported in overview + if self.overview_progress.is_some() { + return None; + } + let (ws, geo) = self.workspace_under(pos_within_output)?; + ws.tab_under_offset(pos_within_output - geo.loc, offset) + } + pub fn resize_edges_under(&self, pos_within_output: Point) -> Option { if self.overview_progress.is_some() { return None; diff --git a/src/layout/scrolling.rs b/src/layout/scrolling.rs index 007bcaf5a8..b215bc1b36 100644 --- a/src/layout/scrolling.rs +++ b/src/layout/scrolling.rs @@ -2964,6 +2964,47 @@ impl ScrollingSpace { } } + /// If pos is over a tabbed column's tab indicator, returns the id of the tab at the + /// column's current active tile index shifted by offset (clamped to range, no wrap). + /// Returns None if the point is not over a tab indicator, or if the shift is clamped + /// to the same active tile (no change). + pub fn tab_under_offset(&self, pos: Point, offset: i32) -> Option { + // This matches the traversal in window_under. + let scale = self.scale; + let view_off = Point::from((-self.view_pos(), 0.)); + for (col, col_x) in self.columns_in_render_order() { + if col.display_mode != ColumnDisplay::Tabbed || !col.sizing_mode().is_normal() { + continue; + } + + let col_off = Point::from((col_x, 0.)); + let col_render_off = col.render_offset(); + let col_pos = view_off + col_off + col_render_off; + let col_pos = col_pos.to_physical_precise_round(scale).to_logical(scale); + + if col + .tab_indicator + .hit( + col.tab_indicator_area(), + col.tiles.len(), + scale, + pos - col_pos, + ) + .is_none() + { + continue; + } + + let new_idx = (col.active_tile_idx as i64 + i64::from(offset)) + .clamp(0, col.tiles.len() as i64 - 1) as usize; + if new_idx == col.active_tile_idx { + return None; + } + return Some(col.tiles[new_idx].window().id().clone()); + } + None + } + pub fn window_under(&self, pos: Point) -> Option<(&W, HitType)> { // This matches self.tiles_with_render_positions(). let scale = self.scale; diff --git a/src/layout/tests.rs b/src/layout/tests.rs index 31265dd9df..2f84bed1c0 100644 --- a/src/layout/tests.rs +++ b/src/layout/tests.rs @@ -1129,7 +1129,9 @@ impl Op { Op::FocusWindowUpOrColumnRight => layout.focus_up_or_right(), Op::FocusWindowOrWorkspaceDown => layout.focus_window_or_workspace_down(), Op::FocusWindowOrWorkspaceUp => layout.focus_window_or_workspace_up(), - Op::FocusWindow(id) => layout.activate_window(&id), + Op::FocusWindow(id) => { + layout.activate_window(&id); + } Op::FocusWindowInColumn(index) => layout.focus_window_in_column(index), Op::FocusWindowTop => layout.focus_window_top(), Op::FocusWindowBottom => layout.focus_window_bottom(), @@ -3856,6 +3858,7 @@ prop_compose! { fn arbitrary_tab_indicator()( off in any::(), hide_when_single_tab in prop::option::of(any::().prop_map(Flag)), + scroll_to_switch_tabs in prop::option::of(any::().prop_map(Flag)), place_within_column in prop::option::of(any::().prop_map(Flag)), width in prop::option::of(arbitrary_spacing().prop_map(FloatOrInt)), gap in prop::option::of(arbitrary_spacing_neg().prop_map(FloatOrInt)), @@ -3867,6 +3870,7 @@ prop_compose! { off, on: !off, hide_when_single_tab, + scroll_to_switch_tabs, place_within_column, width, gap, diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs index 49548a8337..804dacf1c6 100644 --- a/src/layout/workspace.rs +++ b/src/layout/workspace.rs @@ -1768,6 +1768,10 @@ impl Workspace { self.scrolling.window_under(pos) } + pub fn tab_under_offset(&self, pos: Point, offset: i32) -> Option { + self.scrolling.tab_under_offset(pos, offset) + } + pub fn resize_edges_under(&self, pos: Point) -> Option { self.tiles_with_render_positions() .find_map(|(tile, tile_pos, visible)| { diff --git a/src/niri.rs b/src/niri.rs index ec40e01c14..dfe1822f0c 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -373,6 +373,7 @@ pub struct Niri { pub overview_scroll_swipe_gesture: ScrollSwipeGesture, pub vertical_wheel_tracker: ScrollTracker, pub horizontal_wheel_tracker: ScrollTracker, + pub tab_indicator_wheel_tracker: ScrollTracker, pub mods_with_mouse_binds: HashSet, pub mods_with_wheel_binds: HashSet, pub vertical_finger_scroll_tracker: ScrollTracker, @@ -2584,6 +2585,7 @@ impl Niri { overview_scroll_swipe_gesture: ScrollSwipeGesture::new(), vertical_wheel_tracker: ScrollTracker::new(120), horizontal_wheel_tracker: ScrollTracker::new(120), + tab_indicator_wheel_tracker: ScrollTracker::new(120), mods_with_mouse_binds, mods_with_wheel_binds,