From cea12f819bf9af2c9c0e1401650f903c42da19a0 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 18 Oct 2026 01:30:58 +0200 Subject: [PATCH] fix: paint window-aligned gradients within popup bounds --- README.md | 2 +- .../ApplicationActionsController.swift | 14 ++--- Sources/Conjure/LauncherBackground.swift | 17 ++++-- .../ActionsBackgroundTests.swift | 57 +++++++++++++------ 4 files changed, 58 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 40bd069..f51c93b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index 750c1f8..0dae7ad 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -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 diff --git a/Sources/Conjure/LauncherBackground.swift b/Sources/Conjure/LauncherBackground.swift index 9a35779..e34c6af 100644 --- a/Sources/Conjure/LauncherBackground.swift +++ b/Sources/Conjure/LauncherBackground.swift @@ -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() } } diff --git a/Tests/ConjureKitTests/ActionsBackgroundTests.swift b/Tests/ConjureKitTests/ActionsBackgroundTests.swift index 013e885..3b6a4ce 100644 --- a/Tests/ConjureKitTests/ActionsBackgroundTests.swift +++ b/Tests/ConjureKitTests/ActionsBackgroundTests.swift @@ -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) }