fix: paint window-aligned gradients within popup bounds

This commit is contained in:
2026-10-18 01:30:58 +02:00
parent 75c9a329d6
commit cea12f819b
4 changed files with 58 additions and 32 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ The launcher, emoji picker, notes, and translation tool open immediately without
Right-click the launcher background for Settings and Quit. Actions include favorites, resetting usage ranking, revealing applications in Finder, quitting them, and uninstalling them.
Action and provider popups crop the launcher’s full-size background at their own position, keeping the glows continuous without showing the results underneath. They resolve their layout and invalidate the background before fading in, including when reopening or switching popup types.
Action and provider popups paint a popup-sized background using the launcher window’s gradient coordinates, keeping the glows continuous without showing the results underneath. They do not retain an oversized clipped background between presentations. Layout and background invalidation finish before the opening fade, including when switching popup types.
### Global shortcuts and launch at login
@@ -118,6 +118,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
content.trailingAnchor.constraint(equalTo: view.trailingAnchor),
content.topAnchor.constraint(equalTo: view.topAnchor),
content.bottomAnchor.constraint(equalTo: view.bottomAnchor),
background.leadingAnchor.constraint(equalTo: content.leadingAnchor),
background.trailingAnchor.constraint(equalTo: content.trailingAnchor),
background.topAnchor.constraint(equalTo: content.topAnchor),
background.bottomAnchor.constraint(equalTo: content.bottomAnchor),
applicationLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
applicationLabel.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
applicationLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 14),
@@ -197,13 +201,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
view.alphaValue = 0
view.translatesAutoresizingMaskIntoConstraints = false
host.addSubview(view)
// Crop the window-sized background; filtering must not rescale its glows.
NSLayoutConstraint.activate([
background.leadingAnchor.constraint(equalTo: host.leadingAnchor),
background.trailingAnchor.constraint(equalTo: host.trailingAnchor),
background.topAnchor.constraint(equalTo: host.topAnchor),
background.bottomAnchor.constraint(equalTo: host.bottomAnchor)
])
// Paint only the popup's bounds, using the window's gradient coordinates.
background.canvas = host
NSLayoutConstraint.activate([
view.trailingAnchor.constraint(equalTo: pill.trailingAnchor),
belowAnchor
@@ -214,7 +213,6 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
coveredPillAlpha = pill.alphaValue
}
coveredPill?.setAccessibilityHidden(true)
// Resolve the background crop before fading in a newly positioned popup.
host.layoutSubtreeIfNeeded()
background.needsDisplay = true
NSAnimationContext.runAnimationGroup { context in
+11 -6
View File
@@ -13,6 +13,7 @@ enum SunsetPalette {
@MainActor
final class LauncherBackground: NSView {
weak var canvas: NSView?
private let amber = NSGradient(colors: [
SunsetPalette.accent.withAlphaComponent(0.18),
SunsetPalette.accent.withAlphaComponent(0.065),
@@ -29,17 +30,21 @@ final class LauncherBackground: NSView {
override func draw(_ dirtyRect: NSRect) {
SunsetPalette.background.setFill()
dirtyRect.fill()
let upperGlow = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94)
let canvasBounds = canvas.map { convert($0.bounds, from: $0) } ?? bounds
let upperGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.58,
y: canvasBounds.minY + canvasBounds.height * 0.94)
ember.draw(fromCenter: upperGlow, radius: 0, toCenter: upperGlow,
radius: bounds.width * 0.40, options: [])
let lowerGlow = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10)
radius: canvasBounds.width * 0.40, options: [])
let lowerGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.88,
y: canvasBounds.minY + canvasBounds.height * 0.10)
amber.draw(fromCenter: lowerGlow, radius: 0, toCenter: lowerGlow,
radius: bounds.width * 0.55, options: [])
let leftGlow = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65)
radius: canvasBounds.width * 0.55, options: [])
let leftGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.08,
y: canvasBounds.minY + canvasBounds.height * 0.65)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current?.cgContext.setAlpha(0.45)
amber.draw(fromCenter: leftGlow, radius: 0, toCenter: leftGlow,
radius: bounds.width * 0.32, options: [])
radius: canvasBounds.width * 0.32, options: [])
NSGraphicsContext.restoreGraphicsState()
}
}
@@ -3,28 +3,51 @@ import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor func actionsBackgroundKeepsWindowCoordinates() throws {
@Test @MainActor func popupBackgroundMatchesWindowGradientAcrossTransitions() throws {
_ = NSApplication.shared
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 480),
styleMask: [.borderless], backing: .buffered, defer: false)
styleMask: .borderless, backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
let host = try #require(window.contentView)
let anchor = NSView(frame: NSRect(x: 700, y: 20, width: 30, height: 30))
host.addSubview(anchor)
let reference = LauncherBackground(frame: host.bounds)
host.addSubview(reference)
let footer = NSView(frame: NSRect(x: 500, y: 8, width: 230, height: 36))
let header = NSView(frame: NSRect(x: 500, y: 420, width: 230, height: 36))
let actionButton = NSButton(frame: footer.bounds)
let providerButton = NSButton(frame: header.bounds)
footer.addSubview(actionButton)
header.addSubview(providerButton)
host.addSubview(footer)
host.addSubview(header)
let actions = ApplicationActionsController()
defer { actions.close(animated: false) }
defer { actions.close(animated: false); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
for width in [750.0, 900.0] {
window.setContentSize(NSSize(width: width, height: 480))
actions.showEmoji(named: "Smile", relativeTo: anchor)
let background = try #require(descendants(host).compactMap { $0 as? LauncherBackground }.first)
let search = try #require(descendants(actions.view).compactMap { $0 as? NSSearchField }.first)
for query in ["", "Copy Emoji", "no matching action"] {
search.stringValue = query
actions.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
host.layoutSubtreeIfNeeded()
let rect = background.convert(background.bounds, to: host)
#expect(rect == host.bounds)
#expect(background.bounds.size != actions.view.bounds.size)
func snapshot(_ view: NSView) throws -> NSBitmapImageRep {
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
return bitmap
}
func color(_ bitmap: NSBitmapImageRep, at point: NSPoint, in view: NSView) throws -> NSColor {
let x = Int(point.x * CGFloat(bitmap.pixelsWide) / view.bounds.width)
let y = bitmap.pixelsHigh - 1 - Int(point.y * CGFloat(bitmap.pixelsHigh) / view.bounds.height)
return try #require(bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
}
for animatedClose in [false, true] {
actions.showTranslation(relativeTo: actionButton)
actions.close(animated: animatedClose)
actions.showProviders(selectedOpenAI: true, relativeTo: providerButton)
let background = try #require(descendants(actions.view).compactMap { $0 as? LauncherBackground }.first)
let actual = try snapshot(background)
let expected = try snapshot(reference)
// Sample the title strip and the body, away from text and selection rows.
for y in [background.bounds.height - 8, background.bounds.height / 2, 8] {
let point = NSPoint(x: 8, y: y)
let actualColor = try color(actual, at: point, in: background)
let expectedColor = try color(expected, at: background.convert(point, to: reference), in: reference)
#expect(abs(actualColor.redComponent - expectedColor.redComponent) < 0.02)
#expect(abs(actualColor.greenComponent - expectedColor.greenComponent) < 0.02)
#expect(abs(actualColor.blueComponent - expectedColor.blueComponent) < 0.02)
#expect(abs(actualColor.alphaComponent - expectedColor.alphaComponent) < 0.02)
}
actions.close(animated: false)
}