Compare commits

..

7 Commits

Author SHA1 Message Date
Valdemar Lorenzen 7eb52bd1db style: use translucent dark fill for launcher action pill 2026-10-15 09:13:14 +02:00
Valdemar Lorenzen 639167950e fix: let action pill reveal launcher backdrop 2026-10-15 09:13:14 +02:00
Valdemar Lorenzen 5ae4ce7e4b style: reduce launcher action pill tint 2026-10-15 09:13:14 +02:00
Valdemar Lorenzen ffc8f81067 feat: scroll results beneath transparent launcher bars 2026-10-15 09:13:14 +02:00
Valdemar Lorenzen f3d173d245 feat: fade launcher list edges beneath bars 2026-10-15 09:13:14 +02:00
Valdemar Lorenzen 3fd9519169 feat: separate favorite application suggestions 2026-10-15 09:13:14 +02:00
Valdemar Lorenzen fd010ac864 feat: show favorite stars in application rows 2026-10-15 09:13:14 +02:00
7 changed files with 138 additions and 21 deletions
+1 -1
View File
@@ -8,6 +8,6 @@ let package = Package(
targets: [ targets: [
.target(name: "ConjureKit"), .target(name: "ConjureKit"),
.executableTarget(name: "Conjure", dependencies: ["ConjureKit"]), .executableTarget(name: "Conjure", dependencies: ["ConjureKit"]),
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit"]), .testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]),
] ]
) )
+7 -3
View File
@@ -71,7 +71,7 @@ Search is case- and accent-insensitive. Ranking is exact name, full-name prefix,
Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code. Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code.
With an empty search, favorites appear first. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group. With an empty search, favorites appear first, separated from the remaining applications by a 12-point gap when both groups are present. Typed searches have no group spacing. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group.
Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result. Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result.
@@ -116,7 +116,7 @@ This is not full Raycast parity: calendar/date/time-zone calculations, design un
Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application. Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application.
- **Favorite / Remove Favorite** toggles the selected application's persisted favorite status, updates ranking immediately, and preserves the selection without recording a launch. Open remains the default action. Reset ranking clears usage but does not remove a favorite. - **Favorite / Remove Favorite** toggles the selected application's persisted favorite status, updates ranking immediately, and preserves the selection without recording a launch. Favorited results show a filled star immediately to the left of the **Application** label. Open remains the default action. Reset ranking clears usage but does not remove a favorite.
- **Show in Finder** reveals the selected application bundle and dismisses the launcher without restoring the previous application's focus. Its icon is the native monochrome Finder glyph; macOS versions without that symbol use Finder's application icon. - **Show in Finder** reveals the selected application bundle and dismisses the launcher without restoring the previous application's focus. Its icon is the native monochrome Finder glyph; macOS versions without that symbol use Finder's application icon.
- **Reset ranking** clears only the selected bundle's launch count and last-launch timestamp, including persisted history. Results reorder immediately while retaining the selected application; other copies and applications keep their history. Future launches start counting again. - **Reset ranking** clears only the selected bundle's launch count and last-launch timestamp, including persisted history. Results reorder immediately while retaining the selected application; other copies and applications keep their history. Future launches start counting again.
- **Quit application** sends a normal macOS quit request to running instances of that exact bundle, not another copy with the same name or bundle identifier. A non-running application is a no-op. The launcher dismisses after an accepted request; the application may still present save prompts or cancel quitting. Rejected requests show a native alert. Conjure never force-quits the application. - **Quit application** sends a normal macOS quit request to running instances of that exact bundle, not another copy with the same name or bundle identifier. A non-running application is a no-op. The launcher dismisses after an accepted request; the application may still present save prompts or cancel quitting. Rejected requests show a native alert. Conjure never force-quits the application.
@@ -143,11 +143,15 @@ 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 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 extends 16 points beneath the transparent section-label and footer areas, behind their controls, and fades to transparent over 32 points at each edge. Matching content insets keep resting endpoint rows clear of the controls. The fade stays fixed to the viewport while results scroll; the search bar and footer controls remain crisp.
The launch pill uses a 20%-opaque dark-brown fill over the launcher's existing frosted background rather than adding a second, darker popover material. Its rounded orange outline and controls remain distinct.
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. 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. 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.
The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView` using the native HUD material. This exposes macOS's blurred backdrop while retaining the warm palette and glows; the system material supplies an opaque fallback when Reduce Transparency is enabled. Both the panel and launch pill use native rounded alpha masks alongside layer clipping, keeping material inside its rounded outline and making the window shadow follow the same shape. The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView` using the native HUD material. This exposes macOS's blurred backdrop while retaining the warm palette and glows; the system material supplies an opaque fallback when Reduce Transparency is enabled. The panel uses a native rounded alpha mask alongside layer clipping, keeping material inside its rounded outline and making the window shadow follow the same shape.
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection. AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
+1 -1
View File
@@ -2,7 +2,7 @@ import AppKit
@MainActor @MainActor
enum SunsetPalette { enum SunsetPalette {
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.35) static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.55)
static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1) static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1)
static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1) static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1)
static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1) static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1)
+86 -13
View File
@@ -257,7 +257,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
tableView.setAccessibilityLabel("Applications") tableView.setAccessibilityLabel("Applications")
let scrollView = NSScrollView() let scrollView = LauncherScrollView()
scrollView.documentView = tableView scrollView.documentView = tableView
scrollView.hasVerticalScroller = true scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true scrollView.autohidesScrollers = true
@@ -266,6 +266,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
scrollView.drawsBackground = false scrollView.drawsBackground = false
scrollView.contentView.drawsBackground = false scrollView.contentView.drawsBackground = false
scrollView.horizontalScrollElasticity = .none scrollView.horizontalScrollElasticity = .none
scrollView.automaticallyAdjustsContentInsets = false
scrollView.contentInsets = NSEdgeInsets(top: 16, left: 0, bottom: 16, right: 0)
statusLabel.font = .systemFont(ofSize: 12) statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = SunsetPalette.secondaryText statusLabel.textColor = SunsetPalette.secondaryText
@@ -295,11 +297,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let returnKey = ShortcutIndicator("↵") let returnKey = ShortcutIndicator("↵")
let actionKeys = ShortcutIndicator("⌘", "K") let actionKeys = ShortcutIndicator("⌘", "K")
let actionPill = NSVisualEffectView() let actionPill = NSView()
actionPill.material = .popover actionPill.wantsLayer = true
actionPill.blendingMode = .withinWindow actionPill.layer?.cornerRadius = 18
actionPill.state = .active
roundMaterialCorners(actionPill, radius: 18)
actionPill.layer?.borderWidth = 0.5 actionPill.layer?.borderWidth = 0.5
for view in [openButton, returnKey, actionsButton, actionKeys] { for view in [openButton, returnKey, actionsButton, actionKeys] {
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
@@ -310,7 +310,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
footer.orientation = .horizontal footer.orientation = .horizontal
footer.alignment = .centerY footer.alignment = .centerY
footer.spacing = 12 footer.spacing = 12
for view in [header, sectionLabel, scrollView, emptyLabel, footer] { for view in [scrollView, header, sectionLabel, emptyLabel, footer] {
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(view) content.addSubview(view)
} }
@@ -323,10 +323,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
symbol.heightAnchor.constraint(equalToConstant: 22), symbol.heightAnchor.constraint(equalToConstant: 22),
sectionLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16), sectionLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16),
sectionLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), sectionLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 6), scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: -10),
scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8), scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8), scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8), scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: 8),
emptyLabel.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor), emptyLabel.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor), emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
emptyLabel.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 16), emptyLabel.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 16),
@@ -350,7 +350,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
content.layer?.borderColor = border content.layer?.borderColor = border
actionPill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor actionPill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
actionPill.layer?.backgroundColor = SunsetPalette.accent.withAlphaComponent(0.08).cgColor actionPill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
} }
window.initialFirstResponder = searchField window.initialFirstResponder = searchField
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer] launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer]
@@ -527,12 +527,25 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
resultCount resultCount
} }
private func spacingBeforeRow(_ row: Int) -> CGFloat {
guard calculation == nil,
searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
row > 0, results.indices.contains(row),
history.isFavorite(results[row - 1]), !history.isFavorite(results[row]) else { return 0 }
return 12
}
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
LauncherRowView.rowHeight + spacingBeforeRow(row)
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("applicationCell") let identifier = NSUserInterfaceItemIdentifier("applicationCell")
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell) let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell)
?? makeCell(identifier: identifier) ?? makeCell(identifier: identifier)
if let calculation { if let calculation {
cell.textField?.stringValue = calculation.displayValue cell.textField?.stringValue = calculation.displayValue
cell.textField?.setAccessibilityLabel(nil)
cell.locationLabel.isHidden = true cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates" cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
: calculation.copyValue == nil ? "Error" : "Calculator" : calculation.copyValue == nil ? "Error" : "Calculator"
@@ -546,7 +559,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let location = locationLabels[application.id] let location = locationLabels[application.id]
cell.locationLabel.stringValue = location ?? "" cell.locationLabel.stringValue = location ?? ""
cell.locationLabel.isHidden = location == nil cell.locationLabel.isHidden = location == nil
cell.kindLabel.stringValue = "Application" let isFavorite = history.isFavorite(application)
cell.kindLabel.attributedStringValue = isFavorite
? ApplicationCell.favoriteKind : ApplicationCell.applicationKind
cell.textField?.setAccessibilityLabel(isFavorite ? "\(application.name), Favorite" : nil)
cell.imageView?.contentTintColor = nil cell.imageView?.contentTintColor = nil
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path) cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
cell.toolTip = application.url.path cell.toolTip = application.url.path
@@ -560,6 +576,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let view = (tableView.makeView(withIdentifier: identifier, owner: self) as? LauncherRowView) let view = (tableView.makeView(withIdentifier: identifier, owner: self) as? LauncherRowView)
?? LauncherRowView() ?? LauncherRowView()
view.identifier = identifier view.identifier = identifier
view.topSpacing = spacingBeforeRow(row)
return view return view
} }
@@ -729,11 +746,27 @@ final class ApplicationCell: NSTableCellView {
private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor] private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor]
private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor] private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor]
private static let timing = CAMediaTimingFunction(name: .easeOut) private static let timing = CAMediaTimingFunction(name: .easeOut)
static let applicationKind = NSAttributedString(string: "Application", attributes: [
.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.secondaryText
])
static let favoriteKind: NSAttributedString = {
let attachment = NSTextAttachment()
attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")?
.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 12, weight: .regular)
.applying(.init(paletteColors: [SunsetPalette.secondaryText])))
let size = attachment.image?.size ?? .zero
attachment.bounds = NSRect(x: 0, y: (NSFont.systemFont(ofSize: 14).capHeight - size.height) / 2,
width: size.width, height: size.height)
let title = NSMutableAttributedString(attachment: attachment)
title.append(NSAttributedString(string: " "))
title.append(applicationKind)
return title
}()
let locationLabel = NSTextField(labelWithString: "") let locationLabel = NSTextField(labelWithString: "")
let kindLabel = NSTextField(labelWithString: "Application") let kindLabel = NSTextField(labelWithString: "Application")
let numberHint = ShortcutIndicator( let numberHint = ShortcutIndicator(
"1", backgroundColor: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 1), "1", backgroundColor: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 0.5),
fontSize: 10, keySize: 20 fontSize: 10, keySize: 20
) )
private let kindFade = CAGradientLayer() private let kindFade = CAGradientLayer()
@@ -876,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, 32 / 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 @MainActor
private final class ApplicationTableView: NSTableView { private final class ApplicationTableView: NSTableView {
var openSelection: (() -> Void)? var openSelection: (() -> Void)?
@@ -1038,9 +1098,22 @@ final class LauncherRowView: NSTableRowView {
static let textSize: CGFloat = 14 static let textSize: CGFloat = 14
static let iconSize: CGFloat = 22 static let iconSize: CGFloat = 22
static let iconToTextSpacing: CGFloat = 12 static let iconToTextSpacing: CGFloat = 12
var topSpacing: CGFloat = 0 {
didSet { needsLayout = true; needsDisplay = true }
}
override func layout() {
super.layout()
for case let cell as ApplicationCell in subviews {
cell.frame = NSRect(x: 0, y: topSpacing, width: bounds.width, height: bounds.height - topSpacing)
}
}
override func drawSelection(in dirtyRect: NSRect) { override func drawSelection(in dirtyRect: NSRect) {
let selection = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10) var selectionBounds = bounds
selectionBounds.origin.y += topSpacing
selectionBounds.size.height -= topSpacing
let selection = NSBezierPath(roundedRect: selectionBounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
SunsetPalette.selectionFill.setFill() SunsetPalette.selectionFill.setFill()
selection.fill() selection.fill()
let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast
-3
View File
@@ -1,3 +0,0 @@
- [x] Add Favorite / Remove Favorite actions and persist favorites by application bundle.
- Empty search: favorites first, then usage ranking within each group.
- Typed search: match quality first, then favorites, launch count, recency, and alphabetical order.
@@ -0,0 +1,18 @@
import AppKit
import Testing
@testable import Conjure
@Test @MainActor
func favoriteSpacingLeavesApplicationContentAtNormalHeightAndResetsOnReuse() {
_ = NSApplication.shared
let row = LauncherRowView(frame: NSRect(x: 0, y: 0, width: 650, height: 50))
let cell = ApplicationCell(frame: row.bounds)
row.addSubview(cell)
row.topSpacing = 12
row.layoutSubtreeIfNeeded()
#expect(cell.frame == NSRect(x: 0, y: 12, width: 650, height: 38))
row.frame.size.height = 38
row.topSpacing = 0
row.layoutSubtreeIfNeeded()
#expect(cell.frame == NSRect(x: 0, y: 0, width: 650, height: 38))
}
@@ -0,0 +1,25 @@
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(height / 2) == 255)
#expect(alpha(height - 1) < 20)
}
}