Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/wiki/Configuration:-Window-Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -318,6 +319,45 @@ window-rule {
}
```

#### `on-output`

<sup>Since: next</sup>

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.
Expand Down
23 changes: 22 additions & 1 deletion niri-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
2 changes: 2 additions & 0 deletions niri-config/src/window_rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ pub struct Match {
pub is_urgent: Option<bool>,
#[knuffel(property)]
pub at_startup: Option<bool>,
#[knuffel(property)]
pub on_output: Option<String>,
}

#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
Expand Down
90 changes: 67 additions & 23 deletions src/handlers/xdg_shell.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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::<OutputName>().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;
Expand All @@ -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())
Expand Down Expand Up @@ -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::<OutputName>().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;
Expand All @@ -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::<OutputName>().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();
Expand Down
23 changes: 20 additions & 3 deletions src/niri.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<OutputName>().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 {
Expand Down Expand Up @@ -5959,19 +5964,31 @@ 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::<OutputName>().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;
}
}

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::<OutputName>().cloned());
if mapped.recompute_window_rules(
window_rules,
self.is_at_startup,
output_name.as_ref(),
) {
windows.push(mapped.window.clone());
}
});
Expand Down
15 changes: 11 additions & 4 deletions src/window/mapped.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down
27 changes: 23 additions & 4 deletions src/window/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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)) {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
}