From f3d173d24504ebdd216317114a971a0668b753ec Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Wed, 14 Oct 2026 12:27:33 +0200 Subject: [PATCH] feat: fade launcher list edges beneath bars --- README.md | 2 ++ .../Conjure/LauncherWindowController.swift | 29 ++++++++++++++++++- Tests/ConjureKitTests/LauncherFadeTests.swift | 29 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 Tests/ConjureKitTests/LauncherFadeTests.swift diff --git a/README.md b/README.md index 9495f76..a7e6e2d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ Files are never permanently deleted by uninstall. Trash moves preserve file meta The launcher keeps its Raycast-inspired 750 × 474-point, titlebar-free rounded panel, large borderless search field, compact icon rows, right-aligned application labels, and bottom-right launch pill. Its Sunset palette uses a dark brown background, soft amber and rust glows, warm cream text, and orange accents on the caret, selection, and action outline. +The scrolling result list fades to transparent over 16 points at its upper and lower edges, softening the cutoffs below search and above the footer. The fade stays fixed to the viewport while results scroll; the search bar and footer controls remain crisp. + The application icon uses the same orange `sparkles` SF Symbol on a self-contained, rounded dark-brown tile. `Scripts/icon.swift` renders every standard and Retina icon size, and `make build` packages the resulting `.icns` in the signed bundle for Finder and other system icon surfaces. Conjure remains an accessory app; adding an application icon does not add a Dock or app-switcher entry. Settings shares the launcher's dark appearance, frosted HUD material, Sunset background glows, cream headings, and orange control accents. The material and tint extend behind the title bar, while controls stay below it. Native window controls and shortcut recording behavior remain available. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index ebfc906..be9321b 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -257,7 +257,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } tableView.setAccessibilityLabel("Applications") - let scrollView = NSScrollView() + let scrollView = LauncherScrollView() scrollView.documentView = tableView scrollView.hasVerticalScroller = true scrollView.autohidesScrollers = true @@ -909,6 +909,33 @@ final class ApplicationCell: NSTableCellView { } } +@MainActor +final class LauncherScrollView: NSScrollView { + private let edgeFade = CAGradientLayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + edgeFade.colors = [NSColor.clear.cgColor, NSColor.black.cgColor, + NSColor.black.cgColor, NSColor.clear.cgColor] + edgeFade.startPoint = CGPoint(x: 0.5, y: 0) + edgeFade.endPoint = CGPoint(x: 0.5, y: 1) + layer?.mask = edgeFade + } + + required init?(coder: NSCoder) { fatalError("Conjure creates its scroll view programmatically") } + + override func layout() { + super.layout() + let fade = min(0.5, 16 / max(bounds.height, 1)) + CATransaction.begin() + CATransaction.setDisableActions(true) + edgeFade.frame = bounds + edgeFade.locations = [0, NSNumber(value: Double(fade)), NSNumber(value: Double(1 - fade)), 1] + CATransaction.commit() + } +} + @MainActor private final class ApplicationTableView: NSTableView { var openSelection: (() -> Void)? diff --git a/Tests/ConjureKitTests/LauncherFadeTests.swift b/Tests/ConjureKitTests/LauncherFadeTests.swift new file mode 100644 index 0000000..1407af1 --- /dev/null +++ b/Tests/ConjureKitTests/LauncherFadeTests.swift @@ -0,0 +1,29 @@ +import AppKit +import Testing +@testable import Conjure + +@Test @MainActor +func launcherFadesStayAtViewportEdgesAfterScrollingAndResizing() throws { + _ = NSApplication.shared + let scroll = LauncherScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + scroll.documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 600)) + for height in [120, 240] { + scroll.frame.size.height = CGFloat(height) + scroll.contentView.scroll(to: NSPoint(x: 0, y: 80)) + scroll.layoutSubtreeIfNeeded() + let mask = try #require(scroll.layer?.mask) + let context = try #require(CGContext(data: nil, width: 200, height: height, + bitsPerComponent: 8, bytesPerRow: 800, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + mask.render(in: context) + let pixels = try #require(context.data?.assumingMemoryBound(to: UInt8.self)) + func alpha(_ y: Int) -> UInt8 { pixels[y * 800 + 100 * 4 + 3] } + #expect(alpha(0) < 20) + #expect(alpha(8) > 80 && alpha(8) < 180) + #expect(alpha(20) == 255) + #expect(alpha(height / 2) == 255) + #expect(alpha(height - 21) == 255) + #expect(alpha(height - 9) > 80 && alpha(height - 9) < 180) + #expect(alpha(height - 1) < 20) + } +}