diff --git a/Assets/Info.plist b/Assets/Info.plist
index 972958c..0d6dd3b 100644
--- a/Assets/Info.plist
+++ b/Assets/Info.plist
@@ -22,9 +22,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.1.3
+ 1.2.0
CFBundleVersion
- 16
+ 17
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 563b8b4..d772f4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to this project are documented here.
## [Unreleased]
+## [1.2.0] - 2026-09-23
+
+### Added
+
+- Windows glide into place when you use a shortcut, undo or restore, in about a fifth of a second. Turn it off with "Animate windows" in Preferences; it also stays off while Reduce Motion is on. Moves to another display stay instant.
+
+### Changed
+
+- Window spacing now starts at 0 pt, so windows sit edge to edge unless you choose a spacing in Preferences. A spacing you already set is kept.
+- Preferences icons share one column and the spacing slider sits next to its label.
+
+### Fixed
+
+- A window that its app keeps taller or wider than a bottom zone is moved back inside the screen instead of ending partly below it.
+- Warnings after a shortcut, such as "This application limits its window size or position", now disappear on their own after a few seconds.
+
## [1.1.3] - 2026-09-18
### Fixed
diff --git a/README.es.md b/README.es.md
index 4b6737a..ce8de2a 100644
--- a/README.es.md
+++ b/README.es.md
@@ -19,9 +19,12 @@ propias zonas, asigna atajos y mueve ventanas entre monitores. No requiere cuent
- Asignar atajos que combinen Command, Option, Control y Shift.
- Deshacer y rehacer cambios de la cuadrícula, y comparar zonas con contornos de referencia.
- Mover ventanas entre monitores vecinos repitiendo un atajo direccional.
+- Las ventanas se deslizan a su lugar en una quinta de segundo; puedes desactivarlo cuando
+ quieras y se mantiene apagado con Reducir movimiento.
- Guardar espacios de trabajo y restaurar la posición de las ventanas abiertas que coincidan.
- Pausar desde la barra de menús o excluir aplicaciones de los atajos.
-- Ajustar el espaciado y el inicio de sesión opcional, con una guía nativa de Accesibilidad.
+- Ajustar la separación entre ventanas (ninguna por defecto) y el inicio de sesión opcional,
+ con una guía nativa de Accesibilidad.
- Cambiar al instante entre inglés, español y el idioma del sistema.
- Recibir actualizaciones firmadas mediante Sparkle 2, con comprobaciones diarias
opcionales y una acción explícita para instalar.
diff --git a/README.md b/README.md
index 038615d..8a5e041 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,12 @@ zones, assign shortcuts, and move windows between displays. No account is requir
- Assign shortcuts using Command, Option, Control and Shift combinations.
- Undo and redo grid edits, and compare zones using selectable reference outlines.
- Move windows between neighboring displays with repeated directional shortcuts.
+- Windows glide into place in about a fifth of a second; turn it off anytime, and it
+ stays off with Reduce Motion.
- Save workspaces and restore the positions of matching open windows.
- Pause from the menu bar or exclude individual apps from shortcuts.
-- Set spacing and optional launch at login, with native Accessibility setup.
+- Set window spacing (none by default) and optional launch at login, with native
+ Accessibility setup.
- Switch instantly between English, Spanish and the system language.
- Receive signed in-app updates through Sparkle 2, with optional daily checks and
an explicit action to install.
diff --git a/Sources/EncajeApp/AppModel.swift b/Sources/EncajeApp/AppModel.swift
index fe4c48b..8e6b8fe 100644
--- a/Sources/EncajeApp/AppModel.swift
+++ b/Sources/EncajeApp/AppModel.swift
@@ -24,6 +24,12 @@ final class AppModel: ObservableObject {
}
var onLanguageChange: (() -> Void)?
@Published var gap: Double { didSet { defaults.set(gap, forKey: "gap") } }
+ @Published var animatesWindows: Bool {
+ didSet {
+ defaults.set(animatesWindows, forKey: "animatesWindows")
+ engine.animates = animatesWindows
+ }
+ }
@Published var exclusions: String {
didSet {
defaults.set(exclusions, forKey: "exclusions")
@@ -55,12 +61,14 @@ final class AppModel: ObservableObject {
defaults = suite.flatMap(UserDefaults.init(suiteName:)) ?? .standard
language = AppLanguage(rawValue: defaults.string(forKey: "language") ?? "") ?? .system
permissions = PermissionCoordinator(defaults: defaults)
- gap = defaults.object(forKey: "gap") as? Double ?? 8
+ gap = defaults.object(forKey: "gap") as? Double ?? 0
+ animatesWindows = defaults.object(forKey: "animatesWindows") as? Bool ?? true
exclusions = defaults.string(forKey: "exclusions") ?? ""
rules =
Self.decode([WindowRule].self, defaults: defaults, key: "rulesV2")
?? WindowRule.migrating(Self.decode([String: Int].self, defaults: defaults, key: "bindings"))
layouts = Self.decode([SavedLayout].self, defaults: defaults, key: "layouts") ?? []
+ engine.animates = animatesWindows
permissions.$granted.removeDuplicates().sink { [weak self] granted in
Task { @MainActor in self?.permissionChanged(granted) }
}.store(in: &observers)
diff --git a/Sources/EncajeApp/UI/PreferencesView.swift b/Sources/EncajeApp/UI/PreferencesView.swift
index d71c8a4..3158de0 100644
--- a/Sources/EncajeApp/UI/PreferencesView.swift
+++ b/Sources/EncajeApp/UI/PreferencesView.swift
@@ -80,14 +80,24 @@ struct PreferencesView: View {
}.frame(maxWidth: .infinity)
Divider()
HStack {
+ Label(localized("Animate windows", "Animar ventanas"), systemImage: "sparkles")
+ Spacer()
+ Toggle(localized("Animate windows", "Animar ventanas"), isOn: $model.animatesWindows)
+ .labelsHidden().toggleStyle(.settingSwitch)
+ }.frame(maxWidth: .infinity)
+ Divider()
+ HStack(spacing: 12) {
Label(localized("Window spacing", "Separación entre ventanas"), systemImage: "space")
Spacer()
+ Slider(value: $model.gap, in: 0...32, step: 1)
+ .frame(width: 240)
+ .accessibilityLabel(localized("Window spacing", "Separación entre ventanas"))
Text("\(Int(model.gap)) pt").font(.callout.monospacedDigit()).foregroundStyle(
- .secondary)
+ .secondary
+ )
+ .frame(width: 38, alignment: .trailing)
}
- Slider(value: $model.gap, in: 0...32, step: 1)
- .accessibilityLabel(localized("Window spacing", "Separación entre ventanas"))
- }.padding(9)
+ }.padding(9).labelStyle(SettingLabelStyle())
}
VStack(alignment: .leading, spacing: 10) {
HStack {
@@ -132,6 +142,15 @@ struct PreferencesView: View {
}
}
+struct SettingLabelStyle: LabelStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ HStack(spacing: 7) {
+ configuration.icon.frame(width: 20)
+ configuration.title
+ }
+ }
+}
+
private struct ExcludedAppRow: View {
let bundleID: String
var remove: () -> Void
diff --git a/Sources/EncajeApp/UI/UpdateViews.swift b/Sources/EncajeApp/UI/UpdateViews.swift
index d89248d..32ff6c1 100644
--- a/Sources/EncajeApp/UI/UpdateViews.swift
+++ b/Sources/EncajeApp/UI/UpdateViews.swift
@@ -8,7 +8,7 @@ struct UpdateSettingsView: View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label(localized("Updates", "Actualizaciones"), systemImage: "arrow.down.circle")
- .font(.headline)
+ .font(.headline).labelStyle(SettingLabelStyle())
Spacer()
Text(
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
diff --git a/Sources/EncajeApp/WindowAccessibility.swift b/Sources/EncajeApp/WindowAccessibility.swift
index a302afb..2ee8a3f 100644
--- a/Sources/EncajeApp/WindowAccessibility.swift
+++ b/Sources/EncajeApp/WindowAccessibility.swift
@@ -89,23 +89,64 @@ import EncajeCore
return Snapshot(frame: frame)
}
static func apply(_ target: CGRect, to window: AXUIElement, current: CGRect) -> Applied {
- var size = target.size
- var point = CGPoint(x: target.minX, y: originHeight - target.maxY)
- guard let s = AXValueCreate(.cgSize, &size), let p = AXValueCreate(.cgPoint, &point) else {
- return Applied(succeeded: false, actual: nil)
- }
+ guard let values = values(target) else { return Applied(succeeded: false, actual: nil) }
let plan = WindowMovePlan(current: current, target: target)
guard plan.resize || plan.reposition else { return Applied(succeeded: true, actual: current) }
- var success = true
- if plan.resize { success = write(window, name: kAXSizeAttribute, value: s) }
- if plan.reposition { success = write(window, name: kAXPositionAttribute, value: p) && success }
+ var success = place(values, on: window, plan: plan)
var actual = frame(window)
if plan.shouldRetrySize(target: target, observed: actual) {
- success = write(window, name: kAXSizeAttribute, value: s) && success
+ success = write(window, name: kAXSizeAttribute, value: values.size) && success
actual = frame(window)
}
return Applied(succeeded: success, actual: actual)
}
+ static func place(
+ _ target: CGRect, on window: AXUIElement, from current: CGRect, resizeFirst: Bool
+ ) -> Bool {
+ guard let values = values(target) else { return false }
+ return place(
+ values, on: window, plan: WindowMovePlan(current: current, target: target),
+ resizeFirst: resizeFirst)
+ }
+ private static func place(
+ _ values: (size: AXValue, point: AXValue), on window: AXUIElement, plan: WindowMovePlan,
+ resizeFirst: Bool = true
+ ) -> Bool {
+ var success = true
+ if plan.resize && resizeFirst {
+ success = write(window, name: kAXSizeAttribute, value: values.size)
+ }
+ if plan.reposition {
+ success = write(window, name: kAXPositionAttribute, value: values.point) && success
+ }
+ if plan.resize && !resizeFirst {
+ success = write(window, name: kAXSizeAttribute, value: values.size) && success
+ }
+ return success
+ }
+ static func suspendEnhancedUserInterface(of window: AXUIElement) -> AXUIElement? {
+ var pid: pid_t = 0
+ guard AXUIElementGetPid(window, &pid) == .success else { return nil }
+ let app = AXUIElementCreateApplication(pid)
+ AXUIElementSetMessagingTimeout(app, 0.3)
+ guard attribute(app, enhancedUserInterface) as? Bool == true,
+ AXUIElementSetAttributeValue(app, enhancedUserInterface as CFString, kCFBooleanFalse)
+ == .success
+ else { return nil }
+ return app
+ }
+ static func resumeEnhancedUserInterface(of app: AXUIElement) {
+ AXUIElementSetAttributeValue(app, enhancedUserInterface as CFString, kCFBooleanTrue)
+ }
+ private static let enhancedUserInterface = "AXEnhancedUserInterface"
+ private static func values(_ target: CGRect) -> (size: AXValue, point: AXValue)? {
+ var size = target.size
+ var point = CGPoint(x: target.minX, y: originHeight - target.maxY)
+ guard let s = AXValueCreate(.cgSize, &size), let p = AXValueCreate(.cgPoint, &point) else {
+ return nil
+ }
+ return (s, p)
+ }
private static func write(_ window: AXUIElement, name: String, value: CFTypeRef) -> Bool {
WindowPerformanceMetrics.measure(name == kAXSizeAttribute ? "writeSize" : "writePosition") {
WindowPerformanceMetrics.status(AXUIElementSetAttributeValue(window, name as CFString, value))
diff --git a/Sources/EncajeApp/WindowAnimator.swift b/Sources/EncajeApp/WindowAnimator.swift
new file mode 100644
index 0000000..d8f47e9
--- /dev/null
+++ b/Sources/EncajeApp/WindowAnimator.swift
@@ -0,0 +1,85 @@
+import AppKit
+import ApplicationServices
+import EncajeCore
+import QuartzCore
+
+@MainActor final class WindowAnimator: NSObject {
+ private struct Run {
+ let window: AXUIElement
+ let from: CGRect
+ let to: CGRect
+ let started: CFTimeInterval
+ var last: CGRect
+ let bounds: CGRect
+ let enhancedApp: AXUIElement?
+ let land: () -> Void
+ var steps = 0
+ var busy: CFTimeInterval = 0
+ var slowest: CFTimeInterval = 0
+ }
+ static let slowStep = 0.04
+ private var run: Run?
+ private var link: CADisplayLink?
+ private var generation = 0
+
+ func animate(
+ _ window: AXUIElement, from: CGRect, to: CGRect, within bounds: CGRect,
+ land: @escaping () -> Void
+ ) -> Bool {
+ complete()
+ let center = CGPoint(x: to.midX, y: to.midY)
+ guard let screen = NSScreen.screens.first(where: { $0.frame.contains(center) }) ?? NSScreen.main
+ else { return false }
+ let link = screen.displayLink(target: self, selector: #selector(step))
+ let rate = Float(min(120, max(60, screen.maximumFramesPerSecond)))
+ link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: rate, preferred: rate)
+ run = Run(
+ window: window, from: from, to: to, started: CACurrentMediaTime(), last: from,
+ bounds: bounds,
+ enhancedApp: WindowAccessibility.suspendEnhancedUserInterface(of: window), land: land)
+ link.add(to: .main, forMode: .common)
+ self.link = link
+ generation += 1
+ let current = generation
+ Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(WindowAnimation.duration + 0.25))
+ guard let self, self.generation == current else { return }
+ self.complete()
+ }
+ return true
+ }
+
+ func complete(finished: Bool = false) {
+ link?.invalidate()
+ link = nil
+ guard let run else { return }
+ self.run = nil
+ run.land()
+ if let app = run.enhancedApp { WindowAccessibility.resumeEnhancedUserInterface(of: app) }
+ guard WindowPerformanceMetrics.enabled else { return }
+ var record = WindowPerformanceMetrics.Record(action: "animate")
+ record.durationMS = (CACurrentMediaTime() - run.started) * 1000
+ record.calls = ["step": run.steps, "finished": finished ? 1 : 0]
+ record.phasesMS = ["step": run.busy * 1000, "slowestStep": run.slowest * 1000]
+ WindowPerformanceMetrics.write(record)
+ }
+
+ @objc private func step(_ link: CADisplayLink) {
+ guard var run else { return }
+ let progress = WindowAnimation.progress(elapsed: link.targetTimestamp - run.started)
+ guard progress < 1 else { return complete(finished: true) }
+ let frame = WindowGeometry.pointAligned(
+ WindowAnimation.frame(from: run.from, to: run.to, progress: progress))
+ let started = CACurrentMediaTime()
+ let placed = WindowAccessibility.place(
+ frame, on: run.window, from: run.last,
+ resizeFirst: WindowAnimation.resizesFirst(from: run.last, to: frame, within: run.bounds))
+ let elapsed = CACurrentMediaTime() - started
+ run.last = frame
+ run.steps += 1
+ run.busy += elapsed
+ run.slowest = max(run.slowest, elapsed)
+ self.run = run
+ if !placed || elapsed > Self.slowStep { complete() }
+ }
+}
diff --git a/Sources/EncajeApp/WindowEngine.swift b/Sources/EncajeApp/WindowEngine.swift
index c0e2803..ac430e7 100644
--- a/Sources/EncajeApp/WindowEngine.swift
+++ b/Sources/EncajeApp/WindowEngine.swift
@@ -10,8 +10,10 @@ import EncajeCore
}
var originals: [Snapshot] = []
var history: [Snapshot] = []
+ var animates = true
private var movementGeneration = 0
private let pendingMoves = WindowMoveRegistry()
+ private let animator = WindowAnimator()
private var lastDirectional:
(
window: AXUIElement, placement: DirectionalPlacement, zone: GridZone?, neighborZone: GridZone?
@@ -22,9 +24,11 @@ import EncajeCore
) {
WindowPerformanceMetrics.begin(action.rawValue)
defer { WindowPerformanceMetrics.finish() }
+ animator.complete()
guard AXIsProcessTrusted() else {
- lastMessage = localized(
- "Accessibility permission is required.", "Se necesita permiso de Accesibilidad.")
+ notify(
+ localized(
+ "Accessibility permission is required.", "Se necesita permiso de Accesibilidad."))
return
}
if action == .undo {
@@ -32,16 +36,17 @@ import EncajeCore
if let target = WindowGeometry.recovered(
snapshot.frame, displays: WindowAccessibility.displays)
{
- move(snapshot.window, to: target, remember: false)
+ move(snapshot.window, to: target, remember: false, animated: animates)
}
return
}
guard let window = WindowAccessibility.focused(),
let validated = WindowAccessibility.snapshot(window)
else {
- lastMessage = localized(
- "Select a movable application window.",
- "Selecciona una ventana de aplicación que se pueda mover.")
+ notify(
+ localized(
+ "Select a movable application window.",
+ "Selecciona una ventana de aplicación que se pueda mover."))
return
}
let frame = validated.frame
@@ -50,7 +55,7 @@ import EncajeCore
if let target = WindowGeometry.recovered(
original.frame, displays: WindowAccessibility.displays)
{
- move(window, to: target, validated: validated)
+ move(window, to: target, validated: validated, animated: animates)
}
} else {
let displays = WindowAccessibility.displays
@@ -70,7 +75,7 @@ import EncajeCore
if let target {
move(
window, to: target, action: action, gap: gap, validated: validated, zone: zone,
- neighborZone: neighborZone)
+ neighborZone: neighborZone, animated: animates)
}
}
}
@@ -79,8 +84,9 @@ import EncajeCore
func move(
_ window: AXUIElement, to requested: CGRect, remember: Bool = true, action: WindowAction? = nil,
gap: CGFloat = 0, verifyFeedback: Bool = true, validated: WindowAccessibility.Snapshot? = nil,
- zone: GridZone? = nil, neighborZone: GridZone? = nil
+ zone: GridZone? = nil, neighborZone: GridZone? = nil, animated: Bool = false
) -> Bool {
+ animator.complete()
let target = WindowGeometry.pointAligned(requested)
guard let validated = validated ?? WindowAccessibility.snapshot(window) else { return false }
let current = validated.frame
@@ -101,34 +107,60 @@ import EncajeCore
if history.count > 100 { history.removeFirst() }
if originals.count > 200 { originals.removeFirst() }
}
- let applied = WindowAccessibility.apply(target, to: window, current: current)
+ let displays = WindowAccessibility.displays
+ let targetDisplay = WindowGeometry.display(for: target, in: displays)
+ let crossesDisplay = WindowGeometry.display(for: current, in: displays)?.id != targetDisplay?.id
+ let landing = Landing(
+ window: window, current: current, target: target, crossesDisplay: crossesDisplay,
+ generation: movementGeneration, action: action, gap: gap, verifyFeedback: verifyFeedback,
+ zone: zone, neighborZone: neighborZone)
+ if animated && !crossesDisplay && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
+ let bounds = targetDisplay?.frame,
+ animator.animate(
+ window, from: current, to: target, within: bounds,
+ land: { [weak self] in self?.land(landing) })
+ {
+ return true
+ }
+ return land(landing)
+ }
+
+ private struct Landing {
+ let window: AXUIElement
+ let current: CGRect
+ let target: CGRect
+ let crossesDisplay: Bool
+ let generation: Int
+ let action: WindowAction?
+ let gap: CGFloat
+ let verifyFeedback: Bool
+ let zone: GridZone?
+ let neighborZone: GridZone?
+ }
+
+ @discardableResult
+ private func land(_ landing: Landing) -> Bool {
+ let window = landing.window
+ let target = landing.target
+ let generation = landing.generation
+ let applied = WindowAccessibility.apply(target, to: window, current: landing.current)
guard applied.succeeded else {
- pendingMoves.finish(window, generation: movementGeneration)
- lastMessage = localized(
- "This window could not be moved or resized.",
- "No se pudo mover o redimensionar esta ventana.")
+ pendingMoves.finish(window, generation: generation)
+ notify(
+ localized(
+ "This window could not be moved or resized.",
+ "No se pudo mover o redimensionar esta ventana."))
return false
}
lastMessage = nil
- if let action, let actual = applied.actual,
- let display = WindowGeometry.display(for: target, in: WindowAccessibility.displays)
- {
- lastDirectional = (
- window,
- DirectionalPlacement(
- action: action, actualFrame: actual,
- intendedFrame: target, display: display, gap: gap), zone, neighborZone
- )
+ if let actual = applied.actual {
+ remember(landing, actual: actual)
+ if !landing.crossesDisplay { keepOnScreen(landing, actual: actual) }
}
- let generation = movementGeneration
- let displays = WindowAccessibility.displays
- let crossesDisplay =
- WindowGeometry.display(for: current, in: displays)?.id
- != WindowGeometry.display(for: target, in: displays)?.id
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.pendingMoves.finish(window, generation: generation) }
- if crossesDisplay, var observed = applied.actual {
+ if landing.crossesDisplay, var observed = applied.actual {
for delay in [30, 60] {
if WindowGeometry.approximately(observed, target) { break }
try? await Task.sleep(for: .milliseconds(delay))
@@ -139,28 +171,50 @@ import EncajeCore
let corrected = WindowAccessibility.apply(target, to: window, current: settled)
guard corrected.succeeded, let actual = corrected.actual else { return }
observed = actual
- if self.movementGeneration == generation, let action,
- let display = WindowGeometry.display(for: target, in: WindowAccessibility.displays)
- {
- self.lastDirectional = (
- window,
- DirectionalPlacement(
- action: action, actualFrame: actual, intendedFrame: target,
- display: display, gap: gap), zone, neighborZone
- )
- }
+ if self.movementGeneration == generation { self.remember(landing, actual: actual) }
}
}
try? await Task.sleep(for: .milliseconds(150))
guard self.movementGeneration == generation,
let actual = WindowAccessibility.frame(window)
else { return }
- if verifyFeedback && !WindowGeometry.approximately(actual, target) {
- self.lastMessage = localized(
- "This application limits its window size or position.",
- "Esta aplicación limita el tamaño o la posición de su ventana.")
+ if landing.verifyFeedback && !WindowGeometry.approximately(actual, target) {
+ self.notify(
+ localized(
+ "This application limits its window size or position.",
+ "Esta aplicación limita el tamaño o la posición de su ventana."))
}
}
return true
}
+
+ private func keepOnScreen(_ landing: Landing, actual: CGRect) {
+ guard
+ let display = WindowGeometry.display(for: landing.target, in: WindowAccessibility.displays),
+ let fitted = WindowGeometry.recovered(actual, displays: [display]),
+ !WindowGeometry.approximately(fitted, actual, tolerance: 0.5)
+ else { return }
+ let placed = WindowAccessibility.apply(fitted, to: landing.window, current: actual)
+ if placed.succeeded, let realized = placed.actual { remember(landing, actual: realized) }
+ }
+
+ private func notify(_ message: String) {
+ lastMessage = message
+ Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(6))
+ if self?.lastMessage == message { self?.lastMessage = nil }
+ }
+ }
+
+ private func remember(_ landing: Landing, actual: CGRect) {
+ guard let action = landing.action,
+ let display = WindowGeometry.display(for: landing.target, in: WindowAccessibility.displays)
+ else { return }
+ lastDirectional = (
+ landing.window,
+ DirectionalPlacement(
+ action: action, actualFrame: actual, intendedFrame: landing.target, display: display,
+ gap: landing.gap), landing.zone, landing.neighborZone
+ )
+ }
}
diff --git a/Sources/EncajeApp/WindowPerformanceMetrics.swift b/Sources/EncajeApp/WindowPerformanceMetrics.swift
index de6e45a..0d34140 100644
--- a/Sources/EncajeApp/WindowPerformanceMetrics.swift
+++ b/Sources/EncajeApp/WindowPerformanceMetrics.swift
@@ -48,11 +48,17 @@ import Foundation
return (result, completed)
}
+ static var enabled: Bool { destination != nil }
+
static func finish() {
- guard var completed = record, let destination else { return }
+ guard var completed = record else { return }
record = nil
completed.durationMS = Double(DispatchTime.now().uptimeNanoseconds - started) / 1_000_000
- guard var data = try? JSONEncoder().encode(completed) else { return }
+ write(completed)
+ }
+
+ static func write(_ completed: Record) {
+ guard let destination, var data = try? JSONEncoder().encode(completed) else { return }
data.append(0x0A)
let output = data
writer.async {
diff --git a/Sources/EncajeCore/WindowAnimation.swift b/Sources/EncajeCore/WindowAnimation.swift
new file mode 100644
index 0000000..d91f8d9
--- /dev/null
+++ b/Sources/EncajeCore/WindowAnimation.swift
@@ -0,0 +1,48 @@
+import CoreGraphics
+import Foundation
+
+public enum WindowAnimation {
+ public static let duration = 0.22
+
+ public static func progress(elapsed: Double, duration: Double = duration) -> CGFloat {
+ let t = min(max(elapsed / duration, 0), 1)
+ return CGFloat(1 - (1 - t) * (1 - t))
+ }
+
+ public static func frame(from start: CGRect, to end: CGRect, progress: CGFloat) -> CGRect {
+ CGRect(
+ x: start.minX + (end.minX - start.minX) * progress,
+ y: start.minY + (end.minY - start.minY) * progress,
+ width: start.width + (end.width - start.width) * progress,
+ height: start.height + (end.height - start.height) * progress)
+ }
+
+ public static func resizesFirst(from previous: CGRect, to next: CGRect, within bounds: CGRect)
+ -> Bool
+ {
+ guard previous.minX + next.width <= bounds.maxX + 0.5 else { return false }
+ let resized = CGRect(
+ x: previous.minX, y: previous.maxY - next.height, width: next.width, height: next.height)
+ let moved = CGRect(
+ x: next.minX, y: next.maxY - previous.height, width: previous.width, height: previous.height)
+ return drift(resized, from: previous, to: next, within: bounds)
+ <= drift(moved, from: previous, to: next, within: bounds)
+ }
+
+ private static func drift(
+ _ frame: CGRect, from previous: CGRect, to next: CGRect, within bounds: CGRect
+ ) -> CGFloat {
+ func outside(_ value: CGFloat, _ a: CGFloat, _ b: CGFloat, _ low: CGFloat, _ high: CGFloat)
+ -> CGFloat
+ {
+ let visible = min(max(value, low), high)
+ let lower = min(max(min(a, b), low), high)
+ let upper = min(max(max(a, b), low), high)
+ return max(0, lower - visible, visible - upper)
+ }
+ return outside(frame.minX, previous.minX, next.minX, bounds.minX, bounds.maxX)
+ + outside(frame.maxX, previous.maxX, next.maxX, bounds.minX, bounds.maxX)
+ + outside(frame.minY, previous.minY, next.minY, bounds.minY, bounds.maxY)
+ + outside(frame.maxY, previous.maxY, next.maxY, bounds.minY, bounds.maxY)
+ }
+}
diff --git a/Tests/EncajeCoreTests/WindowAnimationTests.swift b/Tests/EncajeCoreTests/WindowAnimationTests.swift
new file mode 100644
index 0000000..1bb37ca
--- /dev/null
+++ b/Tests/EncajeCoreTests/WindowAnimationTests.swift
@@ -0,0 +1,77 @@
+import CoreGraphics
+import XCTest
+
+@testable import EncajeCore
+
+final class WindowAnimationTests: XCTestCase {
+ func testProgressEasesOutAndClamps() {
+ XCTAssertEqual(WindowAnimation.progress(elapsed: -1), 0)
+ XCTAssertEqual(WindowAnimation.progress(elapsed: 0), 0)
+ XCTAssertEqual(WindowAnimation.progress(elapsed: WindowAnimation.duration), 1)
+ XCTAssertEqual(WindowAnimation.progress(elapsed: 5), 1)
+ let half = WindowAnimation.progress(elapsed: WindowAnimation.duration / 2)
+ XCTAssertEqual(half, 0.75, accuracy: 0.0001)
+ var previous: CGFloat = 0
+ for step in 1...20 {
+ let value = WindowAnimation.progress(elapsed: WindowAnimation.duration * Double(step) / 20)
+ XCTAssertGreaterThan(value, previous)
+ previous = value
+ }
+ }
+
+ func testMotionIsSpreadAcrossFrames() {
+ let frames = 13
+ let deltas = (1...frames).map { index in
+ WindowAnimation.progress(elapsed: WindowAnimation.duration * Double(index) / Double(frames))
+ - WindowAnimation.progress(
+ elapsed: WindowAnimation.duration * Double(index - 1) / Double(frames))
+ }
+ XCTAssertLessThan(deltas.max() ?? 1, 0.16)
+ XCTAssertGreaterThan(deltas.filter { $0 > 0.05 }.count, 8)
+ }
+
+ func testWriteOrderKeepsVisibleEdgesFromStepping() {
+ let screen = CGRect(x: 0, y: 0, width: 1440, height: 870)
+ XCTAssertFalse(
+ WindowAnimation.resizesFirst(
+ from: CGRect(x: 0, y: 0, width: 1440, height: 870),
+ to: CGRect(x: 100, y: 0, width: 1340, height: 870), within: screen))
+ XCTAssertFalse(
+ WindowAnimation.resizesFirst(
+ from: CGRect(x: 720, y: 0, width: 720, height: 870),
+ to: CGRect(x: 620, y: 0, width: 820, height: 870), within: screen))
+ XCTAssertTrue(
+ WindowAnimation.resizesFirst(
+ from: CGRect(x: 0, y: 0, width: 660, height: 870),
+ to: CGRect(x: 100, y: 0, width: 700, height: 870), within: screen))
+ XCTAssertTrue(
+ WindowAnimation.resizesFirst(
+ from: CGRect(x: 0, y: 0, width: 660, height: 253),
+ to: CGRect(x: 0, y: 0, width: 760, height: 320), within: screen))
+ XCTAssertFalse(
+ WindowAnimation.resizesFirst(
+ from: CGRect(x: 0, y: 0, width: 1440, height: 870),
+ to: CGRect(x: 90, y: 0, width: 1350, height: 800), within: screen))
+ }
+
+ func testFrameInterpolatesEveryEdge() {
+ let start = CGRect(x: 0, y: 100, width: 400, height: 300)
+ let end = CGRect(x: 800, y: 0, width: 1000, height: 900)
+ XCTAssertEqual(WindowAnimation.frame(from: start, to: end, progress: 0), start)
+ XCTAssertEqual(WindowAnimation.frame(from: start, to: end, progress: 1), end)
+ XCTAssertEqual(
+ WindowAnimation.frame(from: start, to: end, progress: 0.5),
+ CGRect(x: 400, y: 50, width: 700, height: 600))
+ }
+
+ func testIntermediateFramesStayInsideTheSpanOfBothFrames() {
+ let bounds = CGRect(x: 0, y: 0, width: 1440, height: 875)
+ let start = CGRect(x: 0, y: 0, width: 720, height: 875)
+ let end = CGRect(x: 960, y: 437.5, width: 480, height: 437.5)
+ for step in 0...20 {
+ let frame = WindowAnimation.frame(
+ from: start, to: end, progress: WindowAnimation.progress(elapsed: 0.01 * Double(step)))
+ XCTAssertTrue(bounds.contains(frame))
+ }
+ }
+}
diff --git a/Tests/EncajeCoreTests/WindowGeometryTests.swift b/Tests/EncajeCoreTests/WindowGeometryTests.swift
index 581b71c..6dbad47 100644
--- a/Tests/EncajeCoreTests/WindowGeometryTests.swift
+++ b/Tests/EncajeCoreTests/WindowGeometryTests.swift
@@ -21,6 +21,15 @@ final class WindowGeometryTests: XCTestCase {
XCTAssertEqual(
WindowGeometry.target(for: .left, window: frame, displays: screens, gap: 0), frame)
}
+ func testClampedBottomZoneWindowIsLiftedBackOnScreen() throws {
+ let usable = DisplayArea(id: "usable", frame: CGRect(x: 0, y: 0, width: 1440, height: 870))
+ let clamped = CGRect(x: 660, y: -63, width: 780, height: 316)
+ XCTAssertEqual(
+ WindowGeometry.recovered(clamped, displays: [usable]),
+ CGRect(x: 660, y: 0, width: 780, height: 316))
+ let fitting = CGRect(x: 660, y: 0, width: 780, height: 316)
+ XCTAssertEqual(WindowGeometry.recovered(fitting, displays: [usable]), fitting)
+ }
func testVerticalDirectionsUseAppKitCoordinates() throws {
let top = DisplayArea(id: "top", frame: CGRect(x: 100, y: 900, width: 800, height: 600))
let bottom = DisplayArea(id: "bottom", frame: CGRect(x: 0, y: -900, width: 1200, height: 800))
diff --git a/docs/validation.md b/docs/validation.md
index 727a09d..ac61d31 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -34,6 +34,15 @@ alone does not establish the intended window size. AppKit may clip an initial
resize by its previous position even on the same display; the engine conditionally
retries when the observed size proves that correction is needed.
+## Window animation
+
+Launch the fixture with `ENCAJE_FIXTURE_FRAMES_FILE=` and it appends every frame
+change it applies (` `, Cocoa coordinates). With
+`ENCAJE_MOVEMENT_METRICS_FILE`, Encaje writes one `animate` record per animation: AX steps,
+whether it ran to the end, total and slowest step time. An animation must end on the exact
+frame of the instant move, a new command must land the running one first, and idle CPU
+must return to 0.0% once it ends. A step slower than 40 ms lands the window at once.
+
## Interactive checks
- Record Command+W without closing Settings; record Control+Option+key; cancel and
diff --git a/scripts/build-fixture.sh b/scripts/build-fixture.sh
index 432e232..db6f951 100755
--- a/scripts/build-fixture.sh
+++ b/scripts/build-fixture.sh
@@ -4,7 +4,7 @@ cd "$(dirname "$0")/.."
fixture_dir="$(mktemp -d /tmp/encaje-fixture.XXXXXX)"
fixture="$fixture_dir/Encaje Performance Fixture.app"
mkdir -p "$fixture/Contents/MacOS"
-swiftc -swift-version 6 scripts/fixtures/WindowFixture.swift -o "$fixture/Contents/MacOS/EncajeFixture"
+swiftc -swift-version 6 -target arm64-apple-macos14.0 scripts/fixtures/WindowFixture.swift -o "$fixture/Contents/MacOS/EncajeFixture"
cat > "$fixture/Contents/Info.plist" <<'PLIST'
diff --git a/scripts/fixtures/WindowFixture.swift b/scripts/fixtures/WindowFixture.swift
index 66ab1bc..b092eeb 100644
--- a/scripts/fixtures/WindowFixture.swift
+++ b/scripts/fixtures/WindowFixture.swift
@@ -2,6 +2,14 @@ import AppKit
@MainActor final class FixtureDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow?
+ var frames: FileHandle?
+ @objc func frameChanged(_ notification: Notification) {
+ guard let frame = window?.frame else { return }
+ let line = String(
+ format: "%.4f %.1f %.1f %.1f %.1f\n", ProcessInfo.processInfo.systemUptime,
+ frame.minX, frame.minY, frame.width, frame.height)
+ frames?.write(Data(line.utf8))
+ }
func applicationDidFinishLaunching(_ notification: Notification) {
let window = NSWindow(contentRect: NSRect(x: 160, y: 160, width: 720, height: 460),
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
@@ -16,6 +24,15 @@ import AppKit
window.isReleasedWhenClosed = false
window.makeKeyAndOrderFront(nil)
self.window = window
+ if let path = ProcessInfo.processInfo.environment["ENCAJE_FIXTURE_FRAMES_FILE"],
+ FileManager.default.createFile(atPath: path, contents: nil)
+ {
+ frames = FileHandle(forWritingAtPath: path)
+ for name in [NSWindow.didMoveNotification, NSWindow.didResizeNotification] {
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(frameChanged), name: name, object: window)
+ }
+ }
NSApplication.shared.activate(ignoringOtherApps: true)
}
}