fix: keep resting content below the top fade

The 32-point viewport fade covered launcher rows with only 16 points of inset and emoji with only 12. Match their top spacing to the shared fade height while keeping the viewport mask fixed during scrolling.
This commit is contained in:
Valdemar Lorenzen
2026-10-16 09:26:04 +02:00
parent f579d43b00
commit 27c203b336
5 changed files with 53 additions and 5 deletions
+2
View File
@@ -29,6 +29,8 @@ Open Conjure, type an application name, and press Return to launch it or bring i
Favorited launcher entries show a small star before their **Application** or **Command** label. Favorited launcher entries show a small star before their **Application** or **Command** label.
Launcher results and the emoji grid start below the top fade; scrolling moves content naturally through it.
Conjure searches the system and user Applications folders. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background to reposition it; its position is remembered. Conjure searches the system and user Applications folders. Hover over a result to see its path; duplicate names show their locations. Drag the launcher background to reposition it; its position is remembered.
Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut. Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut.
+2 -2
View File
@@ -39,7 +39,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
layout.itemSize = NSSize(width: 64, height: 56) layout.itemSize = NSSize(width: 64, height: 56)
layout.minimumInteritemSpacing = 6 layout.minimumInteritemSpacing = 6
layout.minimumLineSpacing = 6 layout.minimumLineSpacing = 6
layout.sectionInset = NSEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) layout.sectionInset = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 16, bottom: 12, right: 16)
collection.collectionViewLayout = layout collection.collectionViewLayout = layout
collection.backgroundColors = [.clear] collection.backgroundColors = [.clear]
collection.dataSource = self collection.dataSource = self
@@ -260,7 +260,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
} }
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, insetForSectionAt section: Int) -> NSEdgeInsets { func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, insetForSectionAt section: Int) -> NSEdgeInsets {
NSEdgeInsets(top: section == 1 ? 18 : 12, left: 16, bottom: splitFavorites && section == 0 ? 0 : 12, right: 16) NSEdgeInsets(top: section == 1 ? 18 : LauncherScrollView.fadeHeight, left: 16, bottom: splitFavorites && section == 0 ? 0 : 12, right: 16)
} }
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
@@ -310,7 +310,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
scrollView.contentView.drawsBackground = false scrollView.contentView.drawsBackground = false
scrollView.horizontalScrollElasticity = .none scrollView.horizontalScrollElasticity = .none
scrollView.automaticallyAdjustsContentInsets = false scrollView.automaticallyAdjustsContentInsets = false
scrollView.contentInsets = NSEdgeInsets(top: 16, left: 0, bottom: 16, right: 0) scrollView.contentInsets = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 0, bottom: 16, right: 0)
statusLabel.font = .systemFont(ofSize: 12) statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = SunsetPalette.secondaryText statusLabel.textColor = SunsetPalette.secondaryText
@@ -1079,6 +1079,7 @@ final class ApplicationCell: NSTableCellView {
@MainActor @MainActor
final class LauncherScrollView: NSScrollView { final class LauncherScrollView: NSScrollView {
static let fadeHeight: CGFloat = 32
private let edgeFade = CAGradientLayer() private let edgeFade = CAGradientLayer()
override init(frame frameRect: NSRect) { override init(frame frameRect: NSRect) {
@@ -1095,7 +1096,7 @@ final class LauncherScrollView: NSScrollView {
override func layout() { override func layout() {
super.layout() super.layout()
let fade = min(0.5, 32 / max(bounds.height, 1)) let fade = min(0.5, Self.fadeHeight / max(bounds.height, 1))
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
edgeFade.frame = bounds edgeFade.frame = bounds
+1 -1
View File
@@ -1,6 +1,6 @@
- [x] add an emoji picker inspired raycast's emoji picker - [x] add an emoji picker inspired raycast's emoji picker
- [x] make the star/favorite symbol a bit smaller when prefixed launcher list entries (e.g. "<star> Application", or "<star> Command") - [x] make the star/favorite symbol a bit smaller when prefixed launcher list entries (e.g. "<star> Application", or "<star> Command")
- [ ] currently the top fade (from the top section/overlay, typically some search input) overshadows the top of main content of the window. - [x] currently the top fade (from the top section/overlay, typically some search input) overshadows the top of main content of the window.
- spacing should be added such that the fade doesn't overlay the content. - spacing should be added such that the fade doesn't overlay the content.
- of course, if the user scrolls some scrollable content, the content would (and should) naturally be overshadowed. - of course, if the user scrolls some scrollable content, the content would (and should) naturally be overshadowed.
- [ ] add a notes app inspired by raycast notes - [ ] add a notes app inspired by raycast notes
@@ -1,4 +1,5 @@
import AppKit import AppKit
import ConjureKit
import Testing import Testing
@testable import Conjure @testable import Conjure
@@ -23,3 +24,47 @@ func launcherFadesStayAtViewportEdgesAfterScrollingAndResizing() throws {
#expect(alpha(height - 1) < 20) #expect(alpha(height - 1) < 20)
} }
} }
@Test @MainActor
func launcherAndEmojiContentStartBelowTheTopFade() throws {
_ = NSApplication.shared
let suite = "ConjureFade.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
func topOpacity(_ rect: NSRect, in scroll: LauncherScrollView) throws -> UInt8 {
let width = Int(scroll.bounds.width), height = Int(scroll.bounds.height)
let y = Int(rect.minY - scroll.contentView.bounds.minY)
try #require((0..<height).contains(y))
let context = try #require(CGContext(data: nil, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
let mask = try #require(scroll.layer?.mask)
mask.render(in: context)
let pixels = try #require(context.data?.assumingMemoryBound(to: UInt8.self))
return pixels[y * width * 4 + width / 2 * 4 + 3]
}
let launcher = LauncherWindowController(
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) })
)
let content = try #require(launcher.window?.contentView)
let search = try #require(content.subviews.compactMap { $0 as? NSStackView }
.flatMap(\.arrangedSubviews).compactMap { $0 as? NSSearchField }.first)
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
content.layoutSubtreeIfNeeded()
let tableScroll = try #require(content.subviews.compactMap { $0 as? LauncherScrollView }.first)
let table = try #require(tableScroll.documentView as? NSTableView)
let firstRow = tableScroll.contentView.convert(table.rect(ofRow: 0), from: table)
#expect(try topOpacity(firstRow, in: tableScroll) == 255)
let picker = EmojiPickerController(defaults: defaults)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 400),
styleMask: [.borderless], backing: .buffered, defer: false)
window.contentView = picker.view
picker.view.layoutSubtreeIfNeeded()
let emojiScroll = try #require(picker.view.subviews.compactMap { $0 as? LauncherScrollView }.first)
let grid = try #require(emojiScroll.documentView as? NSCollectionView)
let path = IndexPath(item: 0, section: 0)
let attributes = try #require(grid.collectionViewLayout?.layoutAttributesForItem(at: path))
let firstEmoji = emojiScroll.contentView.convert(attributes.frame, from: grid)
#expect(try topOpacity(firstEmoji, in: emojiScroll) == 255)
}