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. 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 ### Global shortcuts and launch at login
@@ -118,6 +118,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
content.trailingAnchor.constraint(equalTo: view.trailingAnchor), content.trailingAnchor.constraint(equalTo: view.trailingAnchor),
content.topAnchor.constraint(equalTo: view.topAnchor), content.topAnchor.constraint(equalTo: view.topAnchor),
content.bottomAnchor.constraint(equalTo: view.bottomAnchor), 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.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
applicationLabel.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16), applicationLabel.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
applicationLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 14), applicationLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 14),
@@ -197,13 +201,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
view.alphaValue = 0 view.alphaValue = 0
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
host.addSubview(view) host.addSubview(view)
// Crop the window-sized background; filtering must not rescale its glows. // Paint only the popup's bounds, using the window's gradient coordinates.
NSLayoutConstraint.activate([ background.canvas = host
background.leadingAnchor.constraint(equalTo: host.leadingAnchor),
background.trailingAnchor.constraint(equalTo: host.trailingAnchor),
background.topAnchor.constraint(equalTo: host.topAnchor),
background.bottomAnchor.constraint(equalTo: host.bottomAnchor)
])
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
view.trailingAnchor.constraint(equalTo: pill.trailingAnchor), view.trailingAnchor.constraint(equalTo: pill.trailingAnchor),
belowAnchor belowAnchor
@@ -214,7 +213,6 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
coveredPillAlpha = pill.alphaValue coveredPillAlpha = pill.alphaValue
} }
coveredPill?.setAccessibilityHidden(true) coveredPill?.setAccessibilityHidden(true)
// Resolve the background crop before fading in a newly positioned popup.
host.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded()
background.needsDisplay = true background.needsDisplay = true
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
+11 -6
View File
@@ -13,6 +13,7 @@ enum SunsetPalette {
@MainActor @MainActor
final class LauncherBackground: NSView { final class LauncherBackground: NSView {
weak var canvas: NSView?
private let amber = NSGradient(colors: [ private let amber = NSGradient(colors: [
SunsetPalette.accent.withAlphaComponent(0.18), SunsetPalette.accent.withAlphaComponent(0.18),
SunsetPalette.accent.withAlphaComponent(0.065), SunsetPalette.accent.withAlphaComponent(0.065),
@@ -29,17 +30,21 @@ final class LauncherBackground: NSView {
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
SunsetPalette.background.setFill() SunsetPalette.background.setFill()
dirtyRect.fill() 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, ember.draw(fromCenter: upperGlow, radius: 0, toCenter: upperGlow,
radius: bounds.width * 0.40, options: []) radius: canvasBounds.width * 0.40, options: [])
let lowerGlow = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10) 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, amber.draw(fromCenter: lowerGlow, radius: 0, toCenter: lowerGlow,
radius: bounds.width * 0.55, options: []) radius: canvasBounds.width * 0.55, options: [])
let leftGlow = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65) let leftGlow = NSPoint(x: canvasBounds.minX + canvasBounds.width * 0.08,
y: canvasBounds.minY + canvasBounds.height * 0.65)
NSGraphicsContext.saveGraphicsState() NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current?.cgContext.setAlpha(0.45) NSGraphicsContext.current?.cgContext.setAlpha(0.45)
amber.draw(fromCenter: leftGlow, radius: 0, toCenter: leftGlow, amber.draw(fromCenter: leftGlow, radius: 0, toCenter: leftGlow,
radius: bounds.width * 0.32, options: []) radius: canvasBounds.width * 0.32, options: [])
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
} }
} }
@@ -3,28 +3,51 @@ import ConjureKit
import Testing import Testing
@testable import Conjure @testable import Conjure
@Test @MainActor func actionsBackgroundKeepsWindowCoordinates() throws { @Test @MainActor func popupBackgroundMatchesWindowGradientAcrossTransitions() throws {
_ = NSApplication.shared _ = NSApplication.shared
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 480), 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 host = try #require(window.contentView)
let anchor = NSView(frame: NSRect(x: 700, y: 20, width: 30, height: 30)) let reference = LauncherBackground(frame: host.bounds)
host.addSubview(anchor) 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() 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) } func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
for width in [750.0, 900.0] { func snapshot(_ view: NSView) throws -> NSBitmapImageRep {
window.setContentSize(NSSize(width: width, height: 480)) let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
actions.showEmoji(named: "Smile", relativeTo: anchor) view.cacheDisplay(in: view.bounds, to: bitmap)
let background = try #require(descendants(host).compactMap { $0 as? LauncherBackground }.first) return bitmap
let search = try #require(descendants(actions.view).compactMap { $0 as? NSSearchField }.first) }
for query in ["", "Copy Emoji", "no matching action"] { func color(_ bitmap: NSBitmapImageRep, at point: NSPoint, in view: NSView) throws -> NSColor {
search.stringValue = query let x = Int(point.x * CGFloat(bitmap.pixelsWide) / view.bounds.width)
actions.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search)) let y = bitmap.pixelsHigh - 1 - Int(point.y * CGFloat(bitmap.pixelsHigh) / view.bounds.height)
host.layoutSubtreeIfNeeded() return try #require(bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
let rect = background.convert(background.bounds, to: host) }
#expect(rect == host.bounds) for animatedClose in [false, true] {
#expect(background.bounds.size != actions.view.bounds.size) 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) actions.close(animated: false)
} }