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
@@ -1,4 +1,5 @@
import AppKit
import ConjureKit
import Testing
@testable import Conjure
@@ -23,3 +24,47 @@ func launcherFadesStayAtViewportEdgesAfterScrollingAndResizing() throws {
#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)
}