Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/wiki/Configuration:-Layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ layout {
// off
on
hide-when-single-tab
scroll-to-switch-tabs
place-within-column
gap 5
width 4
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -478,6 +481,7 @@ layout {
length total-proportion=1.0
position "top"
place-within-column
scroll-to-switch-tabs
}
}
```
Expand Down
1 change: 1 addition & 0 deletions docs/wiki/Tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions niri-config/src/appearance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ impl MergeWith<WorkspaceShadowPart> 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,
Expand All @@ -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.,
Expand Down Expand Up @@ -508,6 +510,7 @@ impl MergeWith<TabIndicatorPart> for TabIndicator {
merge!(
(self, part),
hide_when_single_tab,
scroll_to_switch_tabs,
place_within_column,
gap,
width,
Expand Down Expand Up @@ -535,6 +538,8 @@ pub struct TabIndicatorPart {
#[knuffel(child)]
pub hide_when_single_tab: Option<Flag>,
#[knuffel(child)]
pub scroll_to_switch_tabs: Option<Flag>,
#[knuffel(child)]
pub place_within_column: Option<Flag>,
#[knuffel(child, unwrap(argument))]
pub gap: Option<FloatOrInt<-65535, 65535>>,
Expand Down
2 changes: 2 additions & 0 deletions niri-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,7 @@ mod tests {
tab-indicator {
width 10
position "top"
scroll-to-switch-tabs
}

preset-column-widths {
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions resources/default-config.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 57 additions & 1 deletion src/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1511,10 +1511,10 @@ impl<W: LayoutElement> Layout<W> {
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;
}
}

Expand All @@ -1524,7 +1524,7 @@ impl<W: LayoutElement> Layout<W> {
..
} = &mut self.monitor_set
else {
return;
return false;
};

for (monitor_idx, mon) in monitors.iter_mut().enumerate() {
Expand All @@ -1541,10 +1541,12 @@ impl<W: LayoutElement> Layout<W> {
_ => mon.switch_workspace(workspace_idx),
}

return;
return true;
}
}
}

false
}

pub fn activate_window_without_raising(&mut self, window: &W::Id) {
Expand Down Expand Up @@ -2332,6 +2334,24 @@ impl<W: LayoutElement> Layout<W> {
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<f64, Logical>,
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,
Expand Down
13 changes: 13 additions & 0 deletions src/layout/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1575,6 +1575,19 @@ impl<W: LayoutElement> Monitor<W> {
}
}

pub fn tab_under_offset(
&self,
pos_within_output: Point<f64, Logical>,
offset: i32,
) -> Option<W::Id> {
// 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<f64, Logical>) -> Option<ResizeEdge> {
if self.overview_progress.is_some() {
return None;
Expand Down
41 changes: 41 additions & 0 deletions src/layout/scrolling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2964,6 +2964,47 @@ impl<W: LayoutElement> ScrollingSpace<W> {
}
}

/// 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<f64, Logical>, offset: i32) -> Option<W::Id> {
// 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<f64, Logical>) -> Option<(&W, HitType)> {
// This matches self.tiles_with_render_positions().
let scale = self.scale;
Expand Down
6 changes: 5 additions & 1 deletion src/layout/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -3856,6 +3858,7 @@ prop_compose! {
fn arbitrary_tab_indicator()(
off in any::<bool>(),
hide_when_single_tab in prop::option::of(any::<bool>().prop_map(Flag)),
scroll_to_switch_tabs in prop::option::of(any::<bool>().prop_map(Flag)),
place_within_column in prop::option::of(any::<bool>().prop_map(Flag)),
width in prop::option::of(arbitrary_spacing().prop_map(FloatOrInt)),
gap in prop::option::of(arbitrary_spacing_neg().prop_map(FloatOrInt)),
Expand All @@ -3867,6 +3870,7 @@ prop_compose! {
off,
on: !off,
hide_when_single_tab,
scroll_to_switch_tabs,
place_within_column,
width,
gap,
Expand Down
4 changes: 4 additions & 0 deletions src/layout/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1768,6 +1768,10 @@ impl<W: LayoutElement> Workspace<W> {
self.scrolling.window_under(pos)
}

pub fn tab_under_offset(&self, pos: Point<f64, Logical>, offset: i32) -> Option<W::Id> {
self.scrolling.tab_under_offset(pos, offset)
}

pub fn resize_edges_under(&self, pos: Point<f64, Logical>) -> Option<ResizeEdge> {
self.tiles_with_render_positions()
.find_map(|(tile, tile_pos, visible)| {
Expand Down
2 changes: 2 additions & 0 deletions src/niri.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Modifiers>,
pub mods_with_wheel_binds: HashSet<Modifiers>,
pub vertical_finger_scroll_tracker: ScrollTracker,
Expand Down Expand Up @@ -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,

Expand Down