feat: snap launcher on release at upper-third alignment
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user