From 97420f51e11fad63b4bd571c4a90e9ce306239cf Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 12 Oct 2026 11:57:04 +0200 Subject: [PATCH] feat: show stippled centering guides while dragging launcher --- README.md | 2 +- Sources/Conjure/LauncherAlignmentGuides.swift | 81 +++++++++++++++++++ .../Conjure/LauncherWindowController.swift | 19 +++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 Sources/Conjure/LauncherAlignmentGuides.swift diff --git a/README.md b/README.md index c823235..8a5237e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/Conjure/LauncherAlignmentGuides.swift b/Sources/Conjure/LauncherAlignmentGuides.swift new file mode 100644 index 0000000..4735a0c --- /dev/null +++ b/Sources/Conjure/LauncherAlignmentGuides.swift @@ -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() + } +} diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index a9d1c33..e75b012 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -15,6 +15,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private var discoveryTask: Task? 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?) {