From dc7cf072049a80c470a962504bd2ff7e5b3f52f5 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 12 Oct 2026 12:25:18 +0200 Subject: [PATCH] feat: snap launcher to stippled edge alignment guides --- README.md | 6 +-- Sources/Conjure/LauncherAlignmentGuides.swift | 39 +++++++++++---- Sources/ConjureKit/LauncherAlignment.swift | 20 ++++++++ .../LauncherAlignmentTests.swift | 48 +++++++++++++++++++ 4 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 Sources/ConjureKit/LauncherAlignment.swift create mode 100644 Tests/ConjureKitTests/LauncherAlignmentTests.swift diff --git a/README.md b/README.md index 8a5237e..0872ba9 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. 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:)). diff --git a/Sources/Conjure/LauncherAlignmentGuides.swift b/Sources/Conjure/LauncherAlignmentGuides.swift index 4735a0c..52492f9 100644 --- a/Sources/Conjure/LauncherAlignmentGuides.swift +++ b/Sources/Conjure/LauncherAlignmentGuides.swift @@ -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() } } diff --git a/Sources/ConjureKit/LauncherAlignment.swift b/Sources/ConjureKit/LauncherAlignment.swift new file mode 100644 index 0000000..329e58f --- /dev/null +++ b/Sources/ConjureKit/LauncherAlignment.swift @@ -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 + ) + } +} diff --git a/Tests/ConjureKitTests/LauncherAlignmentTests.swift b/Tests/ConjureKitTests/LauncherAlignmentTests.swift new file mode 100644 index 0000000..52a9130 --- /dev/null +++ b/Tests/ConjureKitTests/LauncherAlignmentTests.swift @@ -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) +}