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) } }