fix: reload tint colors while running
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func livePaletteRepaintsNotesWithoutChangingContentSelectionOrUndo() throws {
|
||||
_ = NSApplication.shared
|
||||
let originalPalette = ThemePalette.loaded
|
||||
defer {
|
||||
ThemePalette.loaded = originalPalette
|
||||
NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil)
|
||||
}
|
||||
let directory = try liveViewThemeDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
ThemePalette.reload(from: directory)
|
||||
|
||||
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 500, height: 300))
|
||||
editor.isRichText = true
|
||||
let delegate = LiveThemeTextDelegate()
|
||||
editor.delegate = delegate
|
||||
let document = NSMutableAttributedString(string: "plain code\nlet answer = 42\n")
|
||||
let inline = NSRange(location: 6, length: 4)
|
||||
let code = (document.string as NSString).paragraphRange(for: NSRange(location: 11, length: 0))
|
||||
document.addAttribute(NoteAttributes.inlineCode, value: true, range: inline)
|
||||
document.addAttribute(NoteAttributes.bold, value: true, range: NSRange(location: 0, length: 5))
|
||||
document.addAttribute(NoteAttributes.block, value: "code", range: code)
|
||||
document.addAttribute(NoteAttributes.language, value: "swift", range: code)
|
||||
editor.setDocumentContents(document)
|
||||
editor.beginInsertionSession()
|
||||
editor.insertText("!", replacementRange: NSRange(location: 0, length: 0))
|
||||
let selection = NSRange(location: 2, length: 3)
|
||||
editor.setSelectedRange(selection)
|
||||
let contents = editor.string
|
||||
let grouping = editor.undoManager?.groupingLevel
|
||||
let oldAccent = editor.insertionPointColor
|
||||
let codeColor = editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor
|
||||
let boldFont = editor.textStorage?.attribute(.font, at: 1, effectiveRange: nil) as? NSFont
|
||||
let changeCount = delegate.changeCount
|
||||
|
||||
try writeLiveViewTheme(in: directory)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
|
||||
#expect(editor.insertionPointColor == ThemePalette.accent)
|
||||
#expect(editor.insertionPointColor != oldAccent)
|
||||
#expect(editor.selectedTextAttributes[.backgroundColor] as? NSColor == ThemePalette.selectionFill)
|
||||
#expect(editor.textStorage?.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor == ThemePalette.text)
|
||||
#expect(editor.textStorage?.attribute(.foregroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.accent)
|
||||
#expect(editor.textStorage?.attribute(.backgroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill)
|
||||
#expect(editor.textStorage?.attribute(.backgroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill)
|
||||
#expect(editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor == codeColor)
|
||||
#expect(editor.textStorage?.attribute(.font, at: 1, effectiveRange: nil) as? NSFont == boldFont)
|
||||
#expect(editor.textStorage?.attribute(NoteAttributes.inlineCode, at: inline.location + 1, effectiveRange: nil) as? Bool == true)
|
||||
#expect(editor.textStorage?.attribute(NoteAttributes.language, at: code.location + 1, effectiveRange: nil) as? String == "swift")
|
||||
#expect(editor.string == contents)
|
||||
#expect(editor.selectedRange() == selection)
|
||||
#expect(editor.undoManager?.groupingLevel == grouping)
|
||||
#expect(delegate.changeCount == changeCount)
|
||||
editor.endInsertionSession()
|
||||
#expect(editor.undoManager?.canUndo == true)
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.string == document.string)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func livePaletteRepaintsLauncherAndFavoriteBadgesWithoutResettingSearch() throws {
|
||||
_ = NSApplication.shared
|
||||
let originalPalette = ThemePalette.loaded
|
||||
defer {
|
||||
ThemePalette.loaded = originalPalette
|
||||
NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil)
|
||||
}
|
||||
let directory = try liveViewThemeDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
ThemePalette.reload(from: directory)
|
||||
let suite = "Conjure.LiveThemeViews.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let launcher = LauncherWindowController(
|
||||
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
|
||||
defaults: defaults, notesDirectory: directory.appendingPathComponent("notes"))
|
||||
let window = try #require(launcher.window)
|
||||
let content = try #require(window.contentView)
|
||||
let views = liveThemedDescendants(content)
|
||||
let search = try #require(views.compactMap { $0 as? NSSearchField }.first)
|
||||
let table = try #require(views.compactMap { $0 as? NSTableView }.first)
|
||||
search.stringValue = "notes"
|
||||
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||
let rows = table.numberOfRows
|
||||
let selected = table.selectedRowIndexes
|
||||
let placeholder = search.placeholderAttributedString?.string
|
||||
window.makeFirstResponder(search)
|
||||
let fieldEditor = try #require(search.currentEditor() as? NSTextView)
|
||||
fieldEditor.setSelectedRange(NSRange(location: 1, length: 2))
|
||||
let selection = fieldEditor.selectedRange()
|
||||
let pill = try #require(views.compactMap { $0 as? ActionPill }.first)
|
||||
let oldBorder = pill.layer?.borderColor
|
||||
let cell = ApplicationCell(frame: .zero)
|
||||
cell.kindLabel.attributedStringValue = ApplicationCell.favoriteCommandKind
|
||||
cell.setNumberHint("3", animated: false)
|
||||
let numberHint = try #require(liveThemedDescendants(cell.numberHint).compactMap { $0 as? NSTextField }.first)
|
||||
|
||||
try writeLiveViewTheme(in: directory)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
|
||||
#expect(search.stringValue == "notes")
|
||||
#expect(search.placeholderAttributedString?.string == placeholder)
|
||||
#expect(table.numberOfRows == rows)
|
||||
#expect(table.selectedRowIndexes == selected)
|
||||
#expect(search.currentEditor() === fieldEditor)
|
||||
#expect(fieldEditor.selectedRange() == selection)
|
||||
#expect(search.textColor == ThemePalette.text)
|
||||
#expect(fieldEditor.insertionPointColor == ThemePalette.accent)
|
||||
#expect(search.placeholderAttributedString?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == ThemePalette.secondaryText)
|
||||
#expect(pill.layer?.borderColor == ThemePalette.accent.withAlphaComponent(0.35).cgColor)
|
||||
#expect(pill.layer?.borderColor != oldBorder)
|
||||
#expect(cell.kindLabel.attributedStringValue.attribute(.foregroundColor, at: cell.kindLabel.attributedStringValue.length - 1, effectiveRange: nil) as? NSColor == ThemePalette.secondaryText)
|
||||
#expect(!cell.numberHint.isHidden)
|
||||
#expect(numberHint.stringValue == "3")
|
||||
#expect(numberHint.layer?.backgroundColor == ThemePalette.keyBackground.cgColor)
|
||||
}
|
||||
|
||||
private func liveViewThemeDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
private func writeLiveViewTheme(in directory: URL) throws {
|
||||
try Data("""
|
||||
[colors]
|
||||
background = "#ffffff"
|
||||
text = "#112233"
|
||||
secondaryText = "#445566"
|
||||
accent = "#1234ab"
|
||||
glow = "#abcdef"
|
||||
keyBackground = "#778899"
|
||||
error = "#aa0011"
|
||||
""".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func liveThemedDescendants(_ view: NSView) -> [NSView] {
|
||||
[view] + view.subviews.flatMap { liveThemedDescendants($0) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LiveThemeTextDelegate: NSObject, NSTextViewDelegate {
|
||||
var changeCount = 0
|
||||
func textDidChange(_ notification: Notification) { changeCount += 1 }
|
||||
}
|
||||
Reference in New Issue
Block a user