feat: snap launcher to stippled edge alignment guides

This commit is contained in:
2026-10-12 12:25:18 +02:00
parent 97420f51e1
commit dc7cf07204
4 changed files with 101 additions and 12 deletions
+3 -3
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. Stippled screen-center guides appear during dragging; each axis brightens when the launcher is centered within one physical pixel. Guides use the current display's usable area, exclude the launcher itself, and disappear on release or dismissal. Positioning remains manual, without snapping.
- 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.
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.
@@ -82,7 +82,7 @@ The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView`
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
- `Sources/Conjure`: application lifecycle, launcher and Settings windows, and application launching.
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, and global shortcut registration.
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, and installation safety checks.
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, global shortcut registration, and launcher alignment geometry.
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, alignment boundaries, and installation safety checks.
The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
+30 -9
View File
@@ -1,4 +1,5 @@
import AppKit
import ConjureKit
@MainActor
final class LauncherAlignmentGuides {
@@ -16,21 +17,38 @@ 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 window.screen != nil else { return }
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, let screen = window.screen else { return }
guard dragging, !updating, let screen = window.screen else { return }
updating = true
defer { updating = false }
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)
}
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.needsDisplay = true
@@ -46,6 +64,7 @@ final class LauncherAlignmentGuides {
@MainActor
private final class AlignmentGuideView: NSView {
var launcherFrame = NSRect.zero
var targetFrame = NSRect.zero
var alignmentTolerance: CGFloat = 0.5
private static let dash: [CGFloat] = [1, 5]
private static let muted = SunsetPalette.accent.withAlphaComponent(0.4).cgColor
@@ -59,23 +78,25 @@ private final class AlignmentGuideView: NSView {
context.saveGState()
defer { context.restoreGState() }
context.addRect(bounds)
context.addRect(launcherFrame.insetBy(dx: -8, dy: -8))
context.addRect(launcherFrame.insetBy(dx: 0.75, dy: 0.75))
context.clip(using: .evenOdd)
context.setLineWidth(1.5)
context.setLineCap(.round)
context.setLineDash(phase: 0, lengths: Self.dash)
context.setShadow(offset: .zero, blur: 2, color: Self.shadow)
let centeredX = abs(launcherFrame.midX - bounds.midX) <= alignmentTolerance
let centeredX = abs(launcherFrame.minX - targetFrame.minX) <= alignmentTolerance
context.setStrokeColor(centeredX ? Self.aligned : Self.muted)
context.move(to: NSPoint(x: bounds.midX, y: bounds.minY))
context.addLine(to: NSPoint(x: bounds.midX, y: bounds.maxY))
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.midY - bounds.midY) <= alignmentTolerance
let centeredY = abs(launcherFrame.maxY - targetFrame.maxY) <= alignmentTolerance
context.setStrokeColor(centeredY ? Self.aligned : Self.muted)
context.move(to: NSPoint(x: bounds.minX, y: bounds.midY))
context.addLine(to: NSPoint(x: bounds.maxX, y: bounds.midY))
context.move(to: NSPoint(x: bounds.minX, y: targetFrame.maxY))
context.addLine(to: NSPoint(x: bounds.maxX, y: targetFrame.maxY))
context.strokePath()
}
}
@@ -0,0 +1,20 @@
import Foundation
public struct LauncherAlignment: Sendable {
public let targetFrame: CGRect
public let snappedOrigin: CGPoint
public init(windowFrame: CGRect, visibleFrame: CGRect) {
// 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),
width: windowFrame.width,
height: windowFrame.height
)
snappedOrigin = CGPoint(
x: abs(windowFrame.minX - targetFrame.minX) <= 12 ? targetFrame.minX : windowFrame.minX,
y: abs(windowFrame.maxY - targetFrame.maxY) <= 12 ? targetFrame.minY : windowFrame.minY
)
}
}
@@ -0,0 +1,48 @@
import ConjureKit
import Foundation
import Testing
@Test
func launcherAlignmentCentersWindowEdgesOnOffsetDisplaysAndTracksWindowSize() {
let display = CGRect(x: -1920, y: 40, width: 1920, height: 1040)
let window = CGRect(x: -1500, y: 400, 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.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))
}
@Test
func launcherAlignmentSnapsAxesIndependentlyAtBothThresholdBoundaries() {
let display = CGRect(x: 0, y: 0, width: 1500, height: 1000)
let target = CGPoint(x: 375, y: 263)
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))
let vertical = LauncherAlignment(windowFrame: CGRect(x: target.x + 40, y: target.y + offset, width: 750, height: 474), visibleFrame: display)
#expect(vertical.snappedOrigin == CGPoint(x: target.x + 40, y: target.y))
}
let both = LauncherAlignment(windowFrame: CGRect(x: target.x - 6, y: target.y + 9, width: 750, height: 474), visibleFrame: display)
#expect(both.snappedOrigin == target)
}
@Test
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)
#expect(LauncherAlignment(windowFrame: window, visibleFrame: display).snappedOrigin == window.origin)
}
}
@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 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))
let placed = CGRect(origin: alignment.snappedOrigin, size: window.size)
#expect(LauncherAlignment(windowFrame: placed, visibleFrame: display).snappedOrigin == placed.origin)
}