fix: reload tint colors while running

This commit is contained in:
2026-10-22 16:07:22 +02:00
parent c48ef4c018
commit 1b171c7b52
18 changed files with 877 additions and 61 deletions
@@ -0,0 +1,207 @@
import AppKit
import Testing
@testable import Conjure
@Test @MainActor
func livePaletteReloadsGeneratedColorsKeepsOverridesAndRetainsLastValidPalette() throws {
let original = ThemePalette.loaded
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer {
ThemePalette.loaded = original
try? FileManager.default.removeItem(at: directory)
}
let config = directory.appendingPathComponent("config.toml")
let theme = directory.appendingPathComponent("theme.json")
ThemePalette.reload(from: directory)
let white = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
let black = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1)
let changes = PaletteChanges()
let observer = NotificationCenter.default.addObserver(
forName: ThemePalette.didChangeNotification, object: nil, queue: .main
) { _ in
MainActor.assumeIsolated {
changes.count += 1
changes.background = ThemePalette.background
changes.accent = ThemePalette.accent
}
}
defer { NotificationCenter.default.removeObserver(observer) }
try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8).write(to: config)
try Data(##"{"colors":{"background":"#123456","accent":"#ff0000","text":"#000000"}}"##.utf8)
.write(to: theme)
#expect(ThemePalette.reload(from: directory))
let firstBackground = ThemePalette.background
#expect(ThemePalette.accent == white)
#expect(ThemePalette.text == black)
#expect(changes.count == 1)
#expect(changes.background == ThemePalette.background)
#expect(changes.accent == ThemePalette.accent)
#expect(ThemePalette.background.alphaComponent == 0.55)
#expect(ThemePalette.selectionFill.alphaComponent == 0.12)
#expect(ThemePalette.selectionBorder.alphaComponent == 0.30)
#expect(ThemePalette.keyBackground.alphaComponent == 0.5)
#expect(!ThemePalette.reload(from: directory))
#expect(changes.count == 1)
try Data(##"{"colors":{"background":"#abcdef","accent":"#00ff00","text":"#ffffff"}}"##.utf8)
.write(to: theme, options: .atomic)
#expect(ThemePalette.reload(from: directory))
#expect(ThemePalette.background != firstBackground)
#expect(ThemePalette.accent == white)
#expect(ThemePalette.text == white)
#expect(changes.count == 2)
let lastBackground = ThemePalette.background
try Data("partial JSON".utf8).write(to: theme)
#expect(!ThemePalette.reload(from: directory))
#expect(ThemePalette.background == lastBackground)
#expect(ThemePalette.text == white)
#expect(changes.count == 2)
#expect(ThemePalette.configurationError?.contains(theme.path) == true)
try Data(##"{"colors":{"background":"#abcdef","text":"#ffffff"}}"##.utf8).write(to: theme)
#expect(!ThemePalette.reload(from: directory))
#expect(ThemePalette.configurationError == nil)
#expect(changes.count == 2)
try Data("colors.accent = '#invalid'\n".utf8).write(to: config)
#expect(!ThemePalette.reload(from: directory))
#expect(ThemePalette.background == lastBackground)
#expect(ThemePalette.configurationError?.contains(config.path) == true)
#expect(changes.count == 2)
try Data("theme = 'sunset'\n".utf8).write(to: config)
#expect(ThemePalette.reload(from: directory))
#expect(ThemePalette.background == ColorScheme.sunset.background.withAlphaComponent(0.55))
#expect(ThemePalette.accent == ColorScheme.sunset.accent)
#expect(changes.count == 3)
#expect(ThemePalette.configurationError == nil)
}
@MainActor private final class PaletteChanges {
var count = 0
var background: NSColor?
var accent: NSColor?
}
@Test(arguments: ["config.toml", "theme.json"]) @MainActor
func liveObservationDetectsInPlaceWritesAndRearmsAfterAtomicReplacement(_ filename: String) async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let directory = root.appendingPathComponent("conjure")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let file = directory.appendingPathComponent(filename)
try Data("initial".utf8).write(to: file)
let events = AsyncStream<String>.makeStream()
let observation = ColorSchemeObservation(directory: directory) {
events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "<missing>")
}
defer {
events.continuation.finish()
withExtendedLifetime(observation) {}
}
try overwriteInPlace("in-place", at: file)
try await expectObserved("in-place", from: events.stream)
try Data("atomic".utf8).write(to: file, options: .atomic)
try await expectObserved("atomic", from: events.stream)
try overwriteInPlace("after-replacement", at: file)
try await expectObserved("after-replacement", from: events.stream)
}
@Test @MainActor
func liveObservationFindsInitiallyMissingDirectoriesAndRecoversAfterRemoval() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let missingAncestor = root.appendingPathComponent("missing")
let directory = missingAncestor.appendingPathComponent("conjure")
let file = directory.appendingPathComponent("theme.json")
let events = AsyncStream<String>.makeStream()
let observation = ColorSchemeObservation(directory: directory) {
events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "<missing>")
}
defer {
events.continuation.finish()
withExtendedLifetime(observation) {}
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data("created".utf8).write(to: file)
try await expectObserved("created", from: events.stream)
try overwriteInPlace("created-then-edited", at: file)
try await expectObserved("created-then-edited", from: events.stream)
try FileManager.default.removeItem(at: missingAncestor)
try await expectObserved("<missing>", from: events.stream)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data("recreated".utf8).write(to: file, options: .atomic)
try await expectObserved("recreated", from: events.stream)
try overwriteInPlace("recreated-then-edited", at: file)
try await expectObserved("recreated-then-edited", from: events.stream)
}
private func overwriteInPlace(_ value: String, at url: URL) throws {
let handle = try FileHandle(forWritingTo: url)
defer { try? handle.close() }
try handle.truncate(atOffset: 0)
try handle.write(contentsOf: Data(value.utf8))
}
private struct ObservationTimeout: Error {}
private func expectObserved(_ expected: String, from events: AsyncStream<String>) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
for await value in events {
if value == expected { return }
}
throw CancellationError()
}
group.addTask {
// Only a failure deadline: event delivery, not a sleep, advances the test.
try await Task.sleep(for: .seconds(5))
throw ObservationTimeout()
}
defer { group.cancelAll() }
_ = try await group.next()
}
}
@Test @MainActor
func liveBackgroundRebuildsCachedGradientsWithoutReplacingTheView() throws {
_ = NSApplication.shared
let original = ThemePalette.loaded
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 480),
styleMask: .borderless, backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
defer {
ThemePalette.loaded = original
window.orderOut(nil)
try? FileManager.default.removeItem(at: directory)
}
let config = directory.appendingPathComponent("config.toml")
try Data("colors.accent = '#ff0000'\ncolors.glow = '#ff0000'\n".utf8).write(to: config)
ThemePalette.reload(from: directory)
let background = LauncherBackground(frame: NSRect(x: 0, y: 0, width: 750, height: 480))
window.contentView?.addSubview(background)
func glowColor() throws -> NSColor {
let bitmap = try #require(background.bitmapImageRepForCachingDisplay(in: background.bounds))
background.cacheDisplay(in: background.bounds, to: bitmap)
return try #require(bitmap.colorAt(x: Int(CGFloat(bitmap.pixelsWide) * 0.58),
y: Int(CGFloat(bitmap.pixelsHigh) * 0.06))?.usingColorSpace(.deviceRGB))
}
let before = try glowColor()
let backgroundColor = ThemePalette.background
background.needsDisplay = false
try Data("colors.accent = '#00ff00'\ncolors.glow = '#00ff00'\n".utf8).write(to: config)
#expect(ThemePalette.reload(from: directory))
#expect(ThemePalette.background == backgroundColor)
#expect(background.needsDisplay)
let after = try glowColor()
#expect(before.redComponent > after.redComponent)
#expect(after.greenComponent > before.greenComponent)
}