refactor(macos): the animation maths, the arrival record and the enhanced-UI rules move into komorebi-macos-core, which needs no macOS

This commit is contained in:
LGUG2Z committed 2026-11-05 15:51:50 -08:00
1 parent b83cf06cc6
commit 8e1a2b261c
21 files changed
+324 -245

No files matched your search

Generated
+11
View File
@@ -3872,6 +3872,7 @@ dependencies = [
"komorebi-config",
"komorebi-core",
"komorebi-layouts",
"komorebi-macos-core",
"komorebi-protocol",
"komorebi-settings",
"komorebi-themes",
@@ -3905,6 +3906,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "komorebi-macos-core"
version = "0.2.0-dev"
dependencies = [
"komorebi-layouts",
"komorebi-protocol",
"parking_lot 0.12.5",
"tracing",
]
[[package]]
name = "komorebi-protocol"
version = "0.2.0-dev"
+1
View File
@@ -28,6 +28,7 @@ members = [
# `default-members` below is the Windows set that a bare cargo command builds
"crates/komorebi-linux",
"crates/komorebi-macos",
"crates/komorebi-macos-core",
"crates/komorebi-hotkeys",
"crates/komorebi-version",
"komorebi-recorder",
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "komorebi-macos-core"
version.workspace = true
description = "The platform-neutral parts of komorebi-macos (animation maths, arrival record, accessibility rules), tested on every platform"
edition = "2024"
publish = false
[dependencies]
komorebi-layouts = { path = "../komorebi-layouts" }
komorebi-protocol = { path = "../komorebi-protocol", default-features = false }
parking_lot = { workspace = true }
tracing = { workspace = true }
@@ -1,5 +1,5 @@
use crate::core::Rect;
use crate::core::animation::AnimationStyle;
use komorebi_layouts::Rect;
use komorebi_protocol::animation::AnimationStyle;
use super::style::apply_ease_func;
@@ -0,0 +1,11 @@
//! The pure part of the window animations: the easing functions, the motion of a frame, the tracks a
//! window follows, and the throttle on the frames. Nothing here reads a clock it was not given or
//! talks to the window server.
pub mod lerp;
pub mod stats;
pub mod step;
pub mod style;
pub mod throttle;
pub mod tracks;
pub mod transition;
@@ -4,8 +4,8 @@
//! can be tested deterministically and the animator is free to decide when a frame happens.
use super::style::apply_ease_func;
use crate::core::Rect;
use crate::core::animation::AnimationStyle;
use komorebi_layouts::Rect;
use komorebi_protocol::animation::AnimationStyle;
use std::time::Duration;
use std::time::Instant;
@@ -1,4 +1,4 @@
use crate::core::animation::AnimationStyle;
use komorebi_protocol::animation::AnimationStyle;
use std::f64::consts::PI;
@@ -10,8 +10,8 @@ use super::step::FrameF;
use super::step::Motion;
use super::step::MotionSpec;
use super::step::step;
use crate::core::Rect;
use crate::core::animation::AnimationStyle;
use komorebi_layouts::Rect;
use komorebi_protocol::animation::AnimationStyle;
use std::collections::HashMap;
use std::collections::HashSet;
use std::time::Duration;
@@ -6,9 +6,9 @@
//! rects, so that it can be tested without a screen.
use crate::animation::style::apply_ease_func;
use crate::core::OperationDirection;
use crate::core::Rect;
use crate::core::animation::AnimationStyle;
use komorebi_layouts::OperationDirection;
use komorebi_layouts::Rect;
use komorebi_protocol::animation::AnimationStyle;
/// Which side of the monitor the workspace that is entered comes in from. The workspace that is
/// left goes out on the other side.
@@ -10,7 +10,7 @@
//! this module waits for anything or takes the lock of the window manager, so it can be called
//! from any thread. The layout picks up what has been said the next time that it runs.
use crate::core::Rect;
use komorebi_layouts::Rect;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::LazyLock;
@@ -0,0 +1,243 @@
//! The rules for stopping apps from animating the moves and resizes we make
//!
//! This is the state machine of `komorebi-macos`'s `accessibility::enhanced_ui`: the attribute is read and
//! written through [`Flag`], which the macOS side implements over the accessibility API, so nothing here
//! needs macOS.
//!
//! When `AXEnhancedUserInterface` is on for an application, the app animates every move and resize
//! it is asked to make over AX, which turns each of our frames into a slow animation of its own. The
//! attribute belongs to the *application* element, not to a window, and it is shared by all of that
//! application's windows, so turning it off has to be reference counted per process: the last window
//! to finish puts it back.
//!
//! Almost no app has the attribute on, so the common case must cost nothing: once an app has been
//! seen not to have it, it is left alone for a while without asking again.
use std::time::Duration;
use std::time::Instant;
/// How long an app that does not have the attribute on is left alone before it is asked again
pub const QUIET_PERIOD: Duration = Duration::from_secs(10);
/// The attribute, as seen by the state machine
pub trait Flag {
/// The current value, or `None` if the app has no such attribute or did not answer
fn read(&self) -> Option<bool>;
/// Returns whether the write succeeded
fn write(&self, value: bool) -> bool;
}
/// What is known about one process: who relies on the attribute being off, and what is owed back to the app
#[derive(Debug, Default)]
pub struct State {
/// How many windows are currently relying on the attribute being off
pub active: u32,
/// The attribute may be off because of us, so it is owed back to the app
///
/// Setters can change app state before they report an error, so this is set before the attribute
/// is touched rather than after. It also stops us reading our own `false` back later and
/// mistaking it for the app's own setting.
pub restore_pending: bool,
pub quiet_until: Option<Instant>,
}
impl State {
/// Returns whether the caller now holds a reference that it has to [`State::release`]
pub fn acquire(&mut self, flag: &impl Flag, now: Instant) -> bool {
if self.active > 0 {
self.active += 1;
return true;
}
if !self.restore_pending {
if self.quiet_until.is_some_and(|until| now < until) {
return false;
}
if flag.read() != Some(true) {
self.quiet_until = Some(now + QUIET_PERIOD);
return false;
}
}
self.restore_pending = true;
flag.write(false);
self.active = 1;
true
}
pub fn release(&mut self, flag: &impl Flag) {
self.active = self.active.saturating_sub(1);
if self.active == 0 && self.restore_pending && flag.write(true) {
self.restore_pending = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
struct Fake {
value: Cell<Option<bool>>,
reads: Cell<u32>,
writes: Cell<u32>,
fail_writes: Cell<bool>,
}
impl Fake {
fn new(value: Option<bool>) -> Self {
Self {
value: Cell::new(value),
reads: Cell::new(0),
writes: Cell::new(0),
fail_writes: Cell::new(false),
}
}
}
impl Flag for Fake {
fn read(&self) -> Option<bool> {
self.reads.set(self.reads.get() + 1);
self.value.get()
}
fn write(&self, value: bool) -> bool {
self.writes.set(self.writes.get() + 1);
if self.fail_writes.get() {
return false;
}
self.value.set(Some(value));
true
}
}
#[test]
fn an_app_with_the_attribute_on_has_it_turned_off_and_back_on() {
let flag = Fake::new(Some(true));
let mut state = State::default();
let now = Instant::now();
assert!(state.acquire(&flag, now));
assert_eq!(flag.value.get(), Some(false));
state.release(&flag);
assert_eq!(flag.value.get(), Some(true));
assert!(!state.restore_pending);
}
#[test]
fn two_windows_share_one_disable_and_one_restore() {
let flag = Fake::new(Some(true));
let mut state = State::default();
let now = Instant::now();
assert!(state.acquire(&flag, now));
assert!(state.acquire(&flag, now));
assert_eq!(flag.writes.get(), 1);
assert_eq!(flag.reads.get(), 1);
state.release(&flag);
assert_eq!(flag.value.get(), Some(false));
state.release(&flag);
assert_eq!(flag.value.get(), Some(true));
assert_eq!(flag.writes.get(), 2);
}
#[test]
fn an_app_without_the_attribute_costs_nothing_after_the_first_look() {
let flag = Fake::new(None);
let mut state = State::default();
let now = Instant::now();
assert!(!state.acquire(&flag, now));
assert!(!state.acquire(&flag, now + Duration::from_secs(1)));
assert!(!state.acquire(&flag, now + Duration::from_secs(9)));
assert_eq!(flag.reads.get(), 1);
assert_eq!(flag.writes.get(), 0);
}
#[test]
fn an_app_with_the_attribute_off_is_left_alone() {
let flag = Fake::new(Some(false));
let mut state = State::default();
assert!(!state.acquire(&flag, Instant::now()));
assert_eq!(flag.writes.get(), 0);
}
#[test]
fn an_app_is_asked_again_after_the_quiet_period() {
let flag = Fake::new(Some(false));
let mut state = State::default();
let now = Instant::now();
assert!(!state.acquire(&flag, now));
// somebody turned on VoiceOver in the meantime
flag.value.set(Some(true));
assert!(!state.acquire(&flag, now + QUIET_PERIOD - Duration::from_secs(1)));
assert!(state.acquire(&flag, now + QUIET_PERIOD + Duration::from_secs(1)));
assert_eq!(flag.value.get(), Some(false));
}
#[test]
fn a_failed_restore_is_owed_and_never_mistaken_for_the_apps_own_setting() {
let flag = Fake::new(Some(true));
let mut state = State::default();
let now = Instant::now();
assert!(state.acquire(&flag, now));
flag.fail_writes.set(true);
state.release(&flag);
assert!(state.restore_pending);
assert_eq!(flag.value.get(), Some(false));
// the next window must not read the false we left behind and conclude the app never had it on
flag.fail_writes.set(false);
let reads = flag.reads.get();
assert!(state.acquire(&flag, now + Duration::from_secs(1)));
assert_eq!(flag.reads.get(), reads);
state.release(&flag);
assert_eq!(flag.value.get(), Some(true));
assert!(!state.restore_pending);
}
#[test]
fn a_failed_disable_is_still_owed_back() {
let flag = Fake::new(Some(true));
flag.fail_writes.set(true);
let mut state = State::default();
// the setter may have changed the app before it reported the error
assert!(state.acquire(&flag, Instant::now()));
assert!(state.restore_pending);
flag.fail_writes.set(false);
state.release(&flag);
assert_eq!(flag.value.get(), Some(true));
assert!(!state.restore_pending);
}
#[test]
fn releasing_more_than_was_acquired_is_harmless() {
let flag = Fake::new(Some(true));
let mut state = State::default();
state.release(&flag);
assert_eq!(flag.writes.get(), 0);
assert_eq!(state.active, 0);
}
}
+15
View File
@@ -0,0 +1,15 @@
//! The parts of `komorebi-macos` that need nothing from macOS.
//!
//! The animation maths, the record of what windows turned out to be, and the rules for the
//! accessibility attribute that makes apps animate on their own live here so that their tests run on
//! every platform (a Linux CI runner included), not only on a Mac: `komorebi-macos` pulls in the
//! Objective-C runtime crates, which refuse to compile anywhere else.
//!
//! The rule of the crate is that it has no dependency on `objc2`, `block2`, `dispatch2` or any
//! binding of an Apple framework, so a module that would need one does not belong in it.
//! `komorebi-macos` re-exports each module at the path it had before, so nothing that uses them
//! changed.
pub mod animation;
pub mod arrival;
pub mod enhanced_ui;
+1
View File
@@ -9,6 +9,7 @@ komorebi-themes = { path = "../komorebi-themes" }
komorebi-backend = { path = "../komorebi-backend" }
komorebi-collections = { path = "../komorebi-collections" }
komorebi-core = { path = "../komorebi-core" }
komorebi-macos-core = { path = "../komorebi-macos-core" }
komorebi-layouts = { path = "../komorebi-layouts", features = [
"darwin",
"schemars",
@@ -12,73 +12,14 @@
use crate::AccessibilityUiElement;
use crate::accessibility::private::get_enhanced_user_interface_if_present;
use crate::accessibility::private::set_enhanced_user_interface;
use komorebi_macos_core::enhanced_ui::Flag;
use komorebi_macos_core::enhanced_ui::State;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::LazyLock;
use std::time::Duration;
use std::time::Instant;
/// How long an app that does not have the attribute on is left alone before it is asked again
const QUIET_PERIOD: Duration = Duration::from_secs(10);
/// The attribute, as seen by the state machine
pub trait Flag {
/// The current value, or `None` if the app has no such attribute or did not answer
fn read(&self) -> Option<bool>;
/// Returns whether the write succeeded
fn write(&self, value: bool) -> bool;
}
#[derive(Debug, Default)]
struct State {
/// How many windows are currently relying on the attribute being off
active: u32,
/// The attribute may be off because of us, so it is owed back to the app
///
/// Setters can change app state before they report an error, so this is set before the attribute
/// is touched rather than after. It also stops us reading our own `false` back later and
/// mistaking it for the app's own setting.
restore_pending: bool,
quiet_until: Option<Instant>,
}
impl State {
/// Returns whether the caller now holds a reference that it has to [`State::release`]
fn acquire(&mut self, flag: &impl Flag, now: Instant) -> bool {
if self.active > 0 {
self.active += 1;
return true;
}
if !self.restore_pending {
if self.quiet_until.is_some_and(|until| now < until) {
return false;
}
if flag.read() != Some(true) {
self.quiet_until = Some(now + QUIET_PERIOD);
return false;
}
}
self.restore_pending = true;
flag.write(false);
self.active = 1;
true
}
fn release(&mut self, flag: &impl Flag) {
self.active = self.active.saturating_sub(1);
if self.active == 0 && self.restore_pending && flag.write(true) {
self.restore_pending = false;
}
}
}
struct AxFlag(AccessibilityUiElement);
impl Flag for AxFlag {
@@ -127,168 +68,3 @@ pub fn disable(pid: i32, app: &AccessibilityUiElement) -> Option<EnhancedUiGuard
pub fn forget(pid: i32) {
STATES.lock().remove(&pid);
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
struct Fake {
value: Cell<Option<bool>>,
reads: Cell<u32>,
writes: Cell<u32>,
fail_writes: Cell<bool>,
}
impl Fake {
fn new(value: Option<bool>) -> Self {
Self {
value: Cell::new(value),
reads: Cell::new(0),
writes: Cell::new(0),
fail_writes: Cell::new(false),
}
}
}
impl Flag for Fake {
fn read(&self) -> Option<bool> {
self.reads.set(self.reads.get() + 1);
self.value.get()
}
fn write(&self, value: bool) -> bool {
self.writes.set(self.writes.get() + 1);
if self.fail_writes.get() {
return false;
}
self.value.set(Some(value));
true
}
}
#[test]
fn an_app_with_the_attribute_on_has_it_turned_off_and_back_on() {
let flag = Fake::new(Some(true));
let mut state = State::default();
let now = Instant::now();
assert!(state.acquire(&flag, now));
assert_eq!(flag.value.get(), Some(false));
state.release(&flag);
assert_eq!(flag.value.get(), Some(true));
assert!(!state.restore_pending);
}
#[test]
fn two_windows_share_one_disable_and_one_restore() {
let flag = Fake::new(Some(true));
let mut state = State::default();
let now = Instant::now();
assert!(state.acquire(&flag, now));
assert!(state.acquire(&flag, now));
assert_eq!(flag.writes.get(), 1);
assert_eq!(flag.reads.get(), 1);
state.release(&flag);
assert_eq!(flag.value.get(), Some(false));
state.release(&flag);
assert_eq!(flag.value.get(), Some(true));
assert_eq!(flag.writes.get(), 2);
}
#[test]
fn an_app_without_the_attribute_costs_nothing_after_the_first_look() {
let flag = Fake::new(None);
let mut state = State::default();
let now = Instant::now();
assert!(!state.acquire(&flag, now));
assert!(!state.acquire(&flag, now + Duration::from_secs(1)));
assert!(!state.acquire(&flag, now + Duration::from_secs(9)));
assert_eq!(flag.reads.get(), 1);
assert_eq!(flag.writes.get(), 0);
}
#[test]
fn an_app_with_the_attribute_off_is_left_alone() {
let flag = Fake::new(Some(false));
let mut state = State::default();
assert!(!state.acquire(&flag, Instant::now()));
assert_eq!(flag.writes.get(), 0);
}
#[test]
fn an_app_is_asked_again_after_the_quiet_period() {
let flag = Fake::new(Some(false));
let mut state = State::default();
let now = Instant::now();
assert!(!state.acquire(&flag, now));
// somebody turned on VoiceOver in the meantime
flag.value.set(Some(true));
assert!(!state.acquire(&flag, now + QUIET_PERIOD - Duration::from_secs(1)));
assert!(state.acquire(&flag, now + QUIET_PERIOD + Duration::from_secs(1)));
assert_eq!(flag.value.get(), Some(false));
}
#[test]
fn a_failed_restore_is_owed_and_never_mistaken_for_the_apps_own_setting() {
let flag = Fake::new(Some(true));
let mut state = State::default();
let now = Instant::now();
assert!(state.acquire(&flag, now));
flag.fail_writes.set(true);
state.release(&flag);
assert!(state.restore_pending);
assert_eq!(flag.value.get(), Some(false));
// the next window must not read the false we left behind and conclude the app never had it on
flag.fail_writes.set(false);
let reads = flag.reads.get();
assert!(state.acquire(&flag, now + Duration::from_secs(1)));
assert_eq!(flag.reads.get(), reads);
state.release(&flag);
assert_eq!(flag.value.get(), Some(true));
assert!(!state.restore_pending);
}
#[test]
fn a_failed_disable_is_still_owed_back() {
let flag = Fake::new(Some(true));
flag.fail_writes.set(true);
let mut state = State::default();
// the setter may have changed the app before it reported the error
assert!(state.acquire(&flag, Instant::now()));
assert!(state.restore_pending);
flag.fail_writes.set(false);
state.release(&flag);
assert_eq!(flag.value.get(), Some(true));
assert!(!state.restore_pending);
}
#[test]
fn releasing_more_than_was_acquired_is_harmless() {
let flag = Fake::new(Some(true));
let mut state = State::default();
state.release(&flag);
assert_eq!(flag.writes.get(), 0);
assert_eq!(state.active, 0);
}
}
+9 -7
View File
@@ -10,16 +10,18 @@ use step::MotionSpec;
pub mod animator;
pub mod capture;
pub mod ghost;
pub mod lerp;
pub mod prefix;
pub mod stats;
pub mod step;
pub mod style;
pub mod throttle;
pub mod tracks;
pub mod transition;
pub mod workspace_transition;
// the pure part of the animations lives in komorebi-macos-core, and is where it always was
pub use komorebi_macos_core::animation::lerp;
pub use komorebi_macos_core::animation::stats;
pub use komorebi_macos_core::animation::step;
pub use komorebi_macos_core::animation::style;
pub use komorebi_macos_core::animation::throttle;
pub use komorebi_macos_core::animation::tracks;
pub use komorebi_macos_core::animation::transition;
pub const DEFAULT_ANIMATION_ENABLED: bool = false;
pub const DEFAULT_ANIMATION_STYLE: AnimationStyle = AnimationStyle::Linear;
pub const DEFAULT_ANIMATION_DURATION: u64 = 250;
+1 -1
View File
@@ -47,7 +47,7 @@ pub mod accessibility;
pub mod animation;
pub mod app_kit_notification_constants;
pub mod application;
pub mod arrival;
pub use komorebi_macos_core::arrival;
pub mod ax_registry;
pub mod border_manager;
pub mod container;
+5
View File
@@ -73,6 +73,11 @@ crate = "komorebi-macos"
expression = "LicenseRef-Komorebi-2.0"
license-files = []
[[licenses.clarify]]
crate = "komorebi-macos-core"
expression = "LicenseRef-Komorebi-2.0"
license-files = []
[[licenses.clarify]]
crate = "komorebi-recorder"
expression = "LicenseRef-Komorebi-2.0"
+1
View File
@@ -617,6 +617,7 @@
"komorebi-layouts 0.2.0-dev",
"komorebi-linux 0.2.0-dev",
"komorebi-macos 0.2.0-dev",
"komorebi-macos-core 0.2.0-dev",
"komorebi-protocol 0.2.0-dev",
"komorebi-recorder 0.1.0-dev",
"komorebi-scrolling 0.2.0-dev",
+1
View File
@@ -5,6 +5,7 @@
"komorebi-collections",
"komorebi-core",
"komorebi-layouts",
"komorebi-macos-core",
"komorebi-scrolling",
"komorebi-settings",
"komorebi-themes",