fix: persist window centers and preserve them across tool size changes
This commit is contained in:
@@ -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.
|
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.
|
Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut.
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
updateResults()
|
updateResults()
|
||||||
if let command {
|
if let command {
|
||||||
executeCommand(command)
|
executeCommand(command)
|
||||||
|
// Validate the final size; compact Notes may fit where the launcher did not.
|
||||||
|
if command == .notes { restorePosition() }
|
||||||
}
|
}
|
||||||
window?.deminiaturize(nil)
|
window?.deminiaturize(nil)
|
||||||
showWindow(nil)
|
showWindow(nil)
|
||||||
@@ -203,22 +205,22 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
|
|
||||||
private func savePosition() {
|
private func savePosition() {
|
||||||
guard launcherFrameBeforeReview == nil, let window, let screen = window.screen, let identifier = displayIdentifier(screen) else { return }
|
guard launcherFrameBeforeReview == nil, let window, let screen = window.screen, let identifier = displayIdentifier(screen) else { return }
|
||||||
UserDefaults.standard.set([
|
defaults.set([
|
||||||
"display": identifier, "x": window.frame.minX, "y": window.frame.minY
|
"display": identifier, "centerX": window.frame.midX, "centerY": window.frame.midY
|
||||||
], forKey: Self.positionKey)
|
], forKey: Self.positionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restorePosition() {
|
private func restorePosition() {
|
||||||
guard let window else { return }
|
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 }
|
let savedScreen = NSScreen.screens.first { displayIdentifier($0) == saved?["display"] as? String }
|
||||||
guard let screen = savedScreen ?? NSScreen.main ?? NSScreen.screens.first else { return }
|
guard let screen = savedScreen ?? NSScreen.main ?? NSScreen.screens.first else { return }
|
||||||
var origin: CGPoint?
|
var center: CGPoint?
|
||||||
if savedScreen != nil, let x = saved?["x"] as? Double, let y = saved?["y"] as? Double {
|
if savedScreen != nil, let x = saved?["centerX"] as? Double, let y = saved?["centerY"] as? Double {
|
||||||
origin = CGPoint(x: x, y: y)
|
center = CGPoint(x: x, y: y)
|
||||||
}
|
}
|
||||||
let frame = LauncherAlignment.restoredFrame(
|
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) }
|
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.searchField.isHidden = false
|
||||||
self.launcherViews.first?.isHidden = false
|
self.launcherViews.first?.isHidden = false
|
||||||
self.launcherViews.first?.setAccessibilityHidden(false)
|
self.launcherViews.first?.setAccessibilityHidden(false)
|
||||||
if command == .notes, let previousFrame {
|
if command == .notes, let previousFrame, let window = self.window {
|
||||||
self.window?.styleMask = [.borderless]
|
let frame = LauncherAlignment.restoredFrame(
|
||||||
self.window?.minSize = .zero
|
savedCenter: CGPoint(x: window.frame.midX, y: window.frame.midY),
|
||||||
self.window?.appearance = NSAppearance(named: .darkAqua)
|
windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame)
|
||||||
self.window?.setFrame(previousFrame, display: true)
|
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) }
|
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
|
||||||
}
|
}
|
||||||
self.activeCommand = nil
|
self.activeCommand = nil
|
||||||
@@ -970,14 +975,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
window?.standardWindowButton(.zoomButton)?.isHidden = true
|
window?.standardWindowButton(.zoomButton)?.isHidden = true
|
||||||
launcherViews.first?.isHidden = true
|
launcherViews.first?.isHidden = true
|
||||||
launcherViews.first?.setAccessibilityHidden(true)
|
launcherViews.first?.setAccessibilityHidden(true)
|
||||||
if var frame = window?.frame {
|
if let previousFrame, let window {
|
||||||
let available = window?.screen?.visibleFrame.size ?? savedSize
|
let visibleFrame = window.screen?.visibleFrame ?? previousFrame
|
||||||
let size = NSSize(width: min(max(savedSize.width, 570), available.width),
|
let size = NSSize(width: min(max(savedSize.width, 570), visibleFrame.width),
|
||||||
height: min(max(savedSize.height, 260), available.height))
|
height: min(max(savedSize.height, 260), visibleFrame.height))
|
||||||
frame.origin.x += (frame.width - size.width) / 2
|
let frame = LauncherAlignment.restoredFrame(
|
||||||
frame.origin.y = frame.maxY - size.height
|
savedCenter: CGPoint(x: previousFrame.midX, y: previousFrame.midY),
|
||||||
frame.size = size
|
windowSize: size, visibleFrame: visibleFrame)
|
||||||
window?.setFrame(frame, display: true)
|
window.setFrame(frame, display: true)
|
||||||
}
|
}
|
||||||
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 14) }
|
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 14) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ public struct LauncherAlignment: Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func restoredFrame(savedOrigin: CGPoint?, windowSize: CGSize, visibleFrame: CGRect) -> CGRect {
|
public static func restoredFrame(savedCenter: CGPoint?, windowSize: CGSize, visibleFrame: CGRect) -> CGRect {
|
||||||
if let savedOrigin, savedOrigin.x.isFinite, savedOrigin.y.isFinite {
|
if let savedCenter, savedCenter.x.isFinite, savedCenter.y.isFinite {
|
||||||
let saved = CGRect(origin: savedOrigin, size: windowSize)
|
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 }
|
if visibleFrame.contains(saved) { return saved }
|
||||||
}
|
}
|
||||||
var frame = LauncherAlignment(
|
var frame = LauncherAlignment(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
- [x] conjure notes:
|
- [x] conjure notes:
|
||||||
- [x] window should be resizable (and set size should be remembered)
|
- [x] window should be resizable (and set size should be remembered)
|
||||||
- [x] make font for note/input text monospaced
|
- [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.
|
- 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
|
- this should keep window placement invariant of corner window sizes
|
||||||
- [ ] conjure notes:
|
- [ ] conjure notes:
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import Testing
|
|||||||
#expect(keyword != name)
|
#expect(keyword != name)
|
||||||
#expect(comment != keyword)
|
#expect(comment != keyword)
|
||||||
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
|
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
|
||||||
#expect((attributes("After")[.font] as? NSFont)?.isFixedPitch == false)
|
|
||||||
#expect(storage.string == text)
|
#expect(storage.string == text)
|
||||||
editor.string = "```python\ndef main():\n pass"
|
editor.string = "```python\ndef main():\n pass"
|
||||||
MarkdownHighlighting.apply(to: editor)
|
MarkdownHighlighting.apply(to: editor)
|
||||||
|
|||||||
@@ -48,17 +48,21 @@ func launcherAlignmentKeepsFractionalDisplayCentersOnAStableWindowGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func launcherPositionRestoresVisibleOriginsAndFallsBackForMissingOrOffscreenPositions() {
|
func launcherPositionRestoresCentersAcrossSizesAndFallsBackForMissingOrOffscreenPositions() {
|
||||||
let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040)
|
let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040)
|
||||||
let size = CGSize(width: 750, height: 474)
|
let size = CGSize(width: 750, height: 474)
|
||||||
let origin = CGPoint(x: -1500, y: 450)
|
let center = CGPoint(x: -1125, y: 687)
|
||||||
#expect(LauncherAlignment.restoredFrame(savedOrigin: origin, windowSize: size, visibleFrame: display)
|
for windowSize in [size, CGSize(width: 570, height: 404), CGSize(width: 840, height: 620)] {
|
||||||
== CGRect(origin: origin, size: size))
|
let frame = LauncherAlignment.restoredFrame(savedCenter: center, windowSize: windowSize, visibleFrame: display)
|
||||||
for invalid: CGPoint? in [nil, CGPoint(x: 0, y: 450), CGPoint(x: -1500, y: 39), CGPoint(x: CGFloat.nan, y: 450)] {
|
#expect(CGPoint(x: frame.midX, y: frame.midY) == center)
|
||||||
#expect(LauncherAlignment.restoredFrame(savedOrigin: invalid, windowSize: size, visibleFrame: display)
|
#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))
|
== CGRect(x: -1335, y: 398, width: 750, height: 474))
|
||||||
}
|
}
|
||||||
let small = CGRect(x: 0, y: 40, width: 800, height: 500)
|
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))
|
== CGRect(x: 25, y: 40, width: 750, height: 474))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user