diff --git a/docs/wiki/Configuration:-Window-Rules.md b/docs/wiki/Configuration:-Window-Rules.md
index 5c7dd28e65..639822e0a7 100644
--- a/docs/wiki/Configuration:-Window-Rules.md
+++ b/docs/wiki/Configuration:-Window-Rules.md
@@ -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
@@ -511,6 +512,36 @@ window-rule {
}
```
+#### `open-consume-into-column`
+
+Since: unreleased
+
+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.
diff --git a/niri-config/src/window_rule.rs b/niri-config/src/window_rule.rs
index 0465d28fad..90e9e27493 100644
--- a/niri-config/src/window_rule.rs
+++ b/niri-config/src/window_rule.rs
@@ -31,6 +31,8 @@ pub struct WindowRule {
pub open_floating: Option,
#[knuffel(child, unwrap(argument))]
pub open_focused: Option,
+ #[knuffel(child, unwrap(argument, str))]
+ pub open_consume_into_column: Option,
// Rules applied dynamically.
#[knuffel(child, unwrap(argument))]
@@ -96,6 +98,28 @@ pub struct Match {
pub at_startup: Option,
}
+/// 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 {
+ 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)]
diff --git a/resources/default-config.kdl b/resources/default-config.kdl
index 3fa09d56b1..990dacf667 100644
--- a/resources/default-config.kdl
+++ b/resources/default-config.kdl
@@ -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
diff --git a/src/handlers/compositor.rs b/src/handlers/compositor.rs
index daf342296f..99ea0ad195 100644
--- a/src/handlers/compositor.rs
+++ b/src/handlers/compositor.rs
@@ -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);
@@ -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
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 010fe65b7c..fd2d0aa3a1 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -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};
@@ -1066,6 +1067,15 @@ impl Layout {
}
}
+ 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,
diff --git a/src/layout/scrolling.rs b/src/layout/scrolling.rs
index 69d0ed62a7..0bfe7429f5 100644
--- a/src/layout/scrolling.rs
+++ b/src/layout/scrolling.rs
@@ -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;
@@ -1990,6 +1991,73 @@ impl ScrollingSpace {
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| {
+ 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;
diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs
index 0df4662096..7869a899f5 100644
--- a/src/layout/workspace.rs
+++ b/src/layout/workspace.rs
@@ -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;
@@ -1120,6 +1121,14 @@ impl Workspace {
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;
diff --git a/src/window/mod.rs b/src/window/mod.rs
index c8c358f156..839acf4b4c 100644
--- a/src/window/mod.rs
+++ b/src/window/mod.rs
@@ -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,
@@ -73,6 +73,9 @@ pub struct ResolvedWindowRules {
/// Whether the window should open focused.
pub open_focused: Option,
+ /// Strategy for auto-consuming this window into an existing column, if any.
+ pub open_consume_into_column: Option,
+
/// Extra bound on the minimum window width.
pub min_width: Option,
/// Extra bound on the minimum window height.
@@ -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);
}