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 }