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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user