Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Assets/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1.3</string>
<string>1.2.0</string>
<key>CFBundleVersion</key>
<string>16</string>
<string>17</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion Sources/EncajeApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 23 additions & 4 deletions Sources/EncajeApp/UI/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/EncajeApp/UI/UpdateViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? ""
Expand Down
59 changes: 50 additions & 9 deletions Sources/EncajeApp/WindowAccessibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
85 changes: 85 additions & 0 deletions Sources/EncajeApp/WindowAnimator.swift
Original file line number Diff line number Diff line change
@@ -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() }
}
}
Loading
Loading