feat: remember launcher position across restarts
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user