fix: paint window-aligned gradients within popup bounds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user