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) + } +}