aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--niri-config/Cargo.toml1
-rw-r--r--niri-config/src/lib.rs50
-rw-r--r--niri-visual-tests/src/cases/layout.rs5
-rw-r--r--resources/default-config.kdl43
-rw-r--r--src/handlers/compositor.rs32
-rw-r--r--src/handlers/xdg_shell.rs116
-rw-r--r--src/layout/mod.rs71
-rw-r--r--src/layout/monitor.rs4
-rw-r--r--src/layout/workspace.rs21
10 files changed, 320 insertions, 24 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 442148c3..ea082dbd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2130,6 +2130,7 @@ dependencies = [
"knuffel",
"miette",
"niri-ipc",
+ "regex",
"smithay",
"tracing",
"tracy-client",
diff --git a/niri-config/Cargo.toml b/niri-config/Cargo.toml
index 8bff3a30..067ce416 100644
--- a/niri-config/Cargo.toml
+++ b/niri-config/Cargo.toml
@@ -12,6 +12,7 @@ bitflags.workspace = true
knuffel = "3.2.0"
miette = "5.10.0"
niri-ipc = { version = "0.1.1", path = "../niri-ipc" }
+regex = "1.10.3"
smithay = { workspace = true, features = ["backend_libinput"] }
tracing.workspace = true
tracy-client.workspace = true
diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs
index 025675bf..564b0b9f 100644
--- a/niri-config/src/lib.rs
+++ b/niri-config/src/lib.rs
@@ -7,6 +7,7 @@ use std::str::FromStr;
use bitflags::bitflags;
use miette::{miette, Context, IntoDiagnostic, NarratableReportHandler};
use niri_ipc::{LayoutSwitchTarget, SizeChange};
+use regex::Regex;
use smithay::input::keyboard::keysyms::KEY_NoSymbol;
use smithay::input::keyboard::xkb::{keysym_from_name, KEYSYM_CASE_INSENSITIVE};
use smithay::input::keyboard::{Keysym, XkbConfig};
@@ -38,6 +39,8 @@ pub struct Config {
pub hotkey_overlay: HotkeyOverlay,
#[knuffel(child, default)]
pub animations: Animations,
+ #[knuffel(children(name = "window-rule"))]
+ pub window_rules: Vec<WindowRule>,
#[knuffel(child, default)]
pub binds: Binds,
#[knuffel(child, default)]
@@ -524,6 +527,34 @@ pub enum AnimationCurve {
EaseOutExpo,
}
+#[derive(knuffel::Decode, Debug, Default, Clone, PartialEq)]
+pub struct WindowRule {
+ #[knuffel(children(name = "match"))]
+ pub matches: Vec<Match>,
+ #[knuffel(children(name = "exclude"))]
+ pub excludes: Vec<Match>,
+
+ #[knuffel(child)]
+ pub default_column_width: Option<DefaultColumnWidth>,
+ #[knuffel(child, unwrap(argument))]
+ pub open_on_output: Option<String>,
+}
+
+#[derive(knuffel::Decode, Debug, Default, Clone)]
+pub struct Match {
+ #[knuffel(property, str)]
+ pub app_id: Option<Regex>,
+ #[knuffel(property, str)]
+ pub title: Option<Regex>,
+}
+
+impl PartialEq for Match {
+ fn eq(&self, other: &Self) -> bool {
+ self.app_id.as_ref().map(Regex::as_str) == other.app_id.as_ref().map(Regex::as_str)
+ && self.title.as_ref().map(Regex::as_str) == other.title.as_ref().map(Regex::as_str)
+ }
+}
+
#[derive(knuffel::Decode, Debug, Default, PartialEq)]
pub struct Binds(#[knuffel(children)] pub Vec<Bind>);
@@ -965,6 +996,13 @@ mod tests {
}
}
+ window-rule {
+ match app-id=".*alacritty"
+ exclude title="~"
+
+ open-on-output "eDP-1"
+ }
+
binds {
Mod+T { spawn "alacritty"; }
Mod+Q { close-window; }
@@ -1098,6 +1136,18 @@ mod tests {
},
..Default::default()
},
+ window_rules: vec![WindowRule {
+ matches: vec![Match {
+ app_id: Some(Regex::new(".*alacritty").unwrap()),
+ title: None,
+ }],
+ excludes: vec![Match {
+ app_id: None,
+ title: Some(Regex::new("~").unwrap()),
+ }],
+ open_on_output: Some("eDP-1".to_owned()),
+ ..Default::default()
+ }],
binds: Binds(vec![
Bind {
key: Key {
diff --git a/niri-visual-tests/src/cases/layout.rs b/niri-visual-tests/src/cases/layout.rs
index 0bb5f332..dd9e29ec 100644
--- a/niri-visual-tests/src/cases/layout.rs
+++ b/niri-visual-tests/src/cases/layout.rs
@@ -143,7 +143,8 @@ impl Layout {
}
fn add_window(&mut self, window: TestWindow, width: Option<ColumnWidth>) {
- self.layout.add_window(window.clone(), width, false);
+ self.layout
+ .add_window(window.clone(), width.map(Some), false);
if window.communicate() {
self.layout.update_window(&window);
}
@@ -157,7 +158,7 @@ impl Layout {
width: Option<ColumnWidth>,
) {
self.layout
- .add_window_right_of(right_of, window.clone(), width, false);
+ .add_window_right_of(right_of, window.clone(), width.map(Some), false);
if window.communicate() {
self.layout.update_window(&window);
}
diff --git a/resources/default-config.kdl b/resources/default-config.kdl
index 2e4e1530..525f9aa3 100644
--- a/resources/default-config.kdl
+++ b/resources/default-config.kdl
@@ -248,6 +248,49 @@ animations {
}
}
+// Window rules let you adjust behavior for individual windows.
+// They are processed in order of appearance in this file.
+// (This example rule is commented out with a "/-" in front.)
+/-window-rule {
+ // Match directives control which windows this rule will apply to.
+ // You can match by app-id and by title.
+ // The window must match all properties of the match directive.
+ match app-id="org.myapp.MyApp" title="My Cool App"
+
+ // There can be multiple match directives. A window must match any one
+ // of the rule's match directives.
+ //
+ // If there are no match directives, any window will match the rule.
+ match title="Second App"
+
+ // You can also add exclude directives which have the same properties.
+ // If a window matches any exclude directive, it won't match this rule.
+ //
+ // Both app-id and title are regular expressions.
+ // Raw KDL strings are helpful here.
+ exclude app-id=r#"\.unwanted\."#
+
+ // Here are the properties that you can set on a window rule.
+ // You can override the default column width.
+ default-column-width { proportion 0.75; }
+
+ // You can set the output that this window will initially open on.
+ // If such an output does not exist, it will open on the currently
+ // focused output as usual.
+ open-on-output "eDP-1"
+}
+
+// Here's a useful example. Work around WezTerm's initial configure bug
+// by setting an empty default-column-width.
+window-rule {
+ // This regular expression is intentially made as specific as possile,
+ // since this is the default config, and we want no false positives.
+ // You can get away with just app-id="wezterm" if you want.
+ // The regular expression can match anywhere in the string.
+ match app-id=r#"^org\.wezfurlong\.wezterm$"#
+ default-column-width {}
+}
+
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 de92d17c..2d6a6406 100644
--- a/src/handlers/compositor.rs
+++ b/src/handlers/compositor.rs
@@ -16,6 +16,7 @@ use smithay::wayland::dmabuf::get_dmabuf;
use smithay::wayland::shm::{ShmHandler, ShmState};
use smithay::{delegate_compositor, delegate_shm};
+use super::xdg_shell::{initial_configure_sent, resolve_window_rules};
use crate::niri::{ClientState, State};
use crate::utils::clone2;
@@ -113,13 +114,28 @@ impl CompositorHandler for State {
.and_then(|parent| self.niri.layout.find_window_and_output(&parent))
.map(|(win, _)| win.clone());
+ let (width, output) = {
+ let config = self.niri.config.borrow();
+ let rules = resolve_window_rules(&config.window_rules, window.toplevel());
+ let output = rules
+ .open_on_output
+ .and_then(|name| self.niri.output_by_name.get(name))
+ .cloned();
+ (rules.default_width, output)
+ };
+
let win = window.clone();
// Open dialogs immediately to the right of their parent window.
let output = if let Some(p) = parent {
- self.niri.layout.add_window_right_of(&p, win, None, false)
+ self.niri.layout.add_window_right_of(&p, win, width, false)
+ } else if let Some(output) = &output {
+ self.niri
+ .layout
+ .add_window_on_output(output, win, width, false);
+ Some(output)
} else {
- self.niri.layout.add_window(win, None, false)
+ self.niri.layout.add_window(win, width, false)
};
if let Some(output) = output.cloned() {
@@ -131,7 +147,17 @@ impl CompositorHandler for State {
// The toplevel remains unmapped.
let window = entry.get().clone();
- self.send_initial_configure_if_needed(&window);
+
+ // Send the initial configure in an idle, in case the client sent some more info
+ // after the initial commit.
+ if !initial_configure_sent(window.toplevel()) {
+ self.niri.event_loop.insert_idle(move |state| {
+ if !window.toplevel().alive() {
+ return;
+ }
+ state.send_initial_configure_if_needed(&window);
+ });
+ }
return;
}
diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs
index 397bdbdc..89cc6869 100644
--- a/src/handlers/xdg_shell.rs
+++ b/src/handlers/xdg_shell.rs
@@ -1,3 +1,4 @@
+use niri_config::{Match, WindowRule};
use smithay::desktop::{
find_popup_root_surface, get_popup_toplevel_coords, layer_map_for_output, LayerSurface,
PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, PopupUngrabStrategy, Window,
@@ -19,13 +20,91 @@ use smithay::wayland::shell::wlr_layer::Layer;
use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
use smithay::wayland::shell::xdg::{
PopupSurface, PositionerState, ToplevelSurface, XdgPopupSurfaceData, XdgShellHandler,
- XdgShellState, XdgToplevelSurfaceData,
+ XdgShellState, XdgToplevelSurfaceData, XdgToplevelSurfaceRoleAttributes,
};
use smithay::{delegate_kde_decoration, delegate_xdg_decoration, delegate_xdg_shell};
+use crate::layout::workspace::ColumnWidth;
use crate::niri::{PopupGrabState, State};
use crate::utils::clone2;
+#[derive(Debug, Default)]
+pub struct ResolvedWindowRule<'a> {
+ /// Default width for this window.
+ ///
+ /// - `None`: unset.
+ /// - `Some(None)`: set to empty.
+ /// - `Some(Some(width))`: set to a particular width.
+ pub default_width: Option<Option<ColumnWidth>>,
+
+ /// Output to open this window on.
+ pub open_on_output: Option<&'a str>,
+}
+
+fn window_matches(role: &XdgToplevelSurfaceRoleAttributes, m: &Match) -> bool {
+ if let Some(app_id_re) = &m.app_id {
+ let Some(app_id) = &role.app_id else {
+ return false;
+ };
+ if !app_id_re.is_match(app_id) {
+ return false;
+ }
+ }
+
+ if let Some(title_re) = &m.title {
+ let Some(title) = &role.title else {
+ return false;
+ };
+ if !title_re.is_match(title) {
+ return false;
+ }
+ }
+
+ true
+}
+
+pub fn resolve_window_rules<'a>(
+ rules: &'a [WindowRule],
+ toplevel: &ToplevelSurface,
+) -> ResolvedWindowRule<'a> {
+ let _span = tracy_client::span!("resolve_window_rules");
+
+ let mut resolved = ResolvedWindowRule::default();
+
+ with_states(toplevel.wl_surface(), |states| {
+ let role = states
+ .data_map
+ .get::<XdgToplevelSurfaceData>()
+ .unwrap()
+ .lock()
+ .unwrap();
+
+ for rule in rules {
+ if !(rule.matches.is_empty() || rule.matches.iter().any(|m| window_matches(&role, m))) {
+ continue;
+ }
+
+ if rule.excludes.iter().any(|m| window_matches(&role, m)) {
+ continue;
+ }
+
+ if let Some(x) = rule
+ .default_column_width
+ .as_ref()
+ .map(|d| d.0.first().copied().map(ColumnWidth::from))
+ {
+ resolved.default_width = Some(x);
+ }
+
+ if let Some(x) = rule.open_on_output.as_deref() {
+ resolved.open_on_output = Some(x);
+ }
+ }
+ });
+
+ resolved
+}
+
impl XdgShellHandler for State {
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
&mut self.niri.xdg_shell_state
@@ -237,9 +316,20 @@ impl XdgShellHandler for State {
let window = window.clone();
self.niri.layout.set_fullscreen(&window, false);
} else if let Some(window) = self.niri.unmapped_windows.get(surface.wl_surface()) {
- if let Some(ws) = self.niri.layout.active_workspace() {
+ let config = self.niri.config.borrow();
+ let rules = resolve_window_rules(&config.window_rules, window.toplevel());
+
+ let output = rules
+ .open_on_output
+ .and_then(|name| self.niri.output_by_name.get(name));
+ let mon = output.map(|o| self.niri.layout.monitor_for_output(o).unwrap());
+ let ws = mon
+ .map(|mon| mon.active_workspace_ref())
+ .or_else(|| self.niri.layout.active_workspace());
+
+ if let Some(ws) = ws {
window.toplevel().with_pending_state(|state| {
- state.size = Some(ws.new_window_size());
+ state.size = Some(ws.new_window_size(rules.default_width));
state.states.unset(xdg_toplevel::State::Fullscreen);
});
}
@@ -333,7 +423,7 @@ impl KdeDecorationHandler for State {
delegate_kde_decoration!(State);
-fn initial_configure_sent(toplevel: &ToplevelSurface) -> bool {
+pub fn initial_configure_sent(toplevel: &ToplevelSurface) -> bool {
with_states(toplevel.wl_surface(), |states| {
states
.data_map
@@ -352,14 +442,26 @@ impl State {
return;
}
+ let _span = tracy_client::span!("State::send_initial_configure_if_needed");
+
+ let config = self.niri.config.borrow();
+ let rules = resolve_window_rules(&config.window_rules, toplevel);
+
+ let output = rules
+ .open_on_output
+ .and_then(|name| self.niri.output_by_name.get(name));
+ let mon = output.map(|o| self.niri.layout.monitor_for_output(o).unwrap());
+ let ws = mon
+ .map(|mon| mon.active_workspace_ref())
+ .or_else(|| self.niri.layout.active_workspace());
+
// Tell the surface the preferred size and bounds for its likely output.
- if let Some(ws) = self.niri.layout.active_workspace() {
- ws.configure_new_window(window);
+ if let Some(ws) = ws {
+ ws.configure_new_window(window, rules.default_width);
}
// If the user prefers no CSD, it's a reasonable assumption that they would prefer to get
// rid of the various client-side rounded corners also by using the tiled state.
- let config = self.niri.config.borrow();
if config.prefer_no_csd {
toplevel.with_pending_state(|state| {
state.states.set(xdg_toplevel::State::TiledLeft);
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index dfc7ee0a..947b8cea 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -531,12 +531,15 @@ impl<W: LayoutElement> Layout<W> {
pub fn add_window(
&mut self,
window: W,
- width: Option<ColumnWidth>,
+ width: Option<Option<ColumnWidth>>,
is_full_width: bool,
) -> Option<&Output> {
- let width = width
- .or(self.options.default_width)
- .unwrap_or_else(|| ColumnWidth::Fixed(window.size().w));
+ let width = match width {
+ Some(Some(width)) => Some(width),
+ Some(None) => None,
+ None => self.options.default_width,
+ }
+ .unwrap_or_else(|| ColumnWidth::Fixed(window.size().w));
match &mut self.monitor_set {
MonitorSet::Normal {
@@ -584,12 +587,15 @@ impl<W: LayoutElement> Layout<W> {
&mut self,
right_of: &W,
window: W,
- width: Option<ColumnWidth>,
+ width: Option<Option<ColumnWidth>>,
is_full_width: bool,
) -> Option<&Output> {
- let width = width
- .or(self.options.default_width)
- .unwrap_or_else(|| ColumnWidth::Fixed(window.size().w));
+ let width = match width {
+ Some(Some(width)) => Some(width),
+ Some(None) => None,
+ None => self.options.default_width,
+ }
+ .unwrap_or_else(|| ColumnWidth::Fixed(window.size().w));
match &mut self.monitor_set {
MonitorSet::Normal { monitors, .. } => {
@@ -612,6 +618,55 @@ impl<W: LayoutElement> Layout<W> {
}
}
+ /// Adds a new window to the layout on a specific output.
+ pub fn add_window_on_output(
+ &mut self,
+ output: &Output,
+ window: W,
+ width: Option<Option<ColumnWidth>>,
+ is_full_width: bool,
+ ) {
+ let width = match width {
+ Some(Some(width)) => Some(width),
+ Some(None) => None,
+ None => self.options.default_width,
+ }
+ .unwrap_or_else(|| ColumnWidth::Fixed(window.size().w));
+
+ let MonitorSet::Normal {
+ monitors,
+ active_monitor_idx,
+ ..
+ } = &mut self.monitor_set
+ else {
+ panic!()
+ };
+
+ let (mon_idx, mon) = monitors
+ .iter_mut()
+ .enumerate()
+ .find(|(_, mon)| mon.output == *output)
+ .unwrap();
+
+ // Don't steal focus from an active fullscreen window.
+ let mut activate = true;
+ let ws = &mon.workspaces[mon.active_workspace_idx];
+ if mon_idx == *active_monitor_idx
+ && !ws.columns.is_empty()
+ && ws.columns[ws.active_column_idx].is_fullscreen
+ {
+ activate = false;
+ }
+
+ mon.add_window(
+ mon.active_workspace_idx,
+ window,
+ activate,
+ width,
+ is_full_width,
+ );
+ }
+
pub fn remove_window(&mut self, window: &W) {
match &mut self.monitor_set {
MonitorSet::Normal { monitors, .. } => {
diff --git a/src/layout/monitor.rs b/src/layout/monitor.rs
index cb99279a..ff63ed4f 100644
--- a/src/layout/monitor.rs
+++ b/src/layout/monitor.rs
@@ -76,6 +76,10 @@ impl<W: LayoutElement> Monitor<W> {
}
}
+ pub fn active_workspace_ref(&self) -> &Workspace<W> {
+ &self.workspaces[self.active_workspace_idx]
+ }
+
pub fn active_workspace(&mut self) -> &mut Workspace<W> {
&mut self.workspaces[self.active_workspace_idx]
}
diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs
index 64bc4bc7..7c9b68fc 100644
--- a/src/layout/workspace.rs
+++ b/src/layout/workspace.rs
@@ -321,8 +321,17 @@ impl<W: LayoutElement> Workspace<W> {
))
}
- pub fn new_window_size(&self) -> Size<i32, Logical> {
- let width = if let Some(width) = self.options.default_width {
+ pub fn new_window_size(
+ &self,
+ default_width: Option<Option<ColumnWidth>>,
+ ) -> Size<i32, Logical> {
+ let default_width = match default_width {
+ Some(Some(width)) => Some(width),
+ Some(None) => None,
+ None => self.options.default_width,
+ };
+
+ let width = if let Some(width) = default_width {
let mut width = width.resolve(&self.options, self.working_area.size.w);
if !self.options.border.off {
width -= self.options.border.width as i32 * 2;
@@ -340,8 +349,12 @@ impl<W: LayoutElement> Workspace<W> {
Size::from((width, max(height, 1)))
}
- pub fn configure_new_window(&self, window: &Window) {
- let size = self.new_window_size();
+ pub fn configure_new_window(
+ &self,
+ window: &Window,
+ default_width: Option<Option<ColumnWidth>>,
+ ) {
+ let size = self.new_window_size(default_width);
let bounds = self.toplevel_bounds();
if let Some(output) = self.output.as_ref() {