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
3 changes: 2 additions & 1 deletion Loop/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions Loop/Extensions/Defaults+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ extension Defaults.Keys {

// Behavior
static let launchAtLogin = Key<Bool>("launchAtLogin", default: false, iCloud: true)
static let startHidden = Key<Bool>("startHidden", default: false, iCloud: true)
static let hideMenuBarIcon = Key<Bool>("hideMenuBarIcon", default: false, iCloud: false)
static let animationConfiguration = Key<AnimationConfiguration>("animationConfiguration", default: .snappy, iCloud: true)
static let windowSnapping = Key<Bool>("windowSnapping", default: false, iCloud: true)
Expand Down
36 changes: 36 additions & 0 deletions Loop/Extensions/NSScreen+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
}
10 changes: 10 additions & 0 deletions Loop/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 21 additions & 2 deletions Loop/Stashing/StashDirection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,39 @@ 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

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
Expand Down
60 changes: 44 additions & 16 deletions Loop/Stashing/StashManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
)
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
Expand All @@ -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<CGFloat>
let range2: ClosedRange<CGFloat>

// 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)
}
Expand Down Expand Up @@ -614,14 +640,16 @@ 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 {
case .left:
currentScreen.leftmostScreenInSameRow(overlapThreshold: threshold)
case .right:
currentScreen.rightmostScreenInSameRow(overlapThreshold: threshold)
case .bottom:
currentScreen.bottommostScreenInSameColumn(overlapThreshold: threshold)
}
}
}
21 changes: 15 additions & 6 deletions Loop/Stashing/StashedWindowInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down