feat: apply validated window rules
This commit is contained in:
1 parent
95b15b6e0d
commit
3d0007ba12
7 files changed
+406
-2
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user