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); }