feat: remember launcher position across restarts

This commit is contained in:
2026-10-12 14:24:14 +02:00
parent 0da8b8e241
commit 8d8e2278ab
4 changed files with 66 additions and 1 deletions
+1
View File
@@ -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. - Command-comma opens the native Settings window.
- Right-click the launcher background for Settings and Quit. - 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. - 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. 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.
+36 -1
View File
@@ -16,6 +16,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var isLaunching = false private var isLaunching = false
private var previousApplication: NSRunningApplication? private var previousApplication: NSRunningApplication?
private var alignmentGuides: LauncherAlignmentGuides? private var alignmentGuides: LauncherAlignmentGuides?
private static let positionKey = "launcherPosition"
init(history: LaunchHistory = LaunchHistory()) { init(history: LaunchHistory = LaunchHistory()) {
self.history = history self.history = history
@@ -40,7 +41,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
window.delegate = self window.delegate = self
window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) } window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) }
window.dismiss = { [weak self] in self?.dismiss() } 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) { required init?(coder: NSCoder) {
@@ -53,6 +59,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
previousApplication = frontmost previousApplication = frontmost
} }
searchField.stringValue = "" searchField.stringValue = ""
restorePosition()
updateResults() updateResults()
window?.deminiaturize(nil) window?.deminiaturize(nil)
showWindow(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) { func windowWillMove(_ notification: Notification) {
guard let window else { return } guard let window else { return }
let guides = alignmentGuides ?? LauncherAlignmentGuides() let guides = alignmentGuides ?? LauncherAlignmentGuides()
@@ -17,4 +17,17 @@ public struct LauncherAlignment: Sendable {
y: abs(windowFrame.maxY - targetFrame.maxY) <= 12 ? targetFrame.minY : windowFrame.minY 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
}
} }
@@ -46,3 +46,19 @@ func launcherAlignmentKeepsFractionalDisplayCentersOnAStableWindowGrid() {
let placed = CGRect(origin: alignment.snappedOrigin, size: window.size) let placed = CGRect(origin: alignment.snappedOrigin, size: window.size)
#expect(LauncherAlignment(windowFrame: placed, visibleFrame: display).snappedOrigin == placed.origin) #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))
}