feat: apply validated window rules

This commit is contained in:
Samuel Onoja committed 2026-09-25 03:02:50 +01:00
1 parent 95b15b6e0d
commit 3d0007ba12
7 files changed
+406 -2

No files matched your search

+18
View File
@@ -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 `AD06`. Invalid chords, actions, arguments, duplicate bindings, and missing
command references stop startup with an error. 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 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 hidden unless the session explicitly opts in, because an input method can
request privileged keyboard access. Enable it only when launching a trusted request privileged keyboard access. Enable it only when launching a trusted
+41 -1
View File
@@ -12,6 +12,8 @@ use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy};
use serde::Deserialize; use serde::Deserialize;
use smithay::input::keyboard::{Keycode, keysyms, xkb}; use smithay::input::keyboard::{Keycode, keysyms, xkb};
use crate::window_rules::{self, WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct Config { pub struct Config {
#[serde(default)] #[serde(default)]
@@ -25,6 +27,8 @@ pub struct Config {
#[serde(default)] #[serde(default)]
bindings: Vec<BindingConfig>, bindings: Vec<BindingConfig>,
#[serde(default)] #[serde(default)]
window_rules: Vec<WindowRuleConfig>,
#[serde(default)]
scrolling: ScrollingConfig, scrolling: ScrollingConfig,
} }
@@ -324,6 +328,7 @@ pub enum ConfigError {
value: String, value: String,
}, },
InvalidBinding(String), InvalidBinding(String),
InvalidWindowRule(String),
} }
impl fmt::Display for ConfigError { impl fmt::Display for ConfigError {
@@ -349,6 +354,7 @@ impl fmt::Display for ConfigError {
write!(formatter, "invalid input.{field} value {value}") write!(formatter, "invalid input.{field} value {value}")
} }
Self::InvalidBinding(message) => write!(formatter, "invalid binding: {message}"), 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::InvalidAnimationValue { .. }
| Self::InvalidLayoutValue { .. } | Self::InvalidLayoutValue { .. }
| Self::InvalidInputValue { .. } | Self::InvalidInputValue { .. }
| Self::InvalidBinding(_) => None, | Self::InvalidBinding(_)
| Self::InvalidWindowRule(_) => None,
} }
} }
} }
@@ -509,6 +516,10 @@ impl Config {
Ok(bindings) Ok(bindings)
} }
pub fn window_rules(&self) -> Result<Vec<WindowRule>, ConfigError> {
window_rules::validate(&self.window_rules).map_err(ConfigError::InvalidWindowRule)
}
pub fn animations_enabled(&self) -> bool { pub fn animations_enabled(&self) -> bool {
self.animations.enabled && !self.animations.reduced_motion 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] #[test]
fn derives_critical_viewport_damping() { fn derives_critical_viewport_damping() {
let config = parse(""); let config = parse("");
+3
View File
@@ -49,6 +49,9 @@ impl CompositorHandler for Ferese {
}; };
if let Some(window) = window { if let Some(window) = window {
if surface == &root {
xdg_shell::apply_initial_window_rules(self, &window);
}
window.on_commit(); window.on_commit();
self.record_client_commit(&window); self.record_client_commit(&window);
} }
+22
View File
@@ -351,6 +351,28 @@ pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, 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::<XdgToplevelSurfaceData>()
.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 { impl Ferese {
pub(crate) fn unconstrain_popup(&self, popup: &PopupSurface) { pub(crate) fn unconstrain_popup(&self, popup: &PopupSurface) {
let Ok(root) = find_popup_root_surface(&PopupKind::Xdg(popup.clone())) else { let Ok(root) = find_popup_root_surface(&PopupKind::Xdg(popup.clone())) else {
+3
View File
@@ -7,6 +7,7 @@ mod grabs;
mod handlers; mod handlers;
mod input; mod input;
mod state; mod state;
mod window_rules;
mod winit; mod winit;
use std::{error::Error, io, process::Command}; use std::{error::Error, io, process::Command};
@@ -26,6 +27,7 @@ fn main() -> Result<(), Box<dyn Error>> {
let config = Config::load()?; let config = Config::load()?;
let input_settings = config.input_settings()?; let input_settings = config.input_settings()?;
let bindings = config.bindings(&input_settings)?; let bindings = config.bindings(&input_settings)?;
let window_rules = config.window_rules()?;
let mut event_loop = EventLoop::try_new()?; let mut event_loop = EventLoop::try_new()?;
let display = Display::new()?; let display = Display::new()?;
let runtime = RuntimeConfig { let runtime = RuntimeConfig {
@@ -33,6 +35,7 @@ fn main() -> Result<(), Box<dyn Error>> {
gap_config: config.gap_config()?, gap_config: config.gap_config()?,
input_settings, input_settings,
bindings, bindings,
window_rules,
default_column_width: config.default_column_width()?, default_column_width: config.default_column_width()?,
scrolling_focus_strategy: config.scrolling_focus_strategy(), scrolling_focus_strategy: config.scrolling_focus_strategy(),
column_width_presets: config.width_presets()?, column_width_presets: config.width_presets()?,
+90 -1
View File
@@ -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 struct Ferese {
pub start_time: Instant, pub start_time: Instant,
@@ -96,6 +99,8 @@ pub struct Ferese {
gap_config: GapConfig, gap_config: GapConfig,
pub(crate) input_settings: InputSettings, pub(crate) input_settings: InputSettings,
pub(crate) bindings: Vec<Binding>, pub(crate) bindings: Vec<Binding>,
window_rules: Vec<WindowRule>,
window_rules_applied: HashSet<WindowId>,
animations_enabled: bool, animations_enabled: bool,
animation_speed: f64, animation_speed: f64,
spring_config: SpringConfig, spring_config: SpringConfig,
@@ -146,6 +151,7 @@ pub struct RuntimeConfig {
pub gap_config: GapConfig, pub gap_config: GapConfig,
pub input_settings: InputSettings, pub input_settings: InputSettings,
pub bindings: Vec<Binding>, pub bindings: Vec<Binding>,
pub window_rules: Vec<WindowRule>,
pub default_column_width: ColumnWidth, pub default_column_width: ColumnWidth,
pub scrolling_focus_strategy: ViewportFocusStrategy, pub scrolling_focus_strategy: ViewportFocusStrategy,
pub column_width_presets: Vec<ColumnWidth>, pub column_width_presets: Vec<ColumnWidth>,
@@ -386,6 +392,8 @@ impl Ferese {
gap_config: config.gap_config, gap_config: config.gap_config,
input_settings: config.input_settings, input_settings: config.input_settings,
bindings: config.bindings, bindings: config.bindings,
window_rules: config.window_rules,
window_rules_applied: HashSet::new(),
animations_enabled: config.animations_enabled, animations_enabled: config.animations_enabled,
animation_speed: config.animation_speed, animation_speed: config.animation_speed,
spring_config: config.spring_config, spring_config: config.spring_config,
@@ -634,6 +642,86 @@ impl Ferese {
self.relayout(); 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) { pub fn make_window_transient(&mut self, window: &Window, parent: WindowId) {
let Some(id) = self.window_ids.get(window).copied() else { let Some(id) = self.window_ids.get(window).copied() else {
return; return;
@@ -676,6 +764,7 @@ impl Ferese {
self.space.unmap_elem(window); self.space.unmap_elem(window);
self.window_geometry.remove(&id); self.window_geometry.remove(&id);
self.window_rules_applied.remove(&id);
self.scrolling_world_x.remove(&id); self.scrolling_world_x.remove(&id);
if let Err(error) = self.workspaces.remove_window(id) { if let Err(error) = self.workspaces.remove_window(id) {
tracing::error!(%error, ?id, "failed to remove window from layout"); tracing::error!(%error, ?id, "failed to remove window from layout");
+229
View File
@@ -0,0 +1,229 @@
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WindowRuleConfig {
pub app_id: Option<String>,
pub title: Option<String>,
pub transient: Option<bool>,
pub workspace: Option<u32>,
pub floating: Option<bool>,
pub width: Option<f64>,
pub height: Option<f64>,
pub fullscreen: Option<bool>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct WindowRule {
app_id: Option<String>,
title: Option<String>,
transient: Option<bool>,
workspace: Option<u32>,
floating: Option<bool>,
width: Option<f64>,
height: Option<f64>,
fullscreen: Option<bool>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct WindowRuleResult {
pub workspace: Option<u32>,
pub floating: Option<bool>,
pub width: Option<f64>,
pub height: Option<f64>,
pub fullscreen: Option<bool>,
}
pub fn validate(configured: &[WindowRuleConfig]) -> Result<Vec<WindowRule>, 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<WindowRule, String> {
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<f64>,
index: usize,
field: &'static str,
) -> Result<Option<f64>, 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());
}
}