From cf60d00e7575c9a96b18b9756fdca860d62df733 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Fri, 6 Nov 2026 14:04:33 -0800 Subject: [PATCH] feat(macos): the class of a window is its subrole, or its role without one macOS has no window class, so a Class rule matched the role, which every ordinary window shares (AXWindow) and which tells nothing: the subrole is what tells an ordinary window (AXStandardWindow) from a dialog, a floating panel or a system dialog. A Class rule already matched either of the two when the daemon decided to manage, float or place a window on a workspace; the state showed neither as a class, and an initial placement rule saw only the role. The class of a window is now its subrole, or the role when it has none (window::class_of). The state of a window has it as `details.class`, as the state of a Windows or a KDE window does, so the tools that read the state read it the same on every platform; a state from an older daemon without it still parses. The rule a toggle of floating adds names the class, and ignore-rule matches a window once when it matches by both role and subrole. A rule that names AXWindow keeps matching, because rules take either. --- crates/komorebi-macos/src/container.rs | 5 +- crates/komorebi-macos/src/process_command.rs | 10 +- crates/komorebi-macos/src/window.rs | 120 +++++++++++++++++++ 3 files changed, 127 insertions(+), 8 deletions(-) diff --git a/crates/komorebi-macos/src/container.rs b/crates/komorebi-macos/src/container.rs index a4b07fbf..cfdb9e08 100644 --- a/crates/komorebi-macos/src/container.rs +++ b/crates/komorebi-macos/src/container.rs @@ -81,9 +81,10 @@ impl komorebi_core::WorkspaceWindow for Window { Window::exe(self) } - /// macOS has no window class: the role, which is what `komorebi.json` matches as a class + /// macOS has no window class: the subrole, or the role without one (see + /// [`crate::window::class_of`]), which is what the state shows as the class fn class(&self) -> Option { - self.role() + Window::class(self) } fn path(&self) -> Option { diff --git a/crates/komorebi-macos/src/process_command.rs b/crates/komorebi-macos/src/process_command.rs index 629da46f..5c01ff23 100644 --- a/crates/komorebi-macos/src/process_command.rs +++ b/crates/komorebi-macos/src/process_command.rs @@ -1408,7 +1408,7 @@ impl WindowManager { let (exe, title, role) = ( AdhocWindow::exe(&foreground_window), AdhocWindow::title(&foreground_window), - AdhocWindow::role(&foreground_window), + AdhocWindow::class(&foreground_window), ); if let (Some(exe), Some(title), Some(role)) = (exe, title, role) { @@ -1472,11 +1472,9 @@ impl WindowManager { } } ApplicationIdentifier::Class => { - if window.role().unwrap_or_default() == *id { - window_ids_to_purge.push((i, window.id)); - } - - if window.subrole().unwrap_or_default() == *id { + if window.role().unwrap_or_default() == *id + || window.subrole().unwrap_or_default() == *id + { window_ids_to_purge.push((i, window.id)); } } diff --git a/crates/komorebi-macos/src/window.rs b/crates/komorebi-macos/src/window.rs index 92a29da4..93614f0d 100644 --- a/crates/komorebi-macos/src/window.rs +++ b/crates/komorebi-macos/src/window.rs @@ -446,6 +446,10 @@ pub struct WindowDetails { pub exe: String, pub role: String, pub subrole: String, + /// The class of the window, as a `Class` rule matches it: the subrole, or the role when there + /// is no subrole. The windows of the other platforms have a `class` in their state too + #[serde(default)] + pub class: String, pub icon_path: PathBuf, } @@ -456,6 +460,7 @@ impl From<&Window> for WindowDetails { exe: value.exe().unwrap_or_default(), role: value.role().unwrap_or_default(), subrole: value.subrole().unwrap_or_default(), + class: value.class().unwrap_or_default(), icon_path: value.icon_path().unwrap_or_default(), } } @@ -538,6 +543,7 @@ struct SerializedWindow { exe: String, role: String, subrole: String, + class: String, rect: Rect, } @@ -552,6 +558,15 @@ impl schemars::JsonSchema for Window { } } +/// The class of a window, which macOS does not have: the accessibility subrole of the window +/// (`AXStandardWindow` of an ordinary window, `AXDialog`, `AXFloatingWindow`, ...), which tells +/// the kinds of window apart, or its role (`AXWindow`, that every window has) when it has no +/// subrole. A `Class` rule matches either of the two, so a rule that names the role keeps working; +/// the state shows this one. +pub fn class_of(role: Option, subrole: Option) -> Option { + subrole.filter(|subrole| !subrole.is_empty()).or(role) +} + /// The id of the window that has the focus, if any window does pub fn foreground_window_id() -> Option { MacosApi::foreground_window_id() @@ -963,6 +978,12 @@ impl Window { .map(|s| s.to_string()) } + /// What `komorebi.json` and the state call the class of the window: its subrole, or its role + /// when it has no subrole (see [`class_of`]) + pub fn class(&self) -> Option { + class_of(self.role(), self.subrole()) + } + fn running_application(&self) -> Result, AccessibilityError> { NSRunningApplication::runningApplicationWithProcessIdentifier(self.application.process_id) .ok_or(AccessibilityError::Custom( @@ -1487,6 +1508,11 @@ impl AdhocWindow { .map(|s| s.to_string()) } + /// The class of the window: its subrole, or its role when it has no subrole (see [`class_of`]) + pub fn class(element: &CFRetained) -> Option { + class_of(AdhocWindow::role(element), AdhocWindow::subrole(element)) + } + pub fn title(element: &CFRetained) -> Option { AccessibilityApi::copy_attribute_value::(element, kAXTitleAttribute) .map(|s| s.to_string()) @@ -1876,6 +1902,100 @@ pub fn should_act_individual( #[cfg(test)] mod tests { use super::*; + use crate::core::ApplicationIdentifier; + use crate::core::config_generation::MatchingStrategy; + + fn class_rule(id: &str, strategy: MatchingStrategy) -> MatchingRule { + MatchingRule::Simple(IdWithIdentifier { + kind: ApplicationIdentifier::Class, + id: id.to_string(), + matching_strategy: Some(strategy), + }) + } + + fn matches(classes: &[&str], rule: &MatchingRule) -> bool { + should_act( + "title", + "exe", + classes, + "/path", + std::slice::from_ref(rule), + &HashMap::new(), + ) + .is_some() + } + + fn some(value: &str) -> Option { + Some(value.to_string()) + } + + #[test] + fn the_class_of_a_window_is_its_subrole() { + assert_eq!( + class_of(some("AXWindow"), some("AXStandardWindow")), + some("AXStandardWindow") + ); + assert_eq!( + class_of(some("AXWindow"), some("e2e-class")), + some("e2e-class") + ); + } + + #[test] + fn the_class_of_a_window_without_a_subrole_is_its_role() { + assert_eq!(class_of(some("AXWindow"), None), some("AXWindow")); + assert_eq!(class_of(some("AXWindow"), some("")), some("AXWindow")); + assert_eq!(class_of(None, None), None); + } + + #[test] + fn a_class_rule_matches_the_subrole_and_the_role_of_a_window() { + let classes = ["AXWindow", "AXStandardWindow"]; + + assert!(matches( + &classes, + &class_rule("AXStandardWindow", MatchingStrategy::Equals) + )); + // a rule written for the role, before the class was the subrole, still holds + assert!(matches( + &classes, + &class_rule("AXWindow", MatchingStrategy::Equals) + )); + assert!(!matches( + &classes, + &class_rule("AXDialog", MatchingStrategy::Equals) + )); + assert!(matches( + &classes, + &class_rule("AXStandard", MatchingStrategy::StartsWith) + )); + assert!(matches( + &classes, + &class_rule("Standard", MatchingStrategy::Contains) + )); + assert!(!matches( + &classes, + &class_rule("Dialog", MatchingStrategy::Contains) + )); + } + + #[test] + fn the_state_of_a_window_has_a_class() { + let details: WindowDetails = serde_json::from_value(serde_json::json!({ + "title": "t", "exe": "e", "role": "AXWindow", "subrole": "AXStandardWindow", + "class": "AXStandardWindow", "icon_path": "", + })) + .unwrap(); + assert_eq!(details.class, "AXStandardWindow"); + + // the state of a daemon from before there was a class has none + let older: WindowDetails = serde_json::from_value(serde_json::json!({ + "title": "t", "exe": "e", "role": "AXWindow", "subrole": "AXStandardWindow", + "icon_path": "", + })) + .unwrap(); + assert_eq!(older.class, ""); + } #[test] fn nothing_is_dragged_before_a_drag_begins() {