diff --git a/docs/wiki/Configuration:-Window-Rules.md b/docs/wiki/Configuration:-Window-Rules.md
index 5c7dd28e65..23fd9a1158 100644
--- a/docs/wiki/Configuration:-Window-Rules.md
+++ b/docs/wiki/Configuration:-Window-Rules.md
@@ -37,6 +37,7 @@ window-rule {
match is-window-cast-target=true
match is-urgent=true
match at-startup=true
+ match on-output="HDMI-A-1"
// Properties that apply once upon window opening.
default-column-width { proportion 0.75; }
@@ -318,6 +319,45 @@ window-rule {
}
```
+#### `on-output`
+
+Since: next
+
+Matches windows based on the output (monitor) they will open on or are currently on.
+
+The value is a string that matches by connector name (e.g. `"DP-1"`, `"HDMI-A-1"`) or by make/model/serial (e.g. `"Goldstar LG ULTRAWIDE 123456789"`), same as in `output` configuration blocks.
+
+For example, you can open Firefox maximized on a laptop display but at 50% width on an external monitor.
+
+```kdl
+// On a large monitor, open Firefox at 50% width.
+window-rule {
+ match app-id="firefox" on-output="HDMI-A-1"
+ default-column-width { proportion 0.5; }
+}
+
+// On a laptop display, open Firefox maximized.
+window-rule {
+ match app-id="firefox"
+ exclude on-output="HDMI-A-1"
+ open-maximized true
+}
+```
+
+> [!NOTE]
+> When a window first opens, `on-output` matches against the output where the window *will* open (determined by `open-on-output`, parent window, or the focused monitor).
+> Properties like `open-on-output` and `open-on-workspace` are evaluated *before* `on-output` matching, so they are not affected by `on-output`.
+
+> [!NOTE]
+> Using `open-on-output` or `open-on-workspace` in a rule with an `on-output` matcher is not allowed, since it would create a circular dependency.
+
+```kdl,must-fail
+window-rule {
+ match on-output="DP-1"
+ open-on-output "HDMI-A-1"
+}
+```
+
### Window Opening Properties
These properties apply once, when a window first opens.
diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs
index b61fe1c1a0..409b7358f4 100644
--- a/niri-config/src/lib.rs
+++ b/niri-config/src/lib.rs
@@ -207,7 +207,28 @@ where
}
"spawn-at-startup" => m_push!(spawn_at_startup),
"spawn-sh-at-startup" => m_push!(spawn_sh_at_startup),
- "window-rule" => m_push!(window_rules),
+ "window-rule" => {
+ let part: WindowRule = knuffel::Decode::decode_node(node, ctx)?;
+ // open-on-output and open-on-workspace conflict with on-output matchers.
+ let has_on_output = part.matches.iter().any(|m| m.on_output.is_some())
+ || part.excludes.iter().any(|m| m.on_output.is_some());
+ if has_on_output {
+ for child in node.children() {
+ let child_name = &**child.node_name;
+ if matches!(child_name, "open-on-output" | "open-on-workspace") {
+ ctx.emit_error(DecodeError::unexpected(
+ child,
+ "node",
+ format!(
+ "node `{child_name}` is not allowed in a window \
+ rule with `on-output`"
+ ),
+ ));
+ }
+ }
+ }
+ config.borrow_mut().window_rules.push(part);
+ }
"layer-rule" => m_push!(layer_rules),
"workspace" => m_push!(workspaces),
diff --git a/niri-config/src/window_rule.rs b/niri-config/src/window_rule.rs
index 0465d28fad..5df0492815 100644
--- a/niri-config/src/window_rule.rs
+++ b/niri-config/src/window_rule.rs
@@ -94,6 +94,8 @@ pub struct Match {
pub is_urgent: Option,
#[knuffel(property)]
pub at_startup: Option,
+ #[knuffel(property)]
+ pub on_output: Option,
}
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs
index 8512f85107..8fe9a83959 100644
--- a/src/handlers/xdg_shell.rs
+++ b/src/handlers/xdg_shell.rs
@@ -1,7 +1,7 @@
use std::cell::Cell;
use calloop::Interest;
-use niri_config::PresetSize;
+use niri_config::{OutputName, PresetSize};
use smithay::desktop::{
find_popup_root_surface, get_popup_toplevel_coords, layer_map_for_output, utils, LayerSurface,
PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, PopupUngrabStrategy, Window,
@@ -1029,38 +1029,50 @@ impl State {
pub fn send_initial_configure(&mut self, toplevel: &ToplevelSurface) {
let _span = tracy_client::span!("State::send_initial_configure");
- let Some(unmapped) = self.niri.unmapped_windows.get_mut(toplevel.wl_surface()) else {
- error!("window must be present in unmapped_windows in send_initial_configure()");
- return;
- };
-
let config = self.niri.config.borrow();
- let rules = ResolvedWindowRules::compute(
- &config.window_rules,
- WindowRef::Unmapped(unmapped),
- self.niri.is_at_startup,
- );
- let Unmapped { window, state, .. } = unmapped;
+ // Compute rules without output context first, to get open-on-output /
+ // open-on-workspace redirects.
+ let (preliminary_rules, wants_fullscreen, wants_maximized) = {
+ let Some(unmapped) = self.niri.unmapped_windows.get_mut(toplevel.wl_surface()) else {
+ error!("window must be present in unmapped_windows in send_initial_configure()");
+ return;
+ };
- let InitialConfigureState::NotConfigured {
- wants_fullscreen,
- wants_maximized,
- } = state
- else {
- error!("window must not be already configured in send_initial_configure()");
- return;
+ let preliminary_rules = ResolvedWindowRules::compute(
+ &config.window_rules,
+ WindowRef::Unmapped(unmapped),
+ self.niri.is_at_startup,
+ None,
+ );
+
+ let Unmapped { state, .. } = unmapped;
+
+ let InitialConfigureState::NotConfigured {
+ wants_fullscreen,
+ wants_maximized,
+ } = state
+ else {
+ error!("window must not be already configured in send_initial_configure()");
+ return;
+ };
+
+ (
+ preliminary_rules,
+ wants_fullscreen.clone(),
+ *wants_maximized,
+ )
};
// Pick the target monitor. First, check if we had a workspace set in the window rules.
- let mon = rules
+ let mon = preliminary_rules
.open_on_workspace
.as_deref()
.and_then(|name| self.niri.layout.monitor_for_workspace(name));
// If not, check if we had an output set in the window rules.
let mon = mon.or_else(|| {
- rules
+ preliminary_rules
.open_on_output
.as_deref()
.and_then(|name| {
@@ -1106,6 +1118,24 @@ impl State {
.map(|(mon, _)| mon.output().clone());
let mon = mon.map(|(mon, _)| mon);
+ // Recompute rules with the target output for on-output matching.
+ let output_name = mon
+ .map(|m| m.output())
+ .and_then(|o| o.user_data().get::().cloned());
+
+ let unmapped = self
+ .niri
+ .unmapped_windows
+ .get_mut(toplevel.wl_surface())
+ .unwrap();
+ let rules = ResolvedWindowRules::compute(
+ &config.window_rules,
+ WindowRef::Unmapped(unmapped),
+ self.niri.is_at_startup,
+ output_name.as_ref(),
+ );
+ let Unmapped { window, state, .. } = unmapped;
+
let mut width = None;
let mut floating_width = None;
let mut height = None;
@@ -1126,7 +1156,7 @@ impl State {
let mut is_pending_maximized = false;
if let Some(ws) = ws {
// Set a fullscreen and maximized state based on window request and window rule.
- is_pending_maximized = (*wants_maximized && rules.open_maximized_to_edges.is_none())
+ is_pending_maximized = (wants_maximized && rules.open_maximized_to_edges.is_none())
|| rules.open_maximized_to_edges == Some(true);
if (wants_fullscreen.is_some() && rules.open_fullscreen.is_none())
@@ -1371,10 +1401,17 @@ impl State {
let window_rules = &config.window_rules;
if let Some(unmapped) = self.niri.unmapped_windows.get_mut(toplevel.wl_surface()) {
+ let output_name = match &unmapped.state {
+ InitialConfigureState::Configured { output, .. } => output
+ .as_ref()
+ .and_then(|o| o.user_data().get::().cloned()),
+ _ => None,
+ };
let new_rules = ResolvedWindowRules::compute(
window_rules,
WindowRef::Unmapped(unmapped),
self.niri.is_at_startup,
+ output_name.as_ref(),
);
if let InitialConfigureState::Configured { rules, .. } = &mut unmapped.state {
*rules = new_rules;
@@ -1384,7 +1421,14 @@ impl State {
.layout
.find_window_and_output_mut(toplevel.wl_surface())
{
- if mapped.recompute_window_rules(window_rules, self.niri.is_at_startup) {
+ let output_name = output
+ .as_ref()
+ .and_then(|o| o.user_data().get::().cloned());
+ if mapped.recompute_window_rules(
+ window_rules,
+ self.niri.is_at_startup,
+ output_name.as_ref(),
+ ) {
drop(config);
let output = output.cloned();
let window = mapped.window.clone();
diff --git a/src/niri.rs b/src/niri.rs
index 414f702d80..8beff223ee 100644
--- a/src/niri.rs
+++ b/src/niri.rs
@@ -3946,7 +3946,12 @@ impl Niri {
let mut windows = vec![];
let mut outputs = HashSet::new();
self.layout.with_windows_mut(|mapped, output| {
- if mapped.recompute_window_rules_if_needed(window_rules, self.is_at_startup) {
+ let output_name = output.and_then(|o| o.user_data().get::().cloned());
+ if mapped.recompute_window_rules_if_needed(
+ window_rules,
+ self.is_at_startup,
+ output_name.as_ref(),
+ ) {
windows.push(mapped.window.clone());
if let Some(output) = output {
@@ -5959,10 +5964,17 @@ impl Niri {
let window_rules = &self.config.borrow().window_rules;
for unmapped in self.unmapped_windows.values_mut() {
+ let output_name = match &unmapped.state {
+ InitialConfigureState::Configured { output, .. } => output
+ .as_ref()
+ .and_then(|o| o.user_data().get::().cloned()),
+ _ => None,
+ };
let new_rules = ResolvedWindowRules::compute(
window_rules,
WindowRef::Unmapped(unmapped),
self.is_at_startup,
+ output_name.as_ref(),
);
if let InitialConfigureState::Configured { rules, .. } = &mut unmapped.state {
*rules = new_rules;
@@ -5970,8 +5982,13 @@ impl Niri {
}
let mut windows = vec![];
- self.layout.with_windows_mut(|mapped, _| {
- if mapped.recompute_window_rules(window_rules, self.is_at_startup) {
+ self.layout.with_windows_mut(|mapped, output| {
+ let output_name = output.and_then(|o| o.user_data().get::().cloned());
+ if mapped.recompute_window_rules(
+ window_rules,
+ self.is_at_startup,
+ output_name.as_ref(),
+ ) {
windows.push(mapped.window.clone());
}
});
diff --git a/src/window/mapped.rs b/src/window/mapped.rs
index e951e3324d..7c6b6f93b5 100644
--- a/src/window/mapped.rs
+++ b/src/window/mapped.rs
@@ -1,7 +1,7 @@
use std::cell::{Cell, Ref, RefCell};
use std::time::Duration;
-use niri_config::{Color, CornerRadius, GradientInterpolation, WindowRule};
+use niri_config::{Color, CornerRadius, GradientInterpolation, OutputName, WindowRule};
use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement;
use smithay::backend::renderer::element::Kind;
use smithay::backend::renderer::gles::GlesRenderer;
@@ -298,10 +298,16 @@ impl Mapped {
}
/// Recomputes the resolved window rules and returns whether they changed.
- pub fn recompute_window_rules(&mut self, rules: &[WindowRule], is_at_startup: bool) -> bool {
+ pub fn recompute_window_rules(
+ &mut self,
+ rules: &[WindowRule],
+ is_at_startup: bool,
+ output: Option<&OutputName>,
+ ) -> bool {
self.need_to_recompute_rules = false;
- let new_rules = ResolvedWindowRules::compute(rules, WindowRef::Mapped(self), is_at_startup);
+ let new_rules =
+ ResolvedWindowRules::compute(rules, WindowRef::Mapped(self), is_at_startup, output);
if new_rules == self.rules {
return false;
}
@@ -320,12 +326,13 @@ impl Mapped {
&mut self,
rules: &[WindowRule],
is_at_startup: bool,
+ output: Option<&OutputName>,
) -> bool {
if !self.need_to_recompute_rules {
return false;
}
- self.recompute_window_rules(rules, is_at_startup)
+ self.recompute_window_rules(rules, is_at_startup, output)
}
pub fn set_needs_configure(&mut self) {
diff --git a/src/window/mod.rs b/src/window/mod.rs
index c8c358f156..3485b36006 100644
--- a/src/window/mod.rs
+++ b/src/window/mod.rs
@@ -3,7 +3,7 @@ use std::cmp::{max, min};
use niri_config::utils::MergeWith as _;
use niri_config::window_rule::{Match, WindowRule};
use niri_config::{
- BlockOutFrom, BorderRule, CornerRadius, FloatingPosition, PresetSize, ShadowRule,
+ BlockOutFrom, BorderRule, CornerRadius, FloatingPosition, OutputName, PresetSize, ShadowRule,
TabIndicatorRule,
};
use niri_ipc::ColumnDisplay;
@@ -174,7 +174,12 @@ impl<'a> WindowRef<'a> {
}
impl ResolvedWindowRules {
- pub fn compute(rules: &[WindowRule], window: WindowRef, is_at_startup: bool) -> Self {
+ pub fn compute(
+ rules: &[WindowRule],
+ window: WindowRef,
+ is_at_startup: bool,
+ output: Option<&OutputName>,
+ ) -> Self {
let _span = tracy_client::span!("ResolvedWindowRules::compute");
let mut resolved = ResolvedWindowRules::default();
@@ -196,7 +201,7 @@ impl ResolvedWindowRules {
}
}
- window_matches(window, role, m)
+ window_matches(window, role, m, output)
};
if !(rule.matches.is_empty() || rule.matches.iter().any(matches)) {
@@ -371,7 +376,12 @@ impl ResolvedWindowRules {
}
}
-fn window_matches(window: WindowRef, role: &XdgToplevelSurfaceRoleAttributes, m: &Match) -> bool {
+fn window_matches(
+ window: WindowRef,
+ role: &XdgToplevelSurfaceRoleAttributes,
+ m: &Match,
+ output: Option<&OutputName>,
+) -> bool {
// Must be ensured by the caller.
let server_pending = role.server_pending.as_ref().unwrap();
@@ -433,5 +443,14 @@ fn window_matches(window: WindowRef, role: &XdgToplevelSurfaceRoleAttributes, m:
}
}
+ if let Some(on_output) = &m.on_output {
+ let Some(name) = output else {
+ return false;
+ };
+ if !name.matches(on_output) {
+ return false;
+ }
+ }
+
true
}