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. - Standard macOS text-editing shortcuts work in the search field.
- Command-comma opens the native Settings window. - Command-comma opens the native Settings window.
- Right-click the launcher background for Settings and Quit. - 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. 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 return panel
}() }()
private var dragging = false private var dragging = false
private var updating = false
private var dragOrigin = NSPoint.zero
private var mouseOrigin = NSPoint.zero
func begin(for window: NSWindow) { func begin(for window: NSWindow) {
guard !dragging, window.screen != nil else { return } guard !dragging, window.screen != nil else { return }
dragging = true dragging = true
dragOrigin = window.frame.origin
mouseOrigin = NSEvent.mouseLocation
update(for: window) update(for: window)
overlay.level = window.level overlay.level = window.level
overlay.order(.below, relativeTo: window.windowNumber) overlay.order(.below, relativeTo: window.windowNumber)
} }
func update(for window: NSWindow) { func update(for window: NSWindow) {
guard dragging, !updating, let screen = window.screen else { return } guard dragging, let screen = window.screen else { return }
updating = true
defer { updating = false }
let frame = screen.visibleFrame let frame = screen.visibleFrame
let mouse = NSEvent.mouseLocation let alignment = LauncherAlignment(windowFrame: window.frame, visibleFrame: frame)
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)
}
if overlay.frame != frame { if overlay.frame != frame {
overlay.setFrame(frame, display: false) overlay.setFrame(frame, display: false)
} }
view.targetFrame = alignment.targetFrame.offsetBy(dx: -frame.minX, dy: -frame.minY) view.targetFrame = alignment.targetFrame.offsetBy(dx: -frame.minX, dy: -frame.minY)
view.launcherFrame = window.frame.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 view.needsDisplay = true
} }
func end() { func end(snapping window: NSWindow? = nil) {
guard dragging else { return } guard dragging else { return }
dragging = false dragging = false
overlay.orderOut(nil) 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 { private final class AlignmentGuideView: NSView {
var launcherFrame = NSRect.zero var launcherFrame = NSRect.zero
var targetFrame = 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 dash: [CGFloat] = [1, 5]
private static let muted = SunsetPalette.accent.withAlphaComponent(0.4).cgColor private static let muted = SunsetPalette.accent.withAlphaComponent(0.4).cgColor
private static let aligned = SunsetPalette.accent.withAlphaComponent(0.95).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.setLineDash(phase: 0, lengths: Self.dash)
context.setShadow(offset: .zero, blur: 2, color: Self.shadow) context.setShadow(offset: .zero, blur: 2, color: Self.shadow)
let centeredX = abs(launcherFrame.minX - targetFrame.minX) <= alignmentTolerance context.setStrokeColor(snapsHorizontally ? Self.aligned : Self.muted)
context.setStrokeColor(centeredX ? Self.aligned : Self.muted)
context.move(to: NSPoint(x: targetFrame.minX, y: bounds.minY)) context.move(to: NSPoint(x: targetFrame.minX, y: bounds.minY))
context.addLine(to: NSPoint(x: targetFrame.minX, y: bounds.maxY)) context.addLine(to: NSPoint(x: targetFrame.minX, y: bounds.maxY))
context.move(to: NSPoint(x: targetFrame.maxX, y: bounds.minY)) context.move(to: NSPoint(x: targetFrame.maxX, y: bounds.minY))
context.addLine(to: NSPoint(x: targetFrame.maxX, y: bounds.maxY)) context.addLine(to: NSPoint(x: targetFrame.maxX, y: bounds.maxY))
context.strokePath() context.strokePath()
let centeredY = abs(launcherFrame.maxY - targetFrame.maxY) <= alignmentTolerance context.setStrokeColor(snapsVertically ? Self.aligned : Self.muted)
context.setStrokeColor(centeredY ? Self.aligned : Self.muted)
context.move(to: NSPoint(x: bounds.minX, y: targetFrame.maxY)) context.move(to: NSPoint(x: bounds.minX, y: targetFrame.maxY))
context.addLine(to: NSPoint(x: bounds.maxX, y: targetFrame.maxY)) context.addLine(to: NSPoint(x: bounds.maxX, y: targetFrame.maxY))
context.strokePath() context.strokePath()
@@ -40,7 +40,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
window.delegate = self window.delegate = self
window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) } window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) }
window.dismiss = { [weak self] in self?.dismiss() } 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) { required init?(coder: NSCoder) {
@@ -505,7 +505,7 @@ private final class LauncherPanel: NSPanel {
} }
} }
super.sendEvent(event) super.sendEvent(event)
if event.type == .leftMouseDown || event.type == .leftMouseUp { if event.type == .leftMouseUp || (event.type == .leftMouseDown && NSEvent.pressedMouseButtons & 1 == 0) {
dragEnded?() 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. // Whole-point origins keep the guides aligned with AppKit's window positioning.
targetFrame = CGRect( targetFrame = CGRect(
x: (visibleFrame.midX - windowFrame.width / 2).rounded(.down), 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, width: windowFrame.width,
height: windowFrame.height height: windowFrame.height
) )
@@ -3,20 +3,20 @@ import Foundation
import Testing import Testing
@Test @Test
func launcherAlignmentCentersWindowEdgesOnOffsetDisplaysAndTracksWindowSize() { func launcherAlignmentPositionsWindowEdgesOnOffsetDisplaysAndTracksWindowSize() {
let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040) 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) 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) #expect(alignment.snappedOrigin == window.origin)
let resized = LauncherAlignment(windowFrame: CGRect(x: -1500, y: 400, width: 840, height: 500), visibleFrame: display) 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 @Test
func launcherAlignmentSnapsAxesIndependentlyAtBothThresholdBoundaries() { func launcherAlignmentSnapsAxesIndependentlyAtBothThresholdBoundaries() {
let display = CGRect(x: 0, y: 0, width: 1500, height: 1000) 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] { 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) 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)) #expect(horizontal.snappedOrigin == CGPoint(x: target.x, y: target.y + 40))
@@ -31,7 +31,7 @@ func launcherAlignmentSnapsAxesIndependentlyAtBothThresholdBoundaries() {
func launcherAlignmentReleasesOutsideEitherSideOfTheSnapThreshold() { func launcherAlignmentReleasesOutsideEitherSideOfTheSnapThreshold() {
let display = CGRect(x: 0, y: 0, width: 1500, height: 1000) let display = CGRect(x: 0, y: 0, width: 1500, height: 1000)
for offset: CGFloat in [-12.01, 12.01] { 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) #expect(LauncherAlignment(windowFrame: window, visibleFrame: display).snappedOrigin == window.origin)
} }
} }
@@ -39,10 +39,10 @@ func launcherAlignmentReleasesOutsideEitherSideOfTheSnapThreshold() {
@Test @Test
func launcherAlignmentKeepsFractionalDisplayCentersOnAStableWindowGrid() { func launcherAlignmentKeepsFractionalDisplayCentersOnAStableWindowGrid() {
let display = CGRect(x: 0, y: 0, width: 1512, height: 949) 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) let alignment = LauncherAlignment(windowFrame: window, visibleFrame: display)
#expect(alignment.targetFrame == CGRect(x: 381, y: 237, width: 750, height: 474)) #expect(alignment.targetFrame == CGRect(x: 381, y: 285, width: 750, height: 474))
#expect(alignment.snappedOrigin == CGPoint(x: 381, y: 237)) #expect(alignment.snappedOrigin == CGPoint(x: 381, y: 285))
let placed = CGRect(origin: alignment.snappedOrigin, size: window.size) let placed = CGRect(origin: alignment.snappedOrigin, size: window.size)
#expect(LauncherAlignment(windowFrame: placed, visibleFrame: display).snappedOrigin == placed.origin) #expect(LauncherAlignment(windowFrame: placed, visibleFrame: display).snappedOrigin == placed.origin)
} }