From c1120d1043269681d786798cbeed3ad15438a482 Mon Sep 17 00:00:00 2001 From: Kami Date: Wed, 4 Feb 2026 17:22:31 +1000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Misc=20updates=20(#1038)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Misc updates * 🎨 Fix swiftformat errors, gray out center stash option * 🐞 Remove top stash action due to unreliable behavior It appears that the Accessibility API requires a window’s title bar to remain visible during manipulation. As a result, stashing windows on top caused unpredictable behavior: the window would sometimes disappear entirely or remain stationary in a revealed state instead of moving into the expected peeking state. However, it would never animate between the hidden/shown state even when enabled. --------- Co-authored-by: Kai Azim --- Loop/App/AppDelegate.swift | 3 +- Loop/Extensions/Defaults+Extensions.swift | 1 + Loop/Extensions/NSScreen+Extensions.swift | 36 +++++++++++ Loop/Localizable.xcstrings | 10 ++++ .../Behavior/BehaviorConfiguration.swift | 3 + .../CustomActionConfigurationView.swift | 4 +- .../StashActionConfigurationView.swift | 10 +++- Loop/Stashing/StashDirection.swift | 23 ++++++- Loop/Stashing/StashManager.swift | 60 ++++++++++++++----- Loop/Stashing/StashedWindowInfo.swift | 21 +++++-- .../CustomWindowActionAnchor.swift | 13 +++- 11 files changed, 152 insertions(+), 32 deletions(-) diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index b8002f43..a31d68da 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -31,7 +31,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await Defaults.iCloud.waitForSyncCompletion() } - if !launchedAsLoginItem { + // Show settings window only if not launched as login item AND startHidden is disabled + if !launchedAsLoginItem, !Defaults[.startHidden] { SettingsWindowManager.shared.show() } else { // Closing also hides the dock icon if needed. diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 6311028d..ed9aa172 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -41,6 +41,7 @@ extension Defaults.Keys { // Behavior static let launchAtLogin = Key("launchAtLogin", default: false, iCloud: true) + static let startHidden = Key("startHidden", default: false, iCloud: true) static let hideMenuBarIcon = Key("hideMenuBarIcon", default: false, iCloud: false) static let animationConfiguration = Key("animationConfiguration", default: .snappy, iCloud: true) static let windowSnapping = Key("windowSnapping", default: false, iCloud: true) diff --git a/Loop/Extensions/NSScreen+Extensions.swift b/Loop/Extensions/NSScreen+Extensions.swift index 91ad9d17..02abe313 100644 --- a/Loop/Extensions/NSScreen+Extensions.swift +++ b/Loop/Extensions/NSScreen+Extensions.swift @@ -130,10 +130,23 @@ extension NSScreen { return max(0, bottom - top) } + private func horizontalOverlap(with other: NSScreen) -> CGFloat { + let a = frame + let b = other.frame + + let left = max(a.minX, b.minX) + let right = min(a.maxX, b.maxX) + return max(0, right - left) + } + private func screensInSameRow(screens: [NSScreen], overlapThreshold: CGFloat = 10.0) -> [NSScreen] { screens.filter { verticalOverlap(with: $0) >= overlapThreshold } } + private func screensInSameColumn(screens: [NSScreen], overlapThreshold: CGFloat = 10.0) -> [NSScreen] { + screens.filter { horizontalOverlap(with: $0) >= overlapThreshold } + } + func leftmostScreenInSameRow(overlapThreshold: CGFloat = 10.0) -> NSScreen { let sameRowScreens = screensInSameRow(screens: NSScreen.screens, overlapThreshold: overlapThreshold) @@ -179,4 +192,27 @@ extension NSScreen { return bestScreen ?? self } + + func bottommostScreenInSameColumn(overlapThreshold: CGFloat = 10.0) -> NSScreen { + let sameColumnScreens = screensInSameColumn(screens: NSScreen.screens, overlapThreshold: overlapThreshold) + + let bottomCandidates = sameColumnScreens.filter { $0.frame.minY >= self.frame.maxY } + + guard !bottomCandidates.isEmpty else { + return self + } + + var bestScreen: NSScreen? = nil + var bestOverlap: CGFloat = -1 + + for screen in bottomCandidates { + let overlap = horizontalOverlap(with: screen) + if overlap > bestOverlap || (overlap == bestOverlap && screen.frame.maxY > bestScreen?.frame.maxY ?? -.infinity) { + bestScreen = screen + bestOverlap = overlap + } + } + + return bestScreen ?? self + } } diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 50ba116a..3fb6ea5b 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -11830,6 +11830,16 @@ } } }, + "Start hidden" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start hidden" + } + } + } + }, "Left" : { "comment" : "Label for a slider in Loop’s padding settings\nSide of a trigger key", "localizations" : { diff --git a/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift b/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift index 9fbcf858..c08dcf3d 100644 --- a/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift +++ b/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift @@ -13,6 +13,7 @@ struct BehaviorConfigurationView: View { @Environment(\.luminareAnimation) private var luminareAnimation @Default(.launchAtLogin) var launchAtLogin + @Default(.startHidden) var startHidden @Default(.hideMenuBarIcon) var hideMenuBarIcon @Default(.animationConfiguration) var animationConfiguration @Default(.windowSnapping) var windowSnapping @@ -55,6 +56,8 @@ struct BehaviorConfigurationView: View { LuminareSection(String(localized: "General", comment: "Section header shown in settings")) { LuminareToggle("Launch at login", isOn: $launchAtLogin) + LuminareToggle("Start hidden", isOn: $startHidden) + LuminareToggle("Hide menu bar icon", isOn: $hideMenuBarIcon) LuminareSliderPicker( diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift index 06a77328..9732cd77 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift @@ -229,7 +229,9 @@ struct CustomActionConfigurationView: View { ), columns: 3 ) { anchor in - IconView(action: anchor.iconAction) + if let action = anchor.iconAction { + IconView(action: action) + } } .luminareRoundingBehavior(bottom: !showMacOSCenterToggle) diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift index 4ca5a456..a022847c 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift @@ -36,7 +36,9 @@ struct StashActionConfigurationView: View { private let defaultAnchor: CustomWindowActionAnchor = .topLeft private var anchors: [CustomWindowActionAnchor] { - [.topLeft, .topRight, .left, .right, .bottomLeft, .bottomRight] + [.topLeft, .none, .topRight, + .left, .none, .right, + .bottomLeft, .bottom, .bottomRight] } private var sizeModes: [CustomWindowActionSizeMode] { @@ -192,9 +194,11 @@ struct StashActionConfigurationView: View { } } ), - columns: 2 + columns: 3 ) { anchor in - IconView(action: anchor.iconAction) + if let action = anchor.iconAction { + IconView(action: action) + } } .luminareRoundingBehavior(top: true, bottom: true) } else { diff --git a/Loop/Stashing/StashDirection.swift b/Loop/Stashing/StashDirection.swift index b5e55670..f5515a53 100644 --- a/Loop/Stashing/StashDirection.swift +++ b/Loop/Stashing/StashDirection.swift @@ -11,10 +11,19 @@ import Foundation enum StashEdge: String, CustomDebugStringConvertible { case left case right + case bottom var debugDescription: String { rawValue } + + var isHorizontal: Bool { + self == .left || self == .right + } + + var isVertical: Bool { + self == .bottom + } } // MARK: - Helpers @@ -22,9 +31,19 @@ enum StashEdge: String, CustomDebugStringConvertible { extension WindowAction { var stashEdge: StashEdge? { switch direction { - case .stash where [.left, .topLeft, .bottomLeft].contains(anchor): + case .stash where anchor == .left: + .left + case .stash where anchor == .right: + .right + case .stash where anchor == .bottom: + .bottom + case .stash where anchor == .topLeft: + .left + case .stash where anchor == .topRight: + .right + case .stash where anchor == .bottomLeft: .left - case .stash where [.right, .topRight, .bottomRight].contains(anchor): + case .stash where anchor == .bottomRight: .right default: nil diff --git a/Loop/Stashing/StashManager.swift b/Loop/Stashing/StashManager.swift index eb97eb31..f9826b8b 100644 --- a/Loop/Stashing/StashManager.swift +++ b/Loop/Stashing/StashManager.swift @@ -62,7 +62,8 @@ final class StashManager { /// Two windows can be stacked along the same edge of the screen as long as there is enough non-overlapping space /// to allow the user to easily position the cursor over either window. - private let minimumVisibleHeightToKeepWindowStacked: CGFloat = 100 + /// This applies to vertical space for horizontal edges (left/right) and horizontal space for vertical edges (bottom). + private let minimumVisibleSizeToKeepWindowStacked: CGFloat = 100 private lazy var store: StashedWindowsStore = { let store = StashedWindowsStore() @@ -373,12 +374,16 @@ private extension StashManager { .mouseMoved, // Normal mouse movement .leftMouseDragged // Dragging items to stashed windows ], - callback: handleMouseMoved + callback: { [weak self] cgEvent in + self?.handleMouseMoved(cgEvent: cgEvent) + } ) monitor.start() mouseMonitor = monitor - frontmostAppMonitor = Task { @MainActor in + frontmostAppMonitor = Task { @MainActor [weak self] in + guard let self else { return } + let notifications = NSWorkspace.shared.notificationCenter.notifications( named: NSWorkspace.didActivateApplicationNotification ) @@ -395,10 +400,21 @@ private extension StashManager { log.info("Stopping listening for reveal triggers…") - mouseMonitor?.stop() - mouseMonitor = nil + // Cancel tasks first frontmostAppMonitor?.cancel() frontmostAppMonitor = nil + + // Stop and release the monitor + // The monitor's deinit will handle cleanup of the event tap + mouseMonitor?.stop() + + // Delay the release to allow the run loop to process the stop + let monitor = mouseMonitor + mouseMonitor = nil + + DispatchQueue.main.async { + _ = monitor // Keep alive until run loop processes the removal + } } /// Handles mouse movement events with a debounce to avoid excessive processing. @@ -523,9 +539,9 @@ private extension StashManager { unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) } else { let currentFrame = stashedWindow.computeStashedFrame(peekSize: stashedWindowVisiblePadding) - let tolerance = minimumVisibleHeightToKeepWindowStacked + let tolerance = minimumVisibleSizeToKeepWindowStacked - if !isThereEnoughNonOverlappingSpace(between: newFrame, and: currentFrame, tolerance: tolerance) { + if !isThereEnoughNonOverlappingSpace(between: newFrame, and: currentFrame, edge: windowToStash.action.stashEdge, tolerance: tolerance) { log.info("Trying to stash a window overlapping another one. Replacing…") unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) } @@ -535,21 +551,31 @@ private extension StashManager { /// Determines whether two rectangles have enough non-overlapping space between them. /// - /// This function compares the vertical ranges (y-axis) of two rectangles, `rect1` and `rect2`, - /// and checks if they are either non-overlapping or sufficiently offset vertically by at least - /// a given `tolerance` value. This ensures that if windows are stashed along the same edge of the screen, - /// they do not overlap each other and leave enough visible space (as defined by `tolerance`). + /// This function checks if windows stashed along the same edge have sufficient separation: + /// - For horizontal edges (left/right): compares vertical ranges (y-axis) + /// - For vertical edges (bottom): compares horizontal ranges (x-axis) /// /// - Parameters: /// - rect1: The first rectangle representing a stashed window's frame. /// - rect2: The second rectangle representing another window's frame. - /// - tolerance: The minimum number of pixels that must separate the two windows (in the vertical direction). + /// - edge: The edge where windows are stashed (determines which axis to check). + /// - tolerance: The minimum number of pixels that must separate the two windows. /// /// - Returns: `true` if the two rectangles do not overlap or are separated by at least `tolerance` pixels; /// `false` otherwise. - func isThereEnoughNonOverlappingSpace(between rect1: CGRect, and rect2: CGRect, tolerance: CGFloat) -> Bool { - let range1 = rect1.minY...rect1.maxY - let range2 = rect2.minY...rect2.maxY + func isThereEnoughNonOverlappingSpace(between rect1: CGRect, and rect2: CGRect, edge: StashEdge?, tolerance: CGFloat) -> Bool { + let range1: ClosedRange + let range2: ClosedRange + + // For horizontal edges (left/right), check vertical overlap + // For vertical edges (bottom), check horizontal overlap + if edge?.isHorizontal == true { + range1 = rect1.minY...rect1.maxY + range2 = rect2.minY...rect2.maxY + } else { + range1 = rect1.minX...rect1.maxX + range2 = rect2.minX...rect2.maxX + } return areRangesNonOverlappingByAtLeast(tolerance, range1, range2) } @@ -614,7 +640,7 @@ private extension StashManager { } func getScreenForEdge(currentScreen: NSScreen, edge: StashEdge) -> NSScreen? { - // Two screens are considered in the same "row" if they overlap vertically by at least `threshold` points + // Two screens are considered in the same "row" or "column" if they overlap by at least `threshold` points let threshold: CGFloat = 100 return switch edge { @@ -622,6 +648,8 @@ private extension StashManager { currentScreen.leftmostScreenInSameRow(overlapThreshold: threshold) case .right: currentScreen.rightmostScreenInSameRow(overlapThreshold: threshold) + case .bottom: + currentScreen.bottommostScreenInSameColumn(overlapThreshold: threshold) } } } diff --git a/Loop/Stashing/StashedWindowInfo.swift b/Loop/Stashing/StashedWindowInfo.swift index 8f05cbed..1ae1c713 100644 --- a/Loop/Stashing/StashedWindowInfo.swift +++ b/Loop/Stashing/StashedWindowInfo.swift @@ -23,14 +23,23 @@ struct StashedWindowInfo: Equatable { var frame = WindowFrameResolver.getFrame(for: action, window: window, bounds: bounds) let minPeekSize: CGFloat = 1 - let maxPeekSize = frame.width * maxPeekPercent - let clampedPeekSize = max(minPeekSize, min(peekSize, maxPeekSize)) switch action.stashEdge { - case .left: - frame.origin.x = bounds.minX - frame.width + clampedPeekSize - case .right: - frame.origin.x = bounds.maxX - clampedPeekSize + case .left, .right: + let maxPeekSize = frame.width * maxPeekPercent + let clampedPeekSize = max(minPeekSize, min(peekSize, maxPeekSize)) + + if action.stashEdge == .left { + frame.origin.x = bounds.minX - frame.width + clampedPeekSize + } else { + frame.origin.x = bounds.maxX - clampedPeekSize + } + + case .bottom: + let maxPeekSize = frame.height * maxPeekPercent + let clampedPeekSize = max(minPeekSize, min(peekSize, maxPeekSize)) + frame.origin.y = bounds.maxY - clampedPeekSize + case .none: log.warn("Trying to compute the stash frame for a non-stash related action.") } diff --git a/Loop/Window Management/Window Action/Custom Window Sizes/CustomWindowActionAnchor.swift b/Loop/Window Management/Window Action/Custom Window Sizes/CustomWindowActionAnchor.swift index 45e6fd79..0a063f62 100644 --- a/Loop/Window Management/Window Action/Custom Window Sizes/CustomWindowActionAnchor.swift +++ b/Loop/Window Management/Window Action/Custom Window Sizes/CustomWindowActionAnchor.swift @@ -5,11 +5,13 @@ // Created by Kai Azim on 2024-01-01. // +import Luminare import SwiftUI -enum CustomWindowActionAnchor: Int, Codable, CaseIterable, Identifiable { +enum CustomWindowActionAnchor: Int, Codable, Identifiable, LuminareSelectionData { var id: Self { self } + case none = -1 case topLeft = 0 case top = 1 case topRight = 2 @@ -20,18 +22,23 @@ enum CustomWindowActionAnchor: Int, Codable, CaseIterable, Identifiable { case left = 7 case center = 8 case macOSCenter = 9 + + var isSelectable: Bool { + self != .none + } } extension CustomWindowActionAnchor { private static var iconActionCache: [CustomWindowActionAnchor: WindowAction] = [:] - var iconAction: WindowAction { + var iconAction: WindowAction? { // Prevents re-initializing the same action multiple times if let cachedAction = CustomWindowActionAnchor.iconActionCache[self] { return cachedAction } - let newAction: WindowAction = switch self { + let newAction: WindowAction? = switch self { + case .none: nil case .topLeft: .init(.topLeftQuarter) case .top: .init(.topHalf) case .topRight: .init(.topRightQuarter)