Skip to content
Merged
16 changes: 16 additions & 0 deletions Rectangle.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@
9824704F22B189250037B409 /* BestEffortWindowMover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824704A22B189250037B409 /* BestEffortWindowMover.swift */; };
9824705122B28D7A0037B409 /* LeftRightHalfCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824705022B28D7A0037B409 /* LeftRightHalfCalculation.swift */; };
983BBD6F253B609D000D223E /* FootprintWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983BBD6E253B609D000D223E /* FootprintWindow.swift */; };
F1AC0DE000000000000000A2 /* StackBadgeGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000A1 /* StackBadgeGeometry.swift */; };
F1AC0DE000000000000000A4 /* StackBadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000A3 /* StackBadgeManager.swift */; };
983DD04028A844BE00BF1EEE /* SnapAreaViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983DD03F28A844BE00BF1EEE /* SnapAreaViewController.swift */; };
983DD04428B0639E00BF1EEE /* SnapAreaModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983DD04328B0639E00BF1EEE /* SnapAreaModel.swift */; };
984EDB0F29A42ED200D119D2 /* LaunchOnLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984EDB0E29A42ED200D119D2 /* LaunchOnLogin.swift */; };
Expand Down Expand Up @@ -317,6 +319,8 @@
9824704A22B189250037B409 /* BestEffortWindowMover.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BestEffortWindowMover.swift; sourceTree = "<group>"; };
9824705022B28D7A0037B409 /* LeftRightHalfCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftRightHalfCalculation.swift; sourceTree = "<group>"; };
983BBD6E253B609D000D223E /* FootprintWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FootprintWindow.swift; sourceTree = "<group>"; };
F1AC0DE000000000000000A1 /* StackBadgeGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeGeometry.swift; sourceTree = "<group>"; };
F1AC0DE000000000000000A3 /* StackBadgeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeManager.swift; sourceTree = "<group>"; };
983DD03F28A844BE00BF1EEE /* SnapAreaViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapAreaViewController.swift; sourceTree = "<group>"; };
983DD04328B0639E00BF1EEE /* SnapAreaModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapAreaModel.swift; sourceTree = "<group>"; };
984EDB0E29A42ED200D119D2 /* LaunchOnLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchOnLogin.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -708,6 +712,7 @@
98910B3C2311304D0066EC23 /* PrefsWindow */,
989DA30C243FC0C3008C7AA4 /* WelcomeWindow */,
98C2755F231FF6AE009B9292 /* Snapping */,
F1AC0DE000000000000000A5 /* StackBadge */,
98D4B6C925B625B6009C7BF6 /* TodoMode */,
985B9BFA22BE218C00A2E8F0 /* Popover */,
982140EA22B7DB0400ABFB3F /* WindowMover */,
Expand Down Expand Up @@ -786,6 +791,15 @@
path = TodoMode;
sourceTree = "<group>";
};
F1AC0DE000000000000000A5 /* StackBadge */ = {
isa = PBXGroup;
children = (
F1AC0DE000000000000000A1 /* StackBadgeGeometry.swift */,
F1AC0DE000000000000000A3 /* StackBadgeManager.swift */,
);
path = StackBadge;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -1081,6 +1095,8 @@
9824702C22AFA22E0037B409 /* AccessibilityWindowController.swift in Sources */,
988D066122EB4C7C004EABD7 /* FirstThirdCalculation.swift in Sources */,
983BBD6F253B609D000D223E /* FootprintWindow.swift in Sources */,
F1AC0DE000000000000000A2 /* StackBadgeGeometry.swift in Sources */,
F1AC0DE000000000000000A4 /* StackBadgeManager.swift in Sources */,
988D067F22EB4EDE004EABD7 /* MoveLeftRightCalculation.swift in Sources */,
AA3A9E8B29230A82004EB8E5 /* CFExtension.swift in Sources */,
98C6DEF023CE191700CC0C1E /* GapCalculation.swift in Sources */,
Expand Down
10 changes: 10 additions & 0 deletions Rectangle/AccessibilityElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,16 @@ class AccessibilityElement {
var isMinimized: Bool? {
windowElement?.wrappedElement.getValue(.minimized) as? Bool
}

var title: String? {
wrappedElement.getValue(.title) as? String
}

/// Caps how long AX calls through this element can block on an
/// unresponsive app (the systemwide default is several seconds).
func setMessagingTimeout(_ seconds: Float) {
AXUIElementSetMessagingTimeout(wrappedElement, seconds)
}

var isFullScreen: Bool? {
guard let subrole = windowElement?.getElementValue(.fullScreenButton)?.subrole else { return nil }
Expand Down
2 changes: 2 additions & 0 deletions Rectangle/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private var applicationToggle: ApplicationToggle!
private var windowCalculationFactory: WindowCalculationFactory!
private var snappingManager: SnappingManager!
private var stackBadgeManager: StackBadgeManager!
private var titleBarManager: TitleBarManager!
private var greenButtonManager: GreenButtonManager!

Expand Down Expand Up @@ -153,6 +154,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.shortcutManager = ShortcutManager(windowManager: windowManager)
self.applicationToggle = ApplicationToggle(shortcutManager: shortcutManager)
self.snappingManager = SnappingManager()
self.stackBadgeManager = StackBadgeManager()
self.titleBarManager = TitleBarManager()
self.greenButtonManager = GreenButtonManager()
self.initializeTodo()
Expand Down
2 changes: 2 additions & 0 deletions Rectangle/Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class Defaults {
static let cyclingOverlapOffset = OptionalBoolDefault(key: "cyclingOverlapOffset")
static let cyclingOverlapOffsetSize = FloatDefault(key: "cyclingOverlapOffsetSize", defaultValue: 11)
static let cyclingOverlapMaxCascade = IntDefault(key: "cyclingOverlapMaxCascade", defaultValue: 1)
static let stackBadge = OptionalBoolDefault(key: "stackBadge")
static let fullIgnoreBundleIds = JSONDefault<[String]>(key: "fullIgnoreBundleIds")
static let notifiedOfProblemApps = BoolDefault(key: "notifiedOfProblemApps")
static let specifiedHeight = FloatDefault(key: "specifiedHeight", defaultValue: 1050)
Expand Down Expand Up @@ -194,6 +195,7 @@ class Defaults {
cyclingOverlapOffset,
cyclingOverlapOffsetSize,
cyclingOverlapMaxCascade,
stackBadge,
moveFixedSizeToEdge,
greenButtonOverride
]
Expand Down
14 changes: 13 additions & 1 deletion Rectangle/PrefsWindow/SettingsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ class SettingsViewController: NSViewController {
Notification.Name.showAdditionalSizesInMenuChanged.post()
}

@objc func toggleStackBadge(_ sender: NSButton) {
Defaults.stackBadge.enabled = sender.state == .on
Notification.Name.stackBadgeChanged.post()
}

@objc func toggleCyclingOverlapOffset(_ sender: NSButton) {
Defaults.cyclingOverlapOffset.enabled = sender.state == .on
}
Expand Down Expand Up @@ -901,11 +906,17 @@ class SettingsViewController: NSViewController {
overlapOffsetCheckbox.translatesAutoresizingMaskIntoConstraints = false
overlapOffsetCheckbox.alignment = .left

let stackBadgeCheckbox = NSButton(checkboxWithTitle: NSLocalizedString("Show stacked window badge on hover", tableName: "Main", value: "", comment: ""), target: self, action: #selector(toggleStackBadge(_:)))
stackBadgeCheckbox.state = Defaults.stackBadge.userEnabled ? .on : .off
stackBadgeCheckbox.translatesAutoresizingMaskIntoConstraints = false
stackBadgeCheckbox.alignment = .left

mainStackView.addArrangedSubview(gridHeaderLabel)
mainStackView.setCustomSpacing(4, after: gridHeaderLabel)
mainStackView.addArrangedSubview(showAdditionalSizesCheckbox)
mainStackView.addArrangedSubview(overlapOffsetCheckbox)
mainStackView.setCustomSpacing(8, after: overlapOffsetCheckbox)
mainStackView.addArrangedSubview(stackBadgeCheckbox)
mainStackView.setCustomSpacing(8, after: stackBadgeCheckbox)
mainStackView.addArrangedSubview(cyclingHintLabel)
mainStackView.setCustomSpacing(8, after: cyclingHintLabel)
mainStackView.addArrangedSubview(topLeftEighthRow)
Expand Down Expand Up @@ -978,6 +989,7 @@ class SettingsViewController: NSViewController {
widthStepField.trailingAnchor.constraint(equalTo: largerWidthShortcutView.trailingAnchor),
showAdditionalSizesCheckbox.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
overlapOffsetCheckbox.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
stackBadgeCheckbox.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
smallerWidthShortcutView.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
topVerticalThirdShortcutView.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
middleVerticalThirdShortcutView.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
Expand Down
90 changes: 90 additions & 0 deletions Rectangle/StackBadge/StackBadgeGeometry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// StackBadgeGeometry.swift
// Rectangle
//
// Copyright © 2026 Ryan Hanson. All rights reserved.
//

import Foundation

/// Pure geometry for the stack badge: where on a screen a grid cell's
/// top-left corner can be, and whether the cursor is resting near one.
/// No AX, no window state - computable from screen frames alone.
enum StackBadgeGeometry {

/// Every grid layout Rectangle can place windows into, as (columns, rows).
/// Cell corners from these are where stacked windows can share an origin.
static let gridDimensions: [(cols: Int, rows: Int)] = [
(2, 1), (1, 2), // halves
(3, 1), (1, 3), // thirds
(2, 2), // quarters
(4, 1), (1, 4), // fourths
(3, 2), (2, 3), // sixths
(4, 2), // eighths
(3, 3), // ninths
(4, 3), (3, 4), // twelfths
(4, 4) // sixteenths
]

/// Top-left corners (AppKit coordinates, y up) of every grid cell across
/// all supported grids, deduplicated. ~30 points for a typical screen.
static func cornerPoints(in screenFrame: CGRect) -> [CGPoint] {
guard screenFrame.width > 0, screenFrame.height > 0 else { return [] }
var points = [CGPoint]()
for grid in gridDimensions {
for col in 0..<grid.cols {
for row in 0..<grid.rows {
let point = CGPoint(
x: screenFrame.minX + CGFloat(col) * screenFrame.width / CGFloat(grid.cols),
y: screenFrame.maxY - CGFloat(row) * screenFrame.height / CGFloat(grid.rows)
)
if !points.contains(where: { abs($0.x - point.x) < 1 && abs($0.y - point.y) < 1 }) {
points.append(point)
}
}
}
}
return points
}

/// The indices of the window origins (AX coordinates) that form a
/// cascade stack. The overlap offset cascades diagonally: +x always, but
/// y can run either way (the offset is applied in AppKit coordinates,
/// where +y converts to an upward move in AX space), so the y window is
/// symmetric. Every origin is tried as the stack's left anchor and the
/// densest cluster wins, so neither unrelated neighbors in a gap-widened
/// candidate box nor an unrelated leftmost outlier can distort the count.
static func stackIndices(among origins: [CGPoint], cascadeRange: CGFloat, tolerance: CGFloat) -> [Int] {
var best = [Int]()
for anchor in origins {
let cluster = origins.indices.filter { index in
let dx = origins[index].x - anchor.x
let dy = origins[index].y - anchor.y
return dx >= -tolerance && dx <= cascadeRange
&& dy >= -cascadeRange && dy <= cascadeRange
}
if cluster.count > best.count {
best = cluster
}
}
return best
}

/// The corner whose hover zone contains the point, or nil. The zone is a
/// square extending right and down from the corner (down in AppKit means
/// minus y), covering where gap-shifted windows and their title bars sit
/// relative to the geometric corner.
static func corner(near point: CGPoint, in corners: [CGPoint], zone: CGFloat) -> CGPoint? {
var best: (corner: CGPoint, distance: CGFloat)?
for corner in corners {
let dx = point.x - corner.x
let dy = corner.y - point.y
guard dx >= -4, dx <= zone, dy >= -4, dy <= zone else { continue }
let distance = dx * dx + dy * dy
if best == nil || distance < best!.distance {
best = (corner, distance)
}
}
return best?.corner
}
}
Loading
Loading