From 3d0007ba124a222b037176cc23b9277f8d848f98 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 03:02:50 +0100 Subject: [PATCH] feat: apply validated window rules --- README.md | 18 ++ compositor/src/config.rs | 42 ++++- compositor/src/handlers/compositor.rs | 3 + compositor/src/handlers/xdg_shell.rs | 22 +++ compositor/src/main.rs | 3 + compositor/src/state.rs | 91 +++++++++- compositor/src/window_rules.rs | 229 ++++++++++++++++++++++++++ 7 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 compositor/src/window_rules.rs diff --git a/README.md b/README.md index dc9dbda..153c381 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,24 @@ instead, set `match = "physical"` and use an XKB physical key name such as `AD06`. Invalid chords, actions, arguments, duplicate bindings, and missing command references stop startup with an error. +Initial window rules match `app_id`, exact title, and/or transient status. All +matching rules are applied in declaration order; later rules override only the +fields they specify. Width or height implies floating placement when `floating` +is omitted: + +```toml +[[window_rules]] +app_id = "org.example.Editor" +workspace = 3 +floating = true +width = 900.0 +height = 600.0 +fullscreen = false +``` + +Rules are finalized on the first toplevel commit, before its buffer is +presented, and are not re-applied when an application later changes its title. + Text-input-v3 is available to applications by default. Input-method-v2 is hidden unless the session explicitly opts in, because an input method can request privileged keyboard access. Enable it only when launching a trusted diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 740263c..d75148b 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -12,6 +12,8 @@ use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy}; use serde::Deserialize; use smithay::input::keyboard::{Keycode, keysyms, xkb}; +use crate::window_rules::{self, WindowRule, WindowRuleConfig}; + #[derive(Debug, Default, Deserialize)] pub struct Config { #[serde(default)] @@ -25,6 +27,8 @@ pub struct Config { #[serde(default)] bindings: Vec, #[serde(default)] + window_rules: Vec, + #[serde(default)] scrolling: ScrollingConfig, } @@ -324,6 +328,7 @@ pub enum ConfigError { value: String, }, InvalidBinding(String), + InvalidWindowRule(String), } impl fmt::Display for ConfigError { @@ -349,6 +354,7 @@ impl fmt::Display for ConfigError { write!(formatter, "invalid input.{field} value {value}") } Self::InvalidBinding(message) => write!(formatter, "invalid binding: {message}"), + Self::InvalidWindowRule(message) => write!(formatter, "invalid window rule: {message}"), } } } @@ -362,7 +368,8 @@ impl Error for ConfigError { | Self::InvalidAnimationValue { .. } | Self::InvalidLayoutValue { .. } | Self::InvalidInputValue { .. } - | Self::InvalidBinding(_) => None, + | Self::InvalidBinding(_) + | Self::InvalidWindowRule(_) => None, } } } @@ -509,6 +516,10 @@ impl Config { Ok(bindings) } + pub fn window_rules(&self) -> Result, ConfigError> { + window_rules::validate(&self.window_rules).map_err(ConfigError::InvalidWindowRule) + } + pub fn animations_enabled(&self) -> bool { self.animations.enabled && !self.animations.reduced_motion } @@ -1206,6 +1217,35 @@ mod tests { } } + #[test] + fn parses_and_validates_window_rules() { + let config = parse( + "[[window_rules]]\napp_id = \"org.example.Editor\"\nworkspace = 3\nfloating = true\nwidth = 900.0\nheight = 600.0\nfullscreen = false", + ); + let rules = config.window_rules().unwrap(); + let result = window_rules::resolve( + &rules, + Some("org.example.editor.desktop"), + Some("Document"), + false, + ); + + assert_eq!(result.workspace, Some(3)); + assert_eq!(result.floating, Some(true)); + assert_eq!(result.width, Some(900.0)); + assert_eq!(result.height, Some(600.0)); + assert_eq!(result.fullscreen, Some(false)); + } + + #[test] + fn rejects_invalid_window_rule_configuration() { + let catch_all = parse("[[window_rules]]\nfloating = true"); + let zero_workspace = parse("[[window_rules]]\napp_id = \"editor\"\nworkspace = 0"); + + assert!(catch_all.window_rules().is_err()); + assert!(zero_workspace.window_rules().is_err()); + } + #[test] fn derives_critical_viewport_damping() { let config = parse(""); diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index 69e4204..e46a794 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -49,6 +49,9 @@ impl CompositorHandler for Ferese { }; if let Some(window) = window { + if surface == &root { + xdg_shell::apply_initial_window_rules(self, &window); + } window.on_commit(); self.record_client_commit(&window); } diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index e480511..6df90ce 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -351,6 +351,28 @@ pub fn handle_commit(popups: &mut PopupManager, space: &mut Space, surfa } } +pub fn apply_initial_window_rules(state: &mut Ferese, window: &Window) { + let Some(toplevel) = window.toplevel() else { + return; + }; + let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { + let attributes = states + .data_map + .get::() + .expect("xdg toplevel state exists") + .lock() + .expect("xdg toplevel state is not poisoned"); + + ( + attributes.app_id.clone(), + attributes.title.clone(), + attributes.parent.is_some(), + ) + }); + + state.apply_initial_window_rules(window, app_id.as_deref(), title.as_deref(), transient); +} + impl Ferese { pub(crate) fn unconstrain_popup(&self, popup: &PopupSurface) { let Ok(root) = find_popup_root_surface(&PopupKind::Xdg(popup.clone())) else { diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 00a5048..aab9ff6 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -7,6 +7,7 @@ mod grabs; mod handlers; mod input; mod state; +mod window_rules; mod winit; use std::{error::Error, io, process::Command}; @@ -26,6 +27,7 @@ fn main() -> Result<(), Box> { let config = Config::load()?; let input_settings = config.input_settings()?; let bindings = config.bindings(&input_settings)?; + let window_rules = config.window_rules()?; let mut event_loop = EventLoop::try_new()?; let display = Display::new()?; let runtime = RuntimeConfig { @@ -33,6 +35,7 @@ fn main() -> Result<(), Box> { gap_config: config.gap_config()?, input_settings, bindings, + window_rules, default_column_width: config.default_column_width()?, scrolling_focus_strategy: config.scrolling_focus_strategy(), column_width_presets: config.width_presets()?, diff --git a/compositor/src/state.rs b/compositor/src/state.rs index bf8bc12..38a69f9 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -75,7 +75,10 @@ use smithay::{ }, }; -use crate::config::{Binding, InputSettings}; +use crate::{ + config::{Binding, InputSettings}, + window_rules::{WindowRule, resolve as resolve_window_rules}, +}; pub struct Ferese { pub start_time: Instant, @@ -96,6 +99,8 @@ pub struct Ferese { gap_config: GapConfig, pub(crate) input_settings: InputSettings, pub(crate) bindings: Vec, + window_rules: Vec, + window_rules_applied: HashSet, animations_enabled: bool, animation_speed: f64, spring_config: SpringConfig, @@ -146,6 +151,7 @@ pub struct RuntimeConfig { pub gap_config: GapConfig, pub input_settings: InputSettings, pub bindings: Vec, + pub window_rules: Vec, pub default_column_width: ColumnWidth, pub scrolling_focus_strategy: ViewportFocusStrategy, pub column_width_presets: Vec, @@ -386,6 +392,8 @@ impl Ferese { gap_config: config.gap_config, input_settings: config.input_settings, bindings: config.bindings, + window_rules: config.window_rules, + window_rules_applied: HashSet::new(), animations_enabled: config.animations_enabled, animation_speed: config.animation_speed, spring_config: config.spring_config, @@ -634,6 +642,86 @@ impl Ferese { self.relayout(); } + pub(crate) fn apply_initial_window_rules( + &mut self, + window: &Window, + app_id: Option<&str>, + title: Option<&str>, + transient: bool, + ) { + let Some(id) = self.window_ids.get(window).copied() else { + return; + }; + let Some(bounds) = self.output_bounds() else { + return; + }; + if !self.window_rules_applied.insert(id) { + return; + } + + let rule = resolve_window_rules(&self.window_rules, app_id, title, transient); + if rule == Default::default() { + return; + } + let axis = self + .workspaces + .active() + .layout + .automatic_axis(self.focused_window, bounds) + .unwrap_or(Axis::Horizontal); + + if let Some(workspace) = rule.workspace + && let Err(error) = self + .workspaces + .move_window_to_numeric(id, workspace, axis, 0.5) + { + tracing::warn!(%error, ?id, workspace, "failed to apply window workspace rule"); + } + + let should_float = rule + .floating + .or((rule.width.is_some() || rule.height.is_some()).then_some(true)); + if let Some(should_float) = should_float { + let is_floating = matches!( + self.workspaces.placement(id), + Some(WindowPlacement::Floating { .. }) + ); + let mut rect = centered_floating_rect(bounds); + rect.width = rule.width.unwrap_or(rect.width).min(bounds.width); + rect.height = rule.height.unwrap_or(rect.height).min(bounds.height); + rect.x = bounds.x + (bounds.width - rect.width) / 2.0; + rect.y = bounds.y + (bounds.height - rect.height) / 2.0; + + let result = if should_float != is_floating { + self.workspaces + .toggle_floating(id, rect, axis, 0.5) + .map(|_| ()) + } else if should_float && (rule.width.is_some() || rule.height.is_some()) { + self.workspaces.set_floating_rect(id, rect) + } else { + Ok(()) + }; + if let Err(error) = result { + tracing::warn!(%error, ?id, "failed to apply floating window rule"); + } + } + + if let Some(fullscreen) = rule.fullscreen + && let Err(error) = self.workspaces.set_fullscreen(id, fullscreen) + { + tracing::warn!(%error, ?id, fullscreen, "failed to apply fullscreen window rule"); + } + + if self.workspaces.workspace_for_window(id) != Some(self.workspaces.active_id()) + && self.focused_window == Some(id) + { + self.focused_window = self.workspaces.active().last_focused; + } + + self.relayout(); + self.restore_keyboard_focus(); + } + pub fn make_window_transient(&mut self, window: &Window, parent: WindowId) { let Some(id) = self.window_ids.get(window).copied() else { return; @@ -676,6 +764,7 @@ impl Ferese { self.space.unmap_elem(window); self.window_geometry.remove(&id); + self.window_rules_applied.remove(&id); self.scrolling_world_x.remove(&id); if let Err(error) = self.workspaces.remove_window(id) { tracing::error!(%error, ?id, "failed to remove window from layout"); diff --git a/compositor/src/window_rules.rs b/compositor/src/window_rules.rs new file mode 100644 index 0000000..b5b61a5 --- /dev/null +++ b/compositor/src/window_rules.rs @@ -0,0 +1,229 @@ +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct WindowRuleConfig { + pub app_id: Option, + pub title: Option, + pub transient: Option, + pub workspace: Option, + pub floating: Option, + pub width: Option, + pub height: Option, + pub fullscreen: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WindowRule { + app_id: Option, + title: Option, + transient: Option, + workspace: Option, + floating: Option, + width: Option, + height: Option, + fullscreen: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct WindowRuleResult { + pub workspace: Option, + pub floating: Option, + pub width: Option, + pub height: Option, + pub fullscreen: Option, +} + +pub fn validate(configured: &[WindowRuleConfig]) -> Result, String> { + configured + .iter() + .enumerate() + .map(|(index, rule)| validate_rule(index, rule)) + .collect() +} + +pub fn resolve( + rules: &[WindowRule], + app_id: Option<&str>, + title: Option<&str>, + transient: bool, +) -> WindowRuleResult { + let app_id = app_id.map(normalize_app_id); + let mut result = WindowRuleResult::default(); + + for rule in rules { + let app_id_matches = rule + .app_id + .as_deref() + .is_none_or(|expected| app_id.as_deref() == Some(expected)); + let title_matches = rule + .title + .as_deref() + .is_none_or(|expected| title == Some(expected)); + let transient_matches = rule.transient.is_none_or(|expected| expected == transient); + if !app_id_matches || !title_matches || !transient_matches { + continue; + } + + result.workspace = rule.workspace.or(result.workspace); + result.floating = rule.floating.or(result.floating); + result.width = rule.width.or(result.width); + result.height = rule.height.or(result.height); + result.fullscreen = rule.fullscreen.or(result.fullscreen); + } + + result +} + +fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result { + if rule.app_id.is_none() && rule.title.is_none() && rule.transient.is_none() { + return Err(format!( + "window_rules[{index}] must match app_id, title, or transient" + )); + } + if rule.workspace == Some(0) { + return Err(format!( + "window_rules[{index}].workspace must be greater than zero" + )); + } + + let app_id = rule + .app_id + .as_deref() + .map(nonempty_matcher) + .transpose() + .map_err(|message| format!("window_rules[{index}].app_id {message}"))? + .map(normalize_app_id); + let title = rule + .title + .as_deref() + .map(nonempty_matcher) + .transpose() + .map_err(|message| format!("window_rules[{index}].title {message}"))? + .map(str::to_owned); + let width = positive_dimension(rule.width, index, "width")?; + let height = positive_dimension(rule.height, index, "height")?; + + Ok(WindowRule { + app_id, + title, + transient: rule.transient, + workspace: rule.workspace, + floating: rule.floating, + width, + height, + fullscreen: rule.fullscreen, + }) +} + +fn nonempty_matcher(value: &str) -> Result<&str, &'static str> { + let value = value.trim(); + if value.is_empty() { + Err("cannot be empty") + } else { + Ok(value) + } +} + +fn positive_dimension( + value: Option, + index: usize, + field: &'static str, +) -> Result, String> { + if value.is_none_or(|value| value.is_finite() && value > 0.0) { + Ok(value) + } else { + Err(format!( + "window_rules[{index}].{field} must be a positive finite number" + )) + } +} + +fn normalize_app_id(value: &str) -> String { + value + .trim() + .strip_suffix(".desktop") + .unwrap_or(value.trim()) + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(app_id: &str) -> WindowRuleConfig { + WindowRuleConfig { + app_id: Some(app_id.to_owned()), + title: None, + transient: None, + workspace: None, + floating: None, + width: None, + height: None, + fullscreen: None, + } + } + + #[test] + fn app_ids_are_normalized_for_matching() { + let rules = validate(&[WindowRuleConfig { + floating: Some(true), + ..config("Org.Example.Editor.desktop") + }]) + .unwrap(); + + assert_eq!( + resolve(&rules, Some("org.example.editor"), None, false).floating, + Some(true) + ); + } + + #[test] + fn later_matching_rules_override_only_their_fields() { + let rules = validate(&[ + WindowRuleConfig { + workspace: Some(3), + floating: Some(true), + width: Some(800.0), + ..config("editor") + }, + WindowRuleConfig { + floating: Some(false), + fullscreen: Some(true), + ..config("editor") + }, + ]) + .unwrap(); + + assert_eq!( + resolve(&rules, Some("editor"), None, false), + WindowRuleResult { + workspace: Some(3), + floating: Some(false), + width: Some(800.0), + fullscreen: Some(true), + ..WindowRuleResult::default() + } + ); + } + + #[test] + fn rejects_catch_all_and_invalid_dimensions() { + let catch_all = WindowRuleConfig { + app_id: None, + title: None, + transient: None, + workspace: None, + floating: Some(true), + width: None, + height: None, + fullscreen: None, + }; + let invalid_size = WindowRuleConfig { + width: Some(f64::NAN), + ..config("editor") + }; + + assert!(validate(&[catch_all]).is_err()); + assert!(validate(&[invalid_size]).is_err()); + } +}