feat: snap launcher on release at upper-third alignment

This commit is contained in:
2026-10-12 12:48:39 +02:00
parent dc7cf07204
commit 54a7d8f494
5 changed files with 28 additions and 37 deletions
+1 -1
View File
@@ -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.
+15 -24
View File
@@ -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()
@@ -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?()
}
}
+1 -1
View File
@@ -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
)
@@ -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)
}