Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f0d2747
Add experimental snapping and restore animation
kiteretsu903 Sep 7, 2026
0f9e238
Add an adaptive frosted snap preview
kiteretsu903 Sep 7, 2026
93591e4
Expose animation and blur options in Snap Areas
kiteretsu903 Sep 7, 2026
43dbe04
Update permission guidance for macOS 27
kiteretsu903 Sep 7, 2026
583b008
Document experimental animation and preview preferences
kiteretsu903 Sep 7, 2026
8c0b372
Preserve title-bar animations on mouse release
kiteretsu903 Sep 7, 2026
a7c0fa8
Use DoubleDefault for footprint alpha
kiteretsu903 Sep 8, 2026
26a8c70
Trim animation and preview comments
kiteretsu903 Sep 8, 2026
991af18
Refine minimum window size warning HUD
kiteretsu903 Sep 8, 2026
6479a3c
Keep constrained windows aligned throughout snap animations
kiteretsu903 Sep 8, 2026
b105349
Improve drag restore responsiveness and quick-release handling
kiteretsu903 Sep 8, 2026
dc8c2fb
Implement Direct Resize and Blue preview window animations
kiteretsu903 Sep 13, 2026
23570cb
Correct Blur preview label and translations
kiteretsu903 Sep 13, 2026
b19a77c
Fix Dock resize reversals and incomplete drag restoration
kiteretsu903 Sep 13, 2026
b0b9dab
Prevent browser tab clicks from triggering titlebar actions
kiteretsu903 Sep 13, 2026
5109b5d
Fix Direct Resize restoration and aspect-ratio alignment
kiteretsu903 Sep 13, 2026
9cce804
Preserve size-change restore suppression in Frosted paths
kiteretsu903 Sep 14, 2026
723c135
Revert "Prevent browser tab clicks from triggering titlebar actions"
kiteretsu903 Sep 14, 2026
edfd474
Extract blur preview animation from the direct resize branch
kiteretsu903 Sep 15, 2026
833b061
Prevent direct resize reversals and verify final window placement
kiteretsu903 Sep 15, 2026
8a1c0d9
Reduce endpoint jumps when retrying stalled window shrinks
kiteretsu903 Sep 15, 2026
dc865bc
Refine direct window animation pacing and constrained placement
kiteretsu903 Sep 15, 2026
9bb5f4d
Smooth keyboard window transitions and preserve motion when retargeting
kiteretsu903 Sep 15, 2026
d04f33b
Removed AI artifacts, moved enum to end of Defaults
rxhanson Sep 16, 2026
4327bd7
Pass window animator in as an instance variable
rxhanson Sep 16, 2026
f838b01
Removed trace logging and cleaned up animate calls
rxhanson Sep 16, 2026
001d212
Allow configuration of special-cased animation handling apps like IINA
rxhanson Sep 16, 2026
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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Drag a window to the edge of the screen. When the mouse cursor reaches the edge
| Bottom left, center, or right third | Respective third |
| Bottom left or right third, then drag to bottom center | First or last two thirds, respectively |

In **Snap Areas**, enable **Animate windows (experimental)** for smooth window resizing or **Blur footprint** for a blurred snap preview. Both are off by default. Animated snapping keeps the selected edge or center aligned when an app limits its window size or aspect ratio. Dragging a snapped window starts restoring its previous size as the window begins moving, and a quick release lets the remaining restore animation continue. Far-right title-bar grabs can still show visible jumps. Title-bar double-click maximize/restore follows the same animation setting and timing as snapping. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases.

### Tile windows in rows or columns

Enable **Show additional sizes in menu** to include Rows and Columns in the **Tiling** submenu. Their keyboard shortcuts remain available when the submenu is hidden.
Expand Down Expand Up @@ -94,7 +96,7 @@ Apple never released a public API for doing this. Rectangle Pro has next/prev Sp

### Windows overlap when using thirds or other small layouts

Some apps enforce a minimum window size that is larger than the requested layout. For example, a window with a minimum width of 600 points cannot fit a 504-point third of a display. Rectangle keeps the window on screen and briefly shows a “Window size limited” message when the app leaves it larger than the requested size. Use a larger layout, such as halves, or reduce the adjacent window manually. Rectangle cannot override an app's minimum window size.
Some apps enforce a minimum window size that is larger than the requested layout. For example, a window with a minimum width of 600 points cannot fit a 504-point third of a display. Rectangle keeps the window on screen and briefly shows a “Minimum window size reached” HUD with a window icon when the app leaves it larger than the requested size. Use a larger layout, such as halves, or reduce the adjacent window manually. Rectangle cannot override an app's minimum window size.

### Window resizing is off slightly for iTerm2

Expand Down
40 changes: 26 additions & 14 deletions Rectangle.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,21 @@ class AccessibilityViewController: NSViewController {
@IBOutlet weak var padlockField: NSTextField!

override func viewDidLoad() {
if #available(OSX 13, *) {
sysPrefsPathField.stringValue = NSLocalizedString(
super.viewDidLoad()
if #available(macOS 27, *) {
sysPrefsPathField.stringValue = NSLocalizedString(
"Go to System Settings → Privacy & Security → Device Control and Data Access", tableName: "Main", value: "", comment: "")
} else if #available(macOS 13, *) {
sysPrefsPathField.stringValue = NSLocalizedString(
"Go to System Settings → Privacy & Security → Accessibility", tableName: "Main", value: "", comment: "")
}
if #available(macOS 13, *) {
openSysPrefsButton.title = NSLocalizedString(
"Open System Settings", tableName: "Main", value: "", comment: "")
padlockField.isHidden = true
}
sysPrefsPathField.preferredMaxLayoutWidth = 250
sysPrefsPathField.maximumNumberOfLines = 0
}

@IBAction func openSystemPrefs(_ sender: Any) {
Expand Down
169 changes: 151 additions & 18 deletions Rectangle/AccessibilityElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@ class AccessibilityElement {
guard let role = role else { return nil }
return role == .staticText
}

func isTitleBarSpacer(in titleBar: CGRect) -> Bool {
guard role == .splitter else { return false }
var settable: DarwinBoolean = false
let status = AXUIElementIsAttributeSettable(wrappedElement, kAXValueAttribute as CFString, &settable)
let value = wrappedElement.getValue(.value)
return Self.isTitleBarSpacer(role: role, parentRole: getElementValue(.parent)?.role,
frame: frame, titleBar: titleBar, childCount: childElements?.count,
hasValue: value != nil && (value as? String) != "",
hasOrientation: wrappedElement.getValue(.orientation) != nil,
valueSettable: status == .success ? settable.boolValue : nil)
}

/// Some toolkits expose empty titlebar spacing as a splitter. A real
/// adjustable divider, or spacing below the titlebar, keeps its own clicks.
static func isTitleBarSpacer(role: NSAccessibility.Role?, parentRole: NSAccessibility.Role?,
frame: CGRect, titleBar: CGRect, childCount: Int?,
hasValue: Bool, hasOrientation: Bool, valueSettable: Bool?) -> Bool {
role == .splitter && parentRole == .toolbar && childCount == 0
&& !hasValue && !hasOrientation && valueSettable == false
&& !frame.isEmpty && !frame.isInfinite && titleBar.contains(frame)
}

private var subrole: NSAccessibility.Subrole? {
guard let value = wrappedElement.getValue(.subrole) as? String else { return nil }
Expand Down Expand Up @@ -131,7 +153,7 @@ class AccessibilityElement {
/// The Accessebility API only allows size & position adjustments individually.
/// To handle moving to different displays, we have to adjust the size then the position, then the size again since macOS will enforce sizes that fit on the current display.
/// When windows take a long time to adjust size & position, there is some visual stutter with doing each of these actions. The stutter can be slightly reduced by removing the initial size adjustment, which can make unsnap restore appear smoother.
func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) {
func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) {
let appElement = applicationElement
let builtInAssistiveTechnologyEnabled = NSWorkspace.shared.isVoiceOverEnabled
|| NSWorkspace.shared.isSwitchControlEnabled
Expand All @@ -149,11 +171,90 @@ class AccessibilityElement {
if adjustSizeFirst {
size = frame.size
}
position = frame.origin
if adjustPosition { position = frame.origin }
size = frame.size
}
)
}

/// Holds the Enhanced UI policy for the transition; returns its cleanup closure.
func beginAnimatedAdjustment() -> () -> Void {
let appElement = applicationElement
let restore = Defaults.enhancedUI.value.beginWindowAdjustment(
bundleIdentifier: appElement?.bundleIdentifier,
builtInAssistiveTechnologyEnabled: NSWorkspace.shared.isVoiceOverEnabled
|| NSWorkspace.shared.isSwitchControlEnabled,
readEnhancedUI: { appElement?.enhancedUserInterface },
writeEnhancedUI: { appElement?.enhancedUserInterface = $0 }
)
// Bound AX calls so an unresponsive app cannot stall the animation.
setMessagingTimeout(0.05)
return { [self] in
setMessagingTimeout(0)
restore()
}
}

/// Writes one frame without readback; completion handles the final placement.
func setAnimationFrame(_ frame: CGRect, resizeOnly: Bool = false) -> Bool {
var size = frame.size
var position = frame.origin
guard let sizeValue = AXValueCreate(.cgSize, &size),
let positionValue = AXValueCreate(.cgPoint, &position) else { return false }
guard AXUIElementSetAttributeValue(wrappedElement, kAXSizeAttribute as CFString, sizeValue) == .success else { return false }
// Native dragging owns position during size restoration.
if !resizeOnly {
guard AXUIElementSetAttributeValue(wrappedElement, kAXPositionAttribute as CFString, positionValue) == .success else { return false }
}
return true
}

func setConstrainedAnimationFrame(_ frame: CGRect, placement: WindowAnimationPlacement,
origin: CGRect, progress: CGFloat, previousFrame: CGRect? = nil,
maximumCorrection: CGFloat = 0) -> CGRect? {
var preparedPosition: CGPoint?
if let previousFrame,
let position = placement.positionBeforeGrowing(from: previousFrame, to: frame),
writeAnimationPosition(position) == .success {
preparedPosition = position
}
// Resize before moving on shrinking axes: a refused shrink must not carry the wider window
// to the narrower frame's origin and leave it behind the Dock until completion.
let sizeUnchanged = progress < 1 && previousFrame?.size == frame.size
let resized = sizeUnchanged || writeAnimationSize(frame.size) == .success
let actualSize = size.flatMap { size -> CGSize? in
guard size.width.isFinite, size.height.isFinite,
size.width > 0, size.height > 0 else { return nil }
return size
}
// Finish positioning with the size the app reports. Shrinking axes must
// not move to the requested origin and then move back after a delayed
// Chromium readback. Continue moving even if resizing failed;
// native display settlement must not stall every intermediate position.
var resolved = placement.frame(for: frame, actualSize: actualSize ?? frame.size,
origin: origin, progress: progress)
if progress < 1, let previousFrame {
let previous = CGRect(origin: preparedPosition ?? previousFrame.origin, size: previousFrame.size)
resolved = placement.intermediateFrame(resolved, requested: frame, previous: previous, maximumCorrection: maximumCorrection)
}
if (preparedPosition ?? previousFrame?.origin) != resolved.origin {
guard writeAnimationPosition(resolved.origin) == .success else { return nil }
}
guard resized, actualSize != nil else { return nil }
return resolved
}

func writeAnimationPosition(_ position: CGPoint) -> AXError {
var position = position
guard let value = AXValueCreate(.cgPoint, &position) else { return .failure }
return AXUIElementSetAttributeValue(wrappedElement, kAXPositionAttribute as CFString, value)
}

func writeAnimationSize(_ size: CGSize) -> AXError {
var size = size
guard let value = AXValueCreate(.cgSize, &size) else { return .failure }
return AXUIElementSetAttributeValue(wrappedElement, kAXSizeAttribute as CFString, value)
}

private var childElements: [AccessibilityElement]? {
getElementsValue(.children)
Expand Down Expand Up @@ -354,16 +455,31 @@ extension AccessibilityElement {
return nil
}

private static func getWindowInfo(_ location: CGPoint) -> WindowInfo? {
WindowUtil.getWindowList().first(where: {windowInfo in
private static func getWindowInfo(_ location: CGPoint, eventWindowID: CGWindowID? = nil) -> WindowInfo? {
// Ignore Rectangle preview surfaces when selecting an application window.
let ignoredWindows = Set(NSApp.windows.compactMap { window in
window.ignoresMouseEvents ? CGWindowID(exactly: window.windowNumber) : nil
})
// A new click can follow a focus change or reveal within the list's
// 100 ms cache lifetime. Select from the current stacking order.
return windowInfoUnderCursor(at: location, windows: WindowUtil.getWindowList(ids: eventWindowID.map { [$0] }, forceRefresh: true),
eventWindowID: eventWindowID, ignoring: ignoredWindows)
}

static func windowInfoUnderCursor(at location: CGPoint, windows: [WindowInfo],
eventWindowID: CGWindowID? = nil,
ignoring ignoredWindows: Set<CGWindowID> = []) -> WindowInfo? {
windows.first(where: { windowInfo in
windowInfo.level < 21 // 21 is the level of the Notification Center
&& windowInfo.alpha > 0
&& !ignoredWindows.contains(windowInfo.id)
&& !["Dock", "WindowManager"].contains(windowInfo.processName)
&& windowInfo.frame.contains(location)
&& (eventWindowID.map { windowInfo.id == $0 } ?? windowInfo.frame.contains(location))
})
}

static func getWindowElementUnderCursor() -> AccessibilityElement? {
let position = NSEvent.mouseLocation.screenFlipped
static func getWindowElementUnderCursor(at mouseDown: CGPoint? = nil, eventWindowID: CGWindowID? = nil) -> AccessibilityElement? {
let position = mouseDown ?? NSEvent.mouseLocation.screenFlipped

var systemWideFirst = Defaults.systemWideMouseDown.userEnabled
if Defaults.systemWideMouseDown.notSet, let frontAppId = ApplicationToggle.frontAppId {
Expand All @@ -372,11 +488,12 @@ extension AccessibilityElement {

if systemWideFirst,
let element = AccessibilityElement(position),
let windowElement = element.windowElement {
let windowElement = element.windowElement,
eventWindowID == nil || windowElement.windowId == eventWindowID {
return windowElement
}

if let info = getWindowInfo(position) {
if let info = getWindowInfo(position, eventWindowID: eventWindowID) {
if !Defaults.dragFromStage.userDisabled {
if StageUtil.stageCapable && StageUtil.stageEnabled,
let group = StageUtil.getStageStripWindowGroup(info.id),
Expand All @@ -390,15 +507,16 @@ extension AccessibilityElement {
if let windowElement = (windowElements.first { $0.windowId == info.id }) {
return windowElement
}
if let windowElement = (windowElements.first { $0.frame == info.frame }) {
if eventWindowID == nil, let windowElement = (windowElements.first { $0.frame == info.frame }) {
return windowElement
}
}
}

if !systemWideFirst,
let element = AccessibilityElement(position),
let windowElement = element.windowElement {
let windowElement = element.windowElement,
eventWindowID == nil || windowElement.windowId == eventWindowID {

if Logger.logging, let pid = windowElement.pid {
let appName = NSRunningApplication(processIdentifier: pid)?.localizedName ?? ""
Expand All @@ -411,6 +529,9 @@ extension AccessibilityElement {
// the frontmost app's own accessibility windows don't depend on it.
if let frontAppElement = getFrontApplicationElement(),
let windowElements = frontAppElement.windowElements {
if let eventWindowID {
return windowElements.first { $0.windowId == eventWindowID }
}
let windowElement = windowElements
.map { (element: $0, frame: $0.frame) }
.filter { !$0.frame.isNull && $0.frame.contains(position) }
Expand Down Expand Up @@ -544,22 +665,34 @@ enum EnhancedUI: Int {
bundleIdentifier: String?,
builtInAssistiveTechnologyEnabled: Bool,
readEnhancedUI: () -> Bool?,
writeEnhancedUI: (Bool) -> Void,
writeEnhancedUI: @escaping (Bool) -> Void,
adjustment: () -> Void
) {
let restore = beginWindowAdjustment(bundleIdentifier: bundleIdentifier,
builtInAssistiveTechnologyEnabled: builtInAssistiveTechnologyEnabled,
readEnhancedUI: readEnhancedUI,
writeEnhancedUI: writeEnhancedUI)
adjustment()
restore()
}

func beginWindowAdjustment(
bundleIdentifier: String?,
builtInAssistiveTechnologyEnabled: Bool,
readEnhancedUI: () -> Bool?,
writeEnhancedUI: @escaping (Bool) -> Void
) -> () -> Void {
let enhancedUIWasEnabled = readEnhancedUI()
if enhancedUIWasEnabled == true {
writeEnhancedUI(false)
}

adjustment()

if enhancedUIWasEnabled == true,
restoresEnhancedUI(
let shouldRestore = enhancedUIWasEnabled == true && restoresEnhancedUI(
bundleIdentifier: bundleIdentifier,
builtInAssistiveTechnologyEnabled: builtInAssistiveTechnologyEnabled
) {
writeEnhancedUI(true)
)
return {
if shouldRestore { writeEnhancedUI(true) }
}
}
}
2 changes: 1 addition & 1 deletion Rectangle/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Sparkle
import ServiceManagement
import os.log

@NSApplicationMain
@main
class AppDelegate: NSObject, NSApplicationDelegate {

static let launcherAppId = "com.knollsoft.RectangleLauncher"
Expand Down
Loading