Files
conjure/Tests/ConjureKitTests/LauncherFadeTests.swift
T
Valdemar Lorenzen 27c203b336 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.
2026-10-16 09:29:34 +02:00

71 lines
3.7 KiB
Swift

import AppKit
import ConjureKit
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(height / 2) == 255)
#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)
}