feat: show stippled centering guides while dragging launcher

This commit is contained in:
2026-10-12 11:57:04 +02:00
parent d4312eb839
commit 97420f51e1
3 changed files with 101 additions and 1 deletions
@@ -0,0 +1,81 @@
import AppKit
@MainActor
final class LauncherAlignmentGuides {
private let view = AlignmentGuideView()
private lazy var overlay: NSPanel = {
let panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = false
panel.ignoresMouseEvents = true
panel.isReleasedWhenClosed = false
panel.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
panel.contentView = view
panel.setAccessibilityElement(false)
return panel
}()
private var dragging = false
func begin(for window: NSWindow) {
guard window.screen != nil else { return }
dragging = true
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 }
let frame = screen.visibleFrame
if overlay.frame != frame {
overlay.setFrame(frame, display: false)
}
view.launcherFrame = window.frame.offsetBy(dx: -frame.minX, dy: -frame.minY)
view.alignmentTolerance = 1 / screen.backingScaleFactor
view.needsDisplay = true
}
func end() {
guard dragging else { return }
dragging = false
overlay.orderOut(nil)
}
}
@MainActor
private final class AlignmentGuideView: NSView {
var launcherFrame = NSRect.zero
var alignmentTolerance: CGFloat = 0.5
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
private static let shadow = NSColor.black.withAlphaComponent(0.6).cgColor
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
defer { context.restoreGState() }
context.addRect(bounds)
context.addRect(launcherFrame.insetBy(dx: -8, dy: -8))
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
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.strokePath()
let centeredY = abs(launcherFrame.midY - bounds.midY) <= 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.strokePath()
}
}
@@ -15,6 +15,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
private var previousApplication: NSRunningApplication?
private var alignmentGuides: LauncherAlignmentGuides?
init(history: LaunchHistory = LaunchHistory()) {
self.history = history
@@ -39,6 +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() }
}
required init?(coder: NSCoder) {
@@ -72,6 +74,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
alignmentGuides?.end()
let previous = previousApplication
previousApplication = nil
window?.orderOut(nil)
@@ -81,6 +84,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
func windowWillMove(_ notification: Notification) {
guard let window else { return }
let guides = alignmentGuides ?? LauncherAlignmentGuides()
alignmentGuides = guides
guides.begin(for: window)
}
func windowDidMove(_ notification: Notification) {
guard let window else { return }
alignmentGuides?.update(for: window)
}
func windowDidResignKey(_ notification: Notification) {
guard !isLaunching, window?.attachedSheet == nil else { return }
dismiss(restoringFocus: false)
@@ -473,6 +488,7 @@ private final class LauncherPanel: NSPanel {
override var canBecomeMain: Bool { true }
var navigateSelection: ((Int) -> Void)?
var dismiss: (() -> Void)?
var dragEnded: (() -> Void)?
override func sendEvent(_ event: NSEvent) {
if event.type == .keyDown, attachedSheet == nil,
@@ -489,6 +505,9 @@ private final class LauncherPanel: NSPanel {
}
}
super.sendEvent(event)
if event.type == .leftMouseDown || event.type == .leftMouseUp {
dragEnded?()
}
}
override func performClose(_ sender: Any?) {