mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-11-13 12:23:38 +01:00
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.
This commit is contained in:
1 parent
7d584a809a
commit
cf60d00e75
3 files changed
+127
-8
No files matched your search
@@ -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<String> {
|
||||
self.role()
|
||||
Window::class(self)
|
||||
}
|
||||
|
||||
fn path(&self) -> Option<String> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>, subrole: Option<String>) -> Option<String> {
|
||||
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<u32> {
|
||||
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<String> {
|
||||
class_of(self.role(), self.subrole())
|
||||
}
|
||||
|
||||
fn running_application(&self) -> Result<Retained<NSRunningApplication>, 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<AXUIElement>) -> Option<String> {
|
||||
class_of(AdhocWindow::role(element), AdhocWindow::subrole(element))
|
||||
}
|
||||
|
||||
pub fn title(element: &CFRetained<AXUIElement>) -> Option<String> {
|
||||
AccessibilityApi::copy_attribute_value::<CFString>(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<String> {
|
||||
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() {
|
||||
|
||||
Reference in new issue
Block a user