diff --git a/README.md b/README.md index 4c13936..f375350 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to - Command-comma opens the native Settings window. - Right-click the launcher background for Settings and Quit. - Drag the panel background to move it freely. Three stippled guides mark the target left, right, and top edges: the launcher is horizontally centered, its top edge sits one-fifth down the current display's usable area, and the side guides are exactly one launcher-width apart. Each axis brightens within its 12-point snap range, but the window snaps only when the mouse button is released. Releasing outside the range leaves that axis untouched; dismissal cancels without snapping. Guides remain continuous underneath the frosted launcher rather than being cut out around it. They disappear on release or dismissal and do not intercept input. +- The final dragged or snapped position is saved locally with the display's stable identifier and restored across restarts. Before showing the launcher, Conjure checks that the saved display is connected and the whole panel fits its current usable area. Missing displays, invalid coordinates, or off-screen positions fall back to the default centered, one-fifth-down alignment on an available display; the fallback is kept inside the usable area when the panel fits. First launch uses the same default alignment. Conjure runs as a native accessory app: no Dock icon, app-switcher entry, or menu-bar menus. Keyboard menu shortcuts remain available while its window is focused. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 94251b4..ef1ff49 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -16,6 +16,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private var isLaunching = false private var previousApplication: NSRunningApplication? private var alignmentGuides: LauncherAlignmentGuides? + private static let positionKey = "launcherPosition" init(history: LaunchHistory = LaunchHistory()) { self.history = history @@ -40,7 +41,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe window.delegate = self window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) } window.dismiss = { [weak self] in self?.dismiss() } - window.dragEnded = { [weak self] in self?.alignmentGuides?.end(snapping: self?.window) } + window.dragEnded = { [weak self] in + guard let self else { return } + self.alignmentGuides?.end(snapping: self.window) + self.savePosition() + } + restorePosition() } required init?(coder: NSCoder) { @@ -53,6 +59,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe previousApplication = frontmost } searchField.stringValue = "" + restorePosition() updateResults() window?.deminiaturize(nil) showWindow(nil) @@ -84,6 +91,34 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } } + private func savePosition() { + guard let window, let screen = window.screen, let identifier = displayIdentifier(screen) else { return } + UserDefaults.standard.set([ + "display": identifier, "x": window.frame.minX, "y": window.frame.minY + ], forKey: Self.positionKey) + } + + private func restorePosition() { + guard let window else { return } + let saved = UserDefaults.standard.dictionary(forKey: Self.positionKey) + let savedScreen = NSScreen.screens.first { displayIdentifier($0) == saved?["display"] as? String } + guard let screen = savedScreen ?? NSScreen.main ?? NSScreen.screens.first else { return } + var origin: CGPoint? + if savedScreen != nil, let x = saved?["x"] as? Double, let y = saved?["y"] as? Double { + origin = CGPoint(x: x, y: y) + } + let frame = LauncherAlignment.restoredFrame( + savedOrigin: origin, windowSize: window.frame.size, visibleFrame: screen.visibleFrame + ) + if window.frame.origin != frame.origin { window.setFrameOrigin(frame.origin) } + } + + private func displayIdentifier(_ screen: NSScreen) -> String? { + guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber, + let uuid = CGDisplayCreateUUIDFromDisplayID(number.uint32Value)?.takeRetainedValue() else { return nil } + return CFUUIDCreateString(nil, uuid) as String + } + func windowWillMove(_ notification: Notification) { guard let window else { return } let guides = alignmentGuides ?? LauncherAlignmentGuides() diff --git a/Sources/ConjureKit/LauncherAlignment.swift b/Sources/ConjureKit/LauncherAlignment.swift index 8d149e2..76b93b1 100644 --- a/Sources/ConjureKit/LauncherAlignment.swift +++ b/Sources/ConjureKit/LauncherAlignment.swift @@ -17,4 +17,17 @@ public struct LauncherAlignment: Sendable { y: abs(windowFrame.maxY - targetFrame.maxY) <= 12 ? targetFrame.minY : windowFrame.minY ) } + + public static func restoredFrame(savedOrigin: CGPoint?, windowSize: CGSize, visibleFrame: CGRect) -> CGRect { + if let savedOrigin, savedOrigin.x.isFinite, savedOrigin.y.isFinite { + let saved = CGRect(origin: savedOrigin, size: windowSize) + if visibleFrame.contains(saved) { return saved } + } + var frame = LauncherAlignment( + windowFrame: CGRect(origin: .zero, size: windowSize), visibleFrame: visibleFrame + ).targetFrame + frame.origin.x = max(visibleFrame.minX, min(frame.minX, visibleFrame.maxX - frame.width)) + frame.origin.y = max(visibleFrame.minY, min(frame.minY, visibleFrame.maxY - frame.height)) + return frame + } } diff --git a/Tests/ConjureKitTests/LauncherAlignmentTests.swift b/Tests/ConjureKitTests/LauncherAlignmentTests.swift index 6cc070e..d9b7c5b 100644 --- a/Tests/ConjureKitTests/LauncherAlignmentTests.swift +++ b/Tests/ConjureKitTests/LauncherAlignmentTests.swift @@ -46,3 +46,19 @@ func launcherAlignmentKeepsFractionalDisplayCentersOnAStableWindowGrid() { let placed = CGRect(origin: alignment.snappedOrigin, size: window.size) #expect(LauncherAlignment(windowFrame: placed, visibleFrame: display).snappedOrigin == placed.origin) } + +@Test +func launcherPositionRestoresVisibleOriginsAndFallsBackForMissingOrOffscreenPositions() { + let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040) + let size = CGSize(width: 750, height: 474) + let origin = CGPoint(x: -1500, y: 450) + #expect(LauncherAlignment.restoredFrame(savedOrigin: origin, windowSize: size, visibleFrame: display) + == CGRect(origin: origin, size: size)) + for invalid: CGPoint? in [nil, CGPoint(x: 0, y: 450), CGPoint(x: -1500, y: 39), CGPoint(x: CGFloat.nan, y: 450)] { + #expect(LauncherAlignment.restoredFrame(savedOrigin: invalid, windowSize: size, visibleFrame: display) + == CGRect(x: -1335, y: 398, width: 750, height: 474)) + } + let small = CGRect(x: 0, y: 40, width: 800, height: 500) + #expect(LauncherAlignment.restoredFrame(savedOrigin: nil, windowSize: size, visibleFrame: small) + == CGRect(x: 25, y: 40, width: 750, height: 474)) +}