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
31 changes: 31 additions & 0 deletions docs/wiki/Configuration:-Window-Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ window-rule {
open-fullscreen true
open-floating true
open-focused false
open-consume-into-column true

// Properties that apply continuously.
draw-border-with-background false
Expand Down Expand Up @@ -511,6 +512,36 @@ window-rule {
}
```

#### `open-consume-into-column`

<sup>Since: unreleased</sup>

Automatically consume this window into an existing column containing another window with the same rule. Only applies to tiled windows; floating windows are unaffected.

Possible values:

- `"active"` — prefer the most recently active matching column, falling back to the first.
- `"first"` — always consume into the first (leftmost) matching column.

```kdl
// Automatically stack foot terminals as tabs in the same column.
window-rule {
match app-id="^foot$"
default-column-display "tabbed"
open-consume-into-column "active"
}
```

You can also combine windows from different apps into the same column:

```kdl
window-rule {
match app-id="^foot$"
match app-id="^librewolf$"
open-consume-into-column "active"
}
```

### Dynamic Properties

These properties apply continuously to open windows.
Expand Down
24 changes: 24 additions & 0 deletions niri-config/src/window_rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub struct WindowRule {
pub open_floating: Option<bool>,
#[knuffel(child, unwrap(argument))]
pub open_focused: Option<bool>,
#[knuffel(child, unwrap(argument, str))]
pub open_consume_into_column: Option<ConsumeIntoColumn>,

// Rules applied dynamically.
#[knuffel(child, unwrap(argument))]
Expand Down Expand Up @@ -96,6 +98,28 @@ pub struct Match {
pub at_startup: Option<bool>,
}

/// Strategy for selecting which existing column to consume a new window into.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ConsumeIntoColumn {
/// Consume into the first (leftmost) matching column.
#[default]
First,
/// Prefer the column that was most recently active, falling back to the first matching column.
Active,
}

impl std::str::FromStr for ConsumeIntoColumn {
type Err = &'static str;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"first" => Ok(Self::First),
"active" => Ok(Self::Active),
_ => Err(r#"invalid value, expected "first" or "active""#),
}
}
}

#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
pub struct FloatingPosition {
#[knuffel(property)]
Expand Down
8 changes: 8 additions & 0 deletions resources/default-config.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,14 @@ window-rule {
clip-to-geometry true
}

// Example: auto-consume foot terminals into the same column as tabs.
// (This example rule is commented out with a "/-" in front.)
/-window-rule {
match app-id="^foot$"
default-column-display "tabbed"
open-consume-into-column "active"
}

binds {
// Keys consist of modifiers separated by + signs, followed by an XKB key name
// in the end. To find an XKB name for a particular key, you may use a program
Expand Down
8 changes: 8 additions & 0 deletions src/handlers/compositor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ impl CompositorHandler for State {
})
.map(|(mapped, _)| mapped.window.clone());

let consume_strategy =
(!is_floating).then(|| rules.open_consume_into_column).flatten();

// The mapped pre-commit hook deals with dma-bufs on its own.
self.remove_default_dmabuf_pre_commit_hook(surface);
let hook = add_mapped_toplevel_pre_commit_hook(toplevel);
Expand Down Expand Up @@ -219,6 +222,11 @@ impl CompositorHandler for State {
);
let output = output.cloned();

// Try to auto-consume the window into an existing matching column if configured.
if let Some(strategy) = consume_strategy {
self.niri.layout.auto_consume_window(&window, strategy);
}

// The window state cannot contain Fullscreen and Maximized at once. Therefore,
// if the window ended up fullscreen, then we only know that it is also
// maximized from the is_pending_maximized variable. Tell the layout about it
Expand Down
10 changes: 10 additions & 0 deletions src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ use monitor::{InsertHint, InsertPosition, InsertWorkspace, MonitorAddWindowTarge
use niri_config::utils::MergeWith as _;
use niri_config::{
Config, CornerRadius, LayoutPart, PresetSize, Workspace as WorkspaceConfig, WorkspaceReference,
window_rule::ConsumeIntoColumn,
};
use niri_ipc::{ColumnDisplay, PositionChange, SizeChange, WindowLayout};
use scrolling::{Column, ColumnWidth};
Expand Down Expand Up @@ -1066,6 +1067,15 @@ impl<W: LayoutElement> Layout<W> {
}
}

pub fn auto_consume_window(&mut self, window_id: &W::Id, strategy: ConsumeIntoColumn) {
for ws in self.workspaces_mut() {
if ws.has_window(window_id) {
ws.auto_consume_window(window_id, strategy);
return;
}
}
}

pub fn remove_window(
&mut self,
window: &W::Id,
Expand Down
68 changes: 68 additions & 0 deletions src/layout/scrolling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::rc::Rc;
use std::time::Duration;

use niri_config::utils::MergeWith as _;
use niri_config::window_rule::ConsumeIntoColumn;
use niri_config::{CenterFocusedColumn, PresetSize, Struts};
use niri_ipc::{ColumnDisplay, SizeChange, WindowLayout};
use ordered_float::NotNan;
Expand Down Expand Up @@ -1990,6 +1991,73 @@ impl<W: LayoutElement> ScrollingSpace<W> {
new_tile.animate_move_from(offset);
}

pub fn auto_consume_window(&mut self, window_id: &W::Id, strategy: ConsumeIntoColumn) {
if self.columns.len() < 2 {
return;
}

// Find the column containing the newly opened window.
let Some(new_col_idx) = self.columns.iter().position(|col| col.contains(window_id)) else {
return;
};

let is_eligible = |col: &Column<W>| {
col.tiles()
.any(|(tile, _)| tile.window().rules().open_consume_into_column.is_some())
};

// Find the first matching column, skipping the new window's own column.
let find_first = || {
self.columns
.iter()
.enumerate()
.position(|(idx, col)| idx != new_col_idx && is_eligible(col))
};

// Find the target column based on the configured strategy.
let target_col_idx = match strategy {
// Active: prefer the column to the left of the new window (the previously active
// column in the common case), falling back to the first matching column.
ConsumeIntoColumn::Active => new_col_idx
.checked_sub(1)
.filter(|&idx| is_eligible(&self.columns[idx]))
.or_else(find_first),
// First: leftmost matching column.
ConsumeIntoColumn::First => find_first(),
};
let Some(target_col_idx) = target_col_idx else {
return;
};

// Calculate animation offset before removing the tile.
let tile_idx = self.columns[new_col_idx].position(window_id).unwrap();
let offset = self.column_x(new_col_idx)
+ self.columns[new_col_idx].render_offset().x
- self.column_x(target_col_idx);
let mut offset = Point::from((offset, 0.));
let prev_off = self.columns[new_col_idx].tile_offset(tile_idx);

// Remove the new window's tile from its column.
let removed = self.remove_tile(window_id, Transaction::new());

// If the new column was to the left of the target, removing it shifts the target index
// down by one.
let adjusted_target_idx = if new_col_idx < target_col_idx {
target_col_idx - 1
} else {
target_col_idx
};

self.add_tile_to_column(adjusted_target_idx, None, removed.tile, true);

let target_column = &mut self.columns[adjusted_target_idx];
offset += prev_off - target_column.tile_offset(target_column.tiles.len() - 1);
offset.x -= target_column.render_offset().x;

let new_tile = target_column.tiles.last_mut().unwrap();
new_tile.animate_move_from(offset);
}

pub fn expel_from_column(&mut self) {
if self.columns.is_empty() {
return;
Expand Down
9 changes: 9 additions & 0 deletions src/layout/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::time::Duration;
use niri_config::utils::MergeWith as _;
use niri_config::{
CenterFocusedColumn, CornerRadius, OutputName, PresetSize, Workspace as WorkspaceConfig,
window_rule::ConsumeIntoColumn,
};
use niri_ipc::{ColumnDisplay, PositionChange, SizeChange, WindowLayout};
use smithay::backend::renderer::element::Kind;
Expand Down Expand Up @@ -1120,6 +1121,14 @@ impl<W: LayoutElement> Workspace<W> {
self.scrolling.consume_into_column();
}

pub fn auto_consume_window(&mut self, window_id: &W::Id, strategy: ConsumeIntoColumn) {
// Skip floating windows; auto-consume only applies to tiled layout.
if self.floating.has_window(window_id) {
return;
}
self.scrolling.auto_consume_window(window_id, strategy);
}

pub fn expel_from_column(&mut self) {
if self.floating_is_active.get() {
return;
Expand Down
9 changes: 8 additions & 1 deletion src/window/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::cmp::{max, min};

use niri_config::utils::MergeWith as _;
use niri_config::window_rule::{Match, WindowRule};
use niri_config::window_rule::{ConsumeIntoColumn, Match, WindowRule};
use niri_config::{
BlockOutFrom, BorderRule, CornerRadius, FloatingPosition, PresetSize, ShadowRule,
TabIndicatorRule,
Expand Down Expand Up @@ -73,6 +73,9 @@ pub struct ResolvedWindowRules {
/// Whether the window should open focused.
pub open_focused: Option<bool>,

/// Strategy for auto-consuming this window into an existing column, if any.
pub open_consume_into_column: Option<ConsumeIntoColumn>,

/// Extra bound on the minimum window width.
pub min_width: Option<u16>,
/// Extra bound on the minimum window height.
Expand Down Expand Up @@ -251,6 +254,10 @@ impl ResolvedWindowRules {
resolved.open_focused = Some(x);
}

if let Some(x) = rule.open_consume_into_column {
resolved.open_consume_into_column = Some(x);
}

if let Some(x) = rule.min_width {
resolved.min_width = Some(x);
}
Expand Down