diff --git a/README.md b/README.md index 0872ba9..48b44a3 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to - Standard macOS text-editing shortcuts work in the search field. - Command-comma opens the native Settings window. - Right-click the launcher background for Settings and Quit. -- Drag the panel background to move it. Three stippled guides mark the centered launcher's left, right, and top edges on the current display's usable area. The side guides are exactly one launcher-width apart. Each axis snaps independently within 12 points of its guide, brightens when aligned, and releases when you drag farther away, including with slow movements. Guides 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 disappear on release or dismissal and do not intercept input. 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/LauncherAlignmentGuides.swift b/Sources/Conjure/LauncherAlignmentGuides.swift index 52492f9..e3bc459 100644 --- a/Sources/Conjure/LauncherAlignmentGuides.swift +++ b/Sources/Conjure/LauncherAlignmentGuides.swift @@ -17,47 +17,39 @@ final class LauncherAlignmentGuides { return panel }() private var dragging = false - private var updating = false - private var dragOrigin = NSPoint.zero - private var mouseOrigin = NSPoint.zero func begin(for window: NSWindow) { guard !dragging, window.screen != nil else { return } dragging = true - dragOrigin = window.frame.origin - mouseOrigin = NSEvent.mouseLocation update(for: window) overlay.level = window.level overlay.order(.below, relativeTo: window.windowNumber) } func update(for window: NSWindow) { - guard dragging, !updating, let screen = window.screen else { return } - updating = true - defer { updating = false } + guard dragging, let screen = window.screen else { return } let frame = screen.visibleFrame - let mouse = NSEvent.mouseLocation - let proposed = NSRect( - origin: NSPoint(x: dragOrigin.x + mouse.x - mouseOrigin.x, y: dragOrigin.y + mouse.y - mouseOrigin.y), - size: window.frame.size - ) - let alignment = LauncherAlignment(windowFrame: proposed, visibleFrame: frame) - if window.frame.origin != alignment.snappedOrigin { - window.setFrameOrigin(alignment.snappedOrigin) - } + let alignment = LauncherAlignment(windowFrame: window.frame, visibleFrame: frame) if overlay.frame != frame { overlay.setFrame(frame, display: false) } view.targetFrame = alignment.targetFrame.offsetBy(dx: -frame.minX, dy: -frame.minY) view.launcherFrame = window.frame.offsetBy(dx: -frame.minX, dy: -frame.minY) - view.alignmentTolerance = 1 / screen.backingScaleFactor + view.snapsHorizontally = alignment.snappedOrigin.x == alignment.targetFrame.minX + view.snapsVertically = alignment.snappedOrigin.y == alignment.targetFrame.minY view.needsDisplay = true } - func end() { + func end(snapping window: NSWindow? = nil) { guard dragging else { return } dragging = false overlay.orderOut(nil) + if let window, let screen = window.screen { + let origin = LauncherAlignment(windowFrame: window.frame, visibleFrame: screen.visibleFrame).snappedOrigin + if window.frame.origin != origin { + window.setFrameOrigin(origin) + } + } } } @@ -65,7 +57,8 @@ final class LauncherAlignmentGuides { private final class AlignmentGuideView: NSView { var launcherFrame = NSRect.zero var targetFrame = NSRect.zero - var alignmentTolerance: CGFloat = 0.5 + var snapsHorizontally = false + var snapsVertically = false private static let dash: [CGFloat] = [1, 5] private static let muted = SunsetPalette.accent.withAlphaComponent(0.4).cgColor private static let aligned = SunsetPalette.accent.withAlphaComponent(0.95).cgColor @@ -85,16 +78,14 @@ private final class AlignmentGuideView: NSView { context.setLineDash(phase: 0, lengths: Self.dash) context.setShadow(offset: .zero, blur: 2, color: Self.shadow) - let centeredX = abs(launcherFrame.minX - targetFrame.minX) <= alignmentTolerance - context.setStrokeColor(centeredX ? Self.aligned : Self.muted) + context.setStrokeColor(snapsHorizontally ? Self.aligned : Self.muted) context.move(to: NSPoint(x: targetFrame.minX, y: bounds.minY)) context.addLine(to: NSPoint(x: targetFrame.minX, y: bounds.maxY)) context.move(to: NSPoint(x: targetFrame.maxX, y: bounds.minY)) context.addLine(to: NSPoint(x: targetFrame.maxX, y: bounds.maxY)) context.strokePath() - let centeredY = abs(launcherFrame.maxY - targetFrame.maxY) <= alignmentTolerance - context.setStrokeColor(centeredY ? Self.aligned : Self.muted) + context.setStrokeColor(snapsVertically ? Self.aligned : Self.muted) context.move(to: NSPoint(x: bounds.minX, y: targetFrame.maxY)) context.addLine(to: NSPoint(x: bounds.maxX, y: targetFrame.maxY)) context.strokePath() diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index e75b012..863c4fe 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -40,7 +40,7 @@ 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() } + window.dragEnded = { [weak self] in self?.alignmentGuides?.end(snapping: self?.window) } } required init?(coder: NSCoder) { @@ -505,7 +505,7 @@ private final class LauncherPanel: NSPanel { } } super.sendEvent(event) - if event.type == .leftMouseDown || event.type == .leftMouseUp { + if event.type == .leftMouseUp || (event.type == .leftMouseDown && NSEvent.pressedMouseButtons & 1 == 0) { dragEnded?() } } diff --git a/Sources/ConjureKit/LauncherAlignment.swift b/Sources/ConjureKit/LauncherAlignment.swift index 329e58f..8d149e2 100644 --- a/Sources/ConjureKit/LauncherAlignment.swift +++ b/Sources/ConjureKit/LauncherAlignment.swift @@ -8,7 +8,7 @@ public struct LauncherAlignment: Sendable { // Whole-point origins keep the guides aligned with AppKit's window positioning. targetFrame = CGRect( x: (visibleFrame.midX - windowFrame.width / 2).rounded(.down), - y: (visibleFrame.midY - windowFrame.height / 2).rounded(.down), + y: (visibleFrame.maxY - visibleFrame.height / 5 - windowFrame.height).rounded(.down), width: windowFrame.width, height: windowFrame.height ) diff --git a/Tests/ConjureKitTests/LauncherAlignmentTests.swift b/Tests/ConjureKitTests/LauncherAlignmentTests.swift index 52a9130..6cc070e 100644 --- a/Tests/ConjureKitTests/LauncherAlignmentTests.swift +++ b/Tests/ConjureKitTests/LauncherAlignmentTests.swift @@ -3,20 +3,20 @@ import Foundation import Testing @Test -func launcherAlignmentCentersWindowEdgesOnOffsetDisplaysAndTracksWindowSize() { +func launcherAlignmentPositionsWindowEdgesOnOffsetDisplaysAndTracksWindowSize() { let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040) - let window = CGRect(x: -1500, y: 400, width: 750, height: 474) + let window = CGRect(x: -1500, y: 450, width: 750, height: 474) let alignment = LauncherAlignment(windowFrame: window, visibleFrame: display) - #expect(alignment.targetFrame == CGRect(x: -1335, y: 323, width: 750, height: 474)) + #expect(alignment.targetFrame == CGRect(x: -1335, y: 398, width: 750, height: 474)) #expect(alignment.snappedOrigin == window.origin) let resized = LauncherAlignment(windowFrame: CGRect(x: -1500, y: 400, width: 840, height: 500), visibleFrame: display) - #expect(resized.targetFrame == CGRect(x: -1380, y: 310, width: 840, height: 500)) + #expect(resized.targetFrame == CGRect(x: -1380, y: 372, width: 840, height: 500)) } @Test func launcherAlignmentSnapsAxesIndependentlyAtBothThresholdBoundaries() { let display = CGRect(x: 0, y: 0, width: 1500, height: 1000) - let target = CGPoint(x: 375, y: 263) + let target = CGPoint(x: 375, y: 326) for offset: CGFloat in [-12, 12] { let horizontal = LauncherAlignment(windowFrame: CGRect(x: target.x + offset, y: target.y + 40, width: 750, height: 474), visibleFrame: display) #expect(horizontal.snappedOrigin == CGPoint(x: target.x, y: target.y + 40)) @@ -31,7 +31,7 @@ func launcherAlignmentSnapsAxesIndependentlyAtBothThresholdBoundaries() { func launcherAlignmentReleasesOutsideEitherSideOfTheSnapThreshold() { let display = CGRect(x: 0, y: 0, width: 1500, height: 1000) for offset: CGFloat in [-12.01, 12.01] { - let window = CGRect(x: 375 + offset, y: 263 + offset, width: 750, height: 474) + let window = CGRect(x: 375 + offset, y: 326 + offset, width: 750, height: 474) #expect(LauncherAlignment(windowFrame: window, visibleFrame: display).snappedOrigin == window.origin) } } @@ -39,10 +39,10 @@ func launcherAlignmentReleasesOutsideEitherSideOfTheSnapThreshold() { @Test func launcherAlignmentKeepsFractionalDisplayCentersOnAStableWindowGrid() { let display = CGRect(x: 0, y: 0, width: 1512, height: 949) - let window = CGRect(x: 390, y: 230, width: 750, height: 474) + let window = CGRect(x: 390, y: 280, width: 750, height: 474) let alignment = LauncherAlignment(windowFrame: window, visibleFrame: display) - #expect(alignment.targetFrame == CGRect(x: 381, y: 237, width: 750, height: 474)) - #expect(alignment.snappedOrigin == CGPoint(x: 381, y: 237)) + #expect(alignment.targetFrame == CGRect(x: 381, y: 285, width: 750, height: 474)) + #expect(alignment.snappedOrigin == CGPoint(x: 381, y: 285)) let placed = CGRect(origin: alignment.snappedOrigin, size: window.size) #expect(LauncherAlignment(windowFrame: placed, visibleFrame: display).snappedOrigin == placed.origin) }