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
+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.
- Command-comma opens the native Settings window.
- Right-click the launcher background for Settings and Quit.
- Drag the panel background to move it.
- 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.
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.
@@ -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?) {