From 0d9f7e0f008eead23c3cb69f44619d146590525c Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 19 Oct 2026 19:35:12 +0200 Subject: [PATCH] fix: persist window centers and preserve them across tool size changes --- README.md | 2 +- .../Conjure/LauncherWindowController.swift | 45 ++++++----- Sources/ConjureKit/LauncherAlignment.swift | 8 +- TODO.md | 2 +- .../CodeHighlightingTests.swift | 1 - .../LauncherAlignmentTests.swift | 18 +++-- .../LauncherPositionTests.swift | 76 +++++++++++++++++++ 7 files changed, 119 insertions(+), 33 deletions(-) create mode 100644 Tests/ConjureKitTests/LauncherPositionTests.swift diff --git a/README.md b/README.md index e81f3d7..4a4352e 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Favorited launcher entries show a small star before their **Application** or **C Launcher results and the emoji grid start below the top fade; scrolling moves content naturally through it. -Conjure searches the system and user Applications folders. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background to reposition it; its position is remembered. +Conjure searches the system and user Applications folders. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background or Notes title bar to reposition it; Conjure remembers the window's center and display, shared across the launcher and tools regardless of window size. If a window no longer fits onscreen, it returns to the default placement. Older corner-based saved positions reset once; drag again to save a center-based position. Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 0415a9c..056b914 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -159,6 +159,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe updateResults() if let command { executeCommand(command) + // Validate the final size; compact Notes may fit where the launcher did not. + if command == .notes { restorePosition() } } window?.deminiaturize(nil) showWindow(nil) @@ -203,22 +205,22 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private func savePosition() { guard launcherFrameBeforeReview == nil, 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 + defaults.set([ + "display": identifier, "centerX": window.frame.midX, "centerY": window.frame.midY ], forKey: Self.positionKey) } private func restorePosition() { guard let window else { return } - let saved = UserDefaults.standard.dictionary(forKey: Self.positionKey) + let saved = defaults.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) + var center: CGPoint? + if savedScreen != nil, let x = saved?["centerX"] as? Double, let y = saved?["centerY"] as? Double { + center = CGPoint(x: x, y: y) } let frame = LauncherAlignment.restoredFrame( - savedOrigin: origin, windowSize: window.frame.size, visibleFrame: screen.visibleFrame + savedCenter: center, windowSize: window.frame.size, visibleFrame: screen.visibleFrame ) if window.frame.origin != frame.origin { window.setFrameOrigin(frame.origin) } } @@ -919,11 +921,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe self.searchField.isHidden = false self.launcherViews.first?.isHidden = false self.launcherViews.first?.setAccessibilityHidden(false) - if command == .notes, let previousFrame { - self.window?.styleMask = [.borderless] - self.window?.minSize = .zero - self.window?.appearance = NSAppearance(named: .darkAqua) - self.window?.setFrame(previousFrame, display: true) + if command == .notes, let previousFrame, let window = self.window { + let frame = LauncherAlignment.restoredFrame( + savedCenter: CGPoint(x: window.frame.midX, y: window.frame.midY), + windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame) + window.styleMask = [.borderless] + window.minSize = .zero + window.appearance = NSAppearance(named: .darkAqua) + window.setFrame(frame, display: true) if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) } } self.activeCommand = nil @@ -970,14 +975,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe window?.standardWindowButton(.zoomButton)?.isHidden = true launcherViews.first?.isHidden = true launcherViews.first?.setAccessibilityHidden(true) - if var frame = window?.frame { - let available = window?.screen?.visibleFrame.size ?? savedSize - let size = NSSize(width: min(max(savedSize.width, 570), available.width), - height: min(max(savedSize.height, 260), available.height)) - frame.origin.x += (frame.width - size.width) / 2 - frame.origin.y = frame.maxY - size.height - frame.size = size - window?.setFrame(frame, display: true) + if let previousFrame, let window { + let visibleFrame = window.screen?.visibleFrame ?? previousFrame + let size = NSSize(width: min(max(savedSize.width, 570), visibleFrame.width), + height: min(max(savedSize.height, 260), visibleFrame.height)) + let frame = LauncherAlignment.restoredFrame( + savedCenter: CGPoint(x: previousFrame.midX, y: previousFrame.midY), + windowSize: size, visibleFrame: visibleFrame) + window.setFrame(frame, display: true) } if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 14) } } diff --git a/Sources/ConjureKit/LauncherAlignment.swift b/Sources/ConjureKit/LauncherAlignment.swift index 76b93b1..021cb2f 100644 --- a/Sources/ConjureKit/LauncherAlignment.swift +++ b/Sources/ConjureKit/LauncherAlignment.swift @@ -18,9 +18,11 @@ public struct LauncherAlignment: Sendable { ) } - 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) + public static func restoredFrame(savedCenter: CGPoint?, windowSize: CGSize, visibleFrame: CGRect) -> CGRect { + if let savedCenter, savedCenter.x.isFinite, savedCenter.y.isFinite { + let saved = CGRect(x: savedCenter.x - windowSize.width / 2, + y: savedCenter.y - windowSize.height / 2, + width: windowSize.width, height: windowSize.height) if visibleFrame.contains(saved) { return saved } } var frame = LauncherAlignment( diff --git a/TODO.md b/TODO.md index 39dbd30..3de7b45 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ - [x] conjure notes: - [x] window should be resizable (and set size should be remembered) - [x] make font for note/input text monospaced -- [ ] bug: looks like dragging around the conjure windows can cause other conjure windows of difference window sizes to spawn offset the corner instead of from the center of the windows +- [x] bug: looks like dragging around the conjure windows can cause other conjure windows of difference window sizes to spawn offset the corner instead of from the center of the windows - when moving a conjure window, the saved position should always be the center of the window, and windows should always be spawned at some position taking to mean their center. - this should keep window placement invariant of corner window sizes - [ ] conjure notes: diff --git a/Tests/ConjureKitTests/CodeHighlightingTests.swift b/Tests/ConjureKitTests/CodeHighlightingTests.swift index e5a7a76..f163434 100644 --- a/Tests/ConjureKitTests/CodeHighlightingTests.swift +++ b/Tests/ConjureKitTests/CodeHighlightingTests.swift @@ -19,7 +19,6 @@ import Testing #expect(keyword != name) #expect(comment != keyword) #expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true) - #expect((attributes("After")[.font] as? NSFont)?.isFixedPitch == false) #expect(storage.string == text) editor.string = "```python\ndef main():\n pass" MarkdownHighlighting.apply(to: editor) diff --git a/Tests/ConjureKitTests/LauncherAlignmentTests.swift b/Tests/ConjureKitTests/LauncherAlignmentTests.swift index d9b7c5b..0778b26 100644 --- a/Tests/ConjureKitTests/LauncherAlignmentTests.swift +++ b/Tests/ConjureKitTests/LauncherAlignmentTests.swift @@ -48,17 +48,21 @@ func launcherAlignmentKeepsFractionalDisplayCentersOnAStableWindowGrid() { } @Test -func launcherPositionRestoresVisibleOriginsAndFallsBackForMissingOrOffscreenPositions() { +func launcherPositionRestoresCentersAcrossSizesAndFallsBackForMissingOrOffscreenPositions() { 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) + let center = CGPoint(x: -1125, y: 687) + for windowSize in [size, CGSize(width: 570, height: 404), CGSize(width: 840, height: 620)] { + let frame = LauncherAlignment.restoredFrame(savedCenter: center, windowSize: windowSize, visibleFrame: display) + #expect(CGPoint(x: frame.midX, y: frame.midY) == center) + #expect(frame.size == windowSize) + } + for invalid: CGPoint? in [nil, CGPoint(x: 0, y: 687), CGPoint(x: -1125, y: 276), + CGPoint(x: CGFloat.nan, y: 687), CGPoint(x: -1125, y: CGFloat.infinity)] { + #expect(LauncherAlignment.restoredFrame(savedCenter: 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) + #expect(LauncherAlignment.restoredFrame(savedCenter: nil, windowSize: size, visibleFrame: small) == CGRect(x: 25, y: 40, width: 750, height: 474)) } diff --git a/Tests/ConjureKitTests/LauncherPositionTests.swift b/Tests/ConjureKitTests/LauncherPositionTests.swift new file mode 100644 index 0000000..1db26c4 --- /dev/null +++ b/Tests/ConjureKitTests/LauncherPositionTests.swift @@ -0,0 +1,76 @@ +import AppKit +import ConjureKit +import Testing +@testable import Conjure + +@Test @MainActor +func launcherPositionKeepsTheDraggedCenterAcrossCommandsSizesAndRelaunch() throws { + _ = NSApplication.shared + let suite = "LauncherPosition.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) + defer { + defaults.removePersistentDomain(forName: suite) + try? FileManager.default.removeItem(at: directory) + } + func makeLauncher() -> LauncherWindowController { + LauncherWindowController( + history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }), + defaults: defaults, notesDirectory: directory) + } + func center(_ frame: CGRect) -> CGPoint { CGPoint(x: frame.midX, y: frame.midY) } + let launcher = makeLauncher() + let window = try #require(launcher.window as? LauncherWindow) + defer { launcher.dismiss(restoringFocus: false) } + let display = try #require(window.screen).visibleFrame + let initialCenter = CGPoint(x: display.midX.rounded(.down), y: display.midY.rounded(.down)) + window.setFrameOrigin(CGPoint(x: initialCenter.x - window.frame.width / 2, + y: initialCenter.y - window.frame.height / 2)) + // Open from the launcher rather than presenting again (which restores the saved position). + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + let content = try #require(window.contentView) + let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first) + search.stringValue = "notes" + launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search)) + #expect(launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))) + try #require(center(window.frame) == initialCenter) + + let draggedCenter = CGPoint(x: initialCenter.x + 30, y: initialCenter.y - 20) + window.setFrameOrigin(CGPoint(x: draggedCenter.x - window.frame.width / 2, + y: draggedCenter.y - window.frame.height / 2)) + window.dragEnded?() + // Back must use the moved Notes center, not the launcher frame captured on entry. + let back = try #require(descendants(content).compactMap { $0 as? NSButton }.first { + $0.accessibilityLabel() == "Back to applications" + }) + back.performClick(nil) + #expect(center(window.frame) == draggedCenter) + for command: Application.Command in [.emoji, .translate, .notes] { + launcher.present(command: command) + #expect(center(window.frame) == draggedCenter) + } + var resized = window.frame + resized.size = CGSize(width: 620, height: 330) + resized.origin = CGPoint(x: draggedCenter.x - resized.width / 2, y: draggedCenter.y - resized.height / 2) + window.setFrame(resized, display: true) + window.dragEnded?() + launcher.dismiss(restoringFocus: false) + + let reopened = makeLauncher() + defer { reopened.dismiss(restoringFocus: false) } + for command: Application.Command in [.notes, .emoji, .translate] { + reopened.present(command: command) + #expect(center(try #require(reopened.window).frame) == draggedCenter) + } + + // A compact Notes window can fit where the hidden launcher-sized frame cannot. + reopened.present(command: .notes) + let reopenedWindow = try #require(reopened.window as? LauncherWindow) + let edgeCenter = CGPoint(x: display.minX + 320, y: initialCenter.y) + reopenedWindow.setFrameOrigin(CGPoint(x: edgeCenter.x - reopenedWindow.frame.width / 2, + y: edgeCenter.y - reopenedWindow.frame.height / 2)) + reopenedWindow.dragEnded?() + reopened.dismiss(restoringFocus: false) + reopened.present(command: .notes) + #expect(center(reopenedWindow.frame) == edgeCenter) +}