From 1c175642d073689ca11b9252411ea5f8446007d0 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Thu, 30 May 2024 16:10:00 +0200 Subject: Fix decoration drift during animated transitions; checkpoint IPC/global-menu/output-management work Border/titlebar decoration was built from Window.geometry (the animation's final target) in both wayland backends' render loops, while sync_geometry already draws a window's actual content at window_anims' interpolated rect during any maximize/fullscreen/open-slide tween. Border and content read two different rectangles for the whole transition, so the border visibly detached from the window it was outlining - reported as "borders aren't flush." Both udev.rs and winit.rs now read the same animated rect for titlebar placement, border-strip placement, and the occlusion test against later windows in stacking order. Verified: cargo build --workspace, cargo clippy (0 new warnings), cargo test -p srdwm-core (111/111). Also checkpoints substantial protocol/IPC work from prior sessions that had accumulated uncommitted: gtk-shell1 support (gtk_shell.rs, the vendored gtk-shell.xml, xwayland.rs's X11-side mirror) backing the global app menu; zwlr_foreign_toplevel_manager_v1 (foreign_toplevel.rs) broadcasting distinct maximized/minimized/fullscreen/activated state per window; output_management (ext-output-management + layer-shell exclusive-zone reservation tracking); workspace.rs and context_menu.rs; a Unix-socket IPC crate (platform/src/ ipc.rs) and a `srd` control-CLI crate (crates/ctl); xkb_config.rs; and a theme module (core/src/theme.rs). A peer session working the AGS shell concurrently verified several of these live against a running srdwm: the global menu rendering a real app's File/Edit menu over gtk-shell1, and foreign-toplevel correctly reporting maximized and fullscreen as independent, non-simultaneous states with the geometry each implies (maximize stops at a reserved top bar and past a dock; fullscreen reaches the true monitor edge). --- crates/core/src/rules.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 6 deletions(-) (limited to 'crates/core/src/rules.rs') diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs index 642179d..148b627 100644 --- a/crates/core/src/rules.rs +++ b/crates/core/src/rules.rs @@ -1,22 +1,46 @@ +use regex::Regex; + use crate::geometry::Rect; use crate::window::Window; use crate::workspace::WorkspaceId; /// Match criteria for a [`WindowRule`]. A matcher with every field `None` /// matches nothing (an accidental `srd.rule({}, {...})` in config should be a -/// silent no-op, not "apply to every window"). +/// silent no-op, not "apply to every window"). Every field that is `Some` +/// must match (AND semantics) - matching the convention i3's multi-criteria +/// rules and bspwm's `class:instance:title` rules both already use. +/// +/// `title_contains`/`class` (plain substring/exact match) are kept +/// alongside the regex fields below rather than folded into them: they +/// cover the large majority of real rules (`srd.rule({ class = "firefox" }, +/// ...)`) with no regex syntax to get right, and are cheaper to evaluate. +/// `title_regex`/`class_regex`/`instance` exist for the cases that need +/// more precision - disambiguating a specific dialog by title while +/// leaving an app's main window alone, the concrete example that motivated +/// adding these - without forcing every simple rule to write one. #[derive(Debug, Clone, Default)] pub struct WindowMatch { /// Case-insensitive substring match against `Window::title`. pub title_contains: Option, - /// Case-insensitive exact match against `Window::app_id` (X11 `WM_CLASS` - /// / Wayland `app_id`). + /// Case-insensitive exact match against `Window::app_id` (X11 `WM_CLASS`'s + /// *class* half / Wayland `app_id`). pub class: Option, + /// Regex match against `Window::title`. Case-sensitive by default -- + /// write `(?i)` at the start of the pattern for case-insensitive, + /// the same convention i3's own criteria use. + pub title_regex: Option, + /// Regex match against `Window::app_id`. + pub class_regex: Option, + /// Case-insensitive exact match against `Window::instance` (X11 + /// `WM_CLASS`'s *instance* half). Always fails to match on Wayland-native + /// windows, which have no equivalent (`Window::instance` is always + /// empty there) - an X11/XWayland-only criterion, same as bspwm's. + pub instance: Option, } impl WindowMatch { pub fn is_empty(&self) -> bool { - self.title_contains.is_none() && self.class.is_none() + self.title_contains.is_none() && self.class.is_none() && self.title_regex.is_none() && self.class_regex.is_none() && self.instance.is_none() } pub fn matches(&self, window: &Window) -> bool { @@ -33,6 +57,21 @@ impl WindowMatch { return false; } } + if let Some(re) = &self.title_regex { + if !re.is_match(&window.title) { + return false; + } + } + if let Some(re) = &self.class_regex { + if !re.is_match(&window.app_id) { + return false; + } + } + if let Some(i) = &self.instance { + if !window.instance.eq_ignore_ascii_case(i) { + return false; + } + } true } } @@ -70,7 +109,7 @@ mod tests { #[test] fn title_match_is_case_insensitive_substring() { let w = Window::new(1, "Mozilla Firefox"); - let m = WindowMatch { title_contains: Some("firefox".into()), class: None }; + let m = WindowMatch { title_contains: Some("firefox".into()), ..Default::default() }; assert!(m.matches(&w)); } @@ -78,10 +117,69 @@ mod tests { fn class_match_is_case_insensitive_exact() { let mut w = Window::new(1, ""); w.app_id = "Firefox".into(); - let m = WindowMatch { title_contains: None, class: Some("firefox".into()) }; + let m = WindowMatch { class: Some("firefox".into()), ..Default::default() }; assert!(m.matches(&w)); let mut w2 = Window::new(2, ""); w2.app_id = "firefoxx".into(); assert!(!m.matches(&w2)); } + + #[test] + fn title_regex_matches_a_specific_dialog_without_matching_the_main_window() { + // The concrete case that motivated adding regex support at all: + // disambiguating a specific dialog by title while leaving an app's + // main window alone - not reliably possible with substring-only + // matching if the dialog's title is a substring-superset situation + // (or vice versa) that plain `contains` can't express. + let m = WindowMatch { title_regex: Some(Regex::new(r"^Save File$").unwrap()), ..Default::default() }; + let dialog = Window::new(1, "Save File"); + let main = Window::new(2, "Save File - GNU Image Manipulation Program"); + assert!(m.matches(&dialog)); + assert!(!m.matches(&main)); + } + + #[test] + fn class_regex_is_case_sensitive_unless_the_pattern_opts_in() { + let mut w = Window::new(1, ""); + w.app_id = "firefox".into(); + let sensitive = WindowMatch { class_regex: Some(Regex::new(r"^Firefox$").unwrap()), ..Default::default() }; + assert!(!sensitive.matches(&w)); + let insensitive = WindowMatch { class_regex: Some(Regex::new(r"(?i)^Firefox$").unwrap()), ..Default::default() }; + assert!(insensitive.matches(&w)); + } + + #[test] + fn instance_match_is_case_insensitive_exact_and_independent_of_class() { + let mut w = Window::new(1, ""); + w.app_id = "Navigator".into(); + w.instance = "firefox".into(); + let m = WindowMatch { instance: Some("Firefox".into()), ..Default::default() }; + assert!(m.matches(&w)); + let mut w2 = Window::new(2, ""); + w2.instance = "firefoxdeveloperedition".into(); + assert!(!m.matches(&w2)); + } + + #[test] + fn multiple_criteria_are_combined_with_and() { + let mut w = Window::new(1, "Preferences"); + w.app_id = "firefox".into(); + let m = WindowMatch { class: Some("firefox".into()), title_contains: Some("preferences".into()), ..Default::default() }; + assert!(m.matches(&w)); + // Same class, different title - must not match once title is + // also a criterion. + let mut w2 = Window::new(2, "Mozilla Firefox"); + w2.app_id = "firefox".into(); + assert!(!m.matches(&w2)); + } + + #[test] + fn a_nonempty_instance_criterion_does_not_match_a_wayland_native_window() { + // `Window::instance` is always empty on Wayland (no equivalent + // concept), so a real `instance` rule (never an empty-string one -- + // nobody writes `instance = ""`) must not match there. + let w = Window::new(1, ""); + let m = WindowMatch { instance: Some("firefox".into()), ..Default::default() }; + assert!(!m.matches(&w)); + } } -- cgit v1.2.3