fix: render light palettes with matching appearance
This commit is contained in:
@@ -71,6 +71,7 @@ func malformedColorConfigurationReportsItsPathAndReason() throws {
|
||||
("colors.accent = 42", "colors.accent"),
|
||||
("colors.accnet = '#123456'", "colors.accnet"),
|
||||
("opacity = 0.5", "opacity"),
|
||||
("variant = 'light'", "variant"),
|
||||
("colors = []", "colors"),
|
||||
("colors = false", "colors"),
|
||||
("theme = 'unknown'", "theme"),
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import AppKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
// Conjure roles generated by tint from base16-swamp-light.
|
||||
private let swampLightTheme = ##"{"variant":"light","colors":{"background":"#f1e3d1","text":"#64513e","secondaryText":"#a0907d","accent":"#bf7979","glow":"#d09700","keyBackground":"#ddcebc","error":"#d09700"}}"##
|
||||
|
||||
@Test @MainActor
|
||||
func generatedLightMetadataAndManualLuminanceChooseTheMatchingAppearance() throws {
|
||||
let directory = try lightConfigurationDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let theme = directory.appendingPathComponent("theme.json")
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
try Data(swampLightTheme.utf8).write(to: theme)
|
||||
let light = try ColorScheme.load(from: directory)
|
||||
#expect(light.isLight)
|
||||
#expect(ThemePalette.State(scheme: light).appearance.name == .aqua)
|
||||
|
||||
// Metadata wins even when manual colors disagree with the generated variant.
|
||||
try Data("theme = 'tint'\ncolors.background = '#000000'\ncolors.text = '#ffffff'\n".utf8)
|
||||
.write(to: directory.appendingPathComponent("config.toml"))
|
||||
#expect(try ColorScheme.load(from: directory).isLight)
|
||||
|
||||
try Data("colors.background = '#f1e3d1'\ncolors.text = '#64513e'\n".utf8)
|
||||
.write(to: directory.appendingPathComponent("config.toml"))
|
||||
let manual = try ColorScheme.load(from: directory)
|
||||
#expect(manual.isLight)
|
||||
#expect(ThemePalette.State(scheme: manual).appearance.name == .aqua)
|
||||
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
try Data(swampLightTheme.replacingOccurrences(of: "\"variant\":\"light\",", with: "").utf8).write(to: theme)
|
||||
#expect(try ColorScheme.load(from: directory).isLight)
|
||||
try FileManager.default.removeItem(at: directory.appendingPathComponent("config.toml"))
|
||||
#expect(try !ColorScheme.load(from: directory).isLight)
|
||||
}
|
||||
|
||||
@Test(arguments: ["null", "true", "42", "[]", "{}", "\"Light\"", "\"darkAqua\"", "\"\""]) @MainActor
|
||||
func invalidGeneratedVariantsReportTheThemePath(_ value: String) throws {
|
||||
let directory = try lightConfigurationDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
let theme = directory.appendingPathComponent("theme.json")
|
||||
try Data("{\"variant\":\(value),\"colors\":{}}".utf8).write(to: theme)
|
||||
do {
|
||||
_ = try ColorScheme.load(from: directory)
|
||||
Issue.record("Expected invalid generated variant to be rejected")
|
||||
} catch {
|
||||
#expect(error.localizedDescription.contains(theme.path))
|
||||
#expect(error.localizedDescription.contains("variant"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func variantOnlyReloadNotifiesConsumersAndChangesRenderedAppearance() throws {
|
||||
let original = ThemePalette.loaded
|
||||
let directory = try lightConfigurationDirectory()
|
||||
defer {
|
||||
ThemePalette.loaded = original
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
let theme = directory.appendingPathComponent("theme.json")
|
||||
try Data(swampLightTheme.replacingOccurrences(of: "\"light\"", with: "\"dark\"").utf8).write(to: theme)
|
||||
ThemePalette.reload(from: directory)
|
||||
let changes = LightPaletteChanges()
|
||||
let observer = NotificationCenter.default.addObserver(forName: ThemePalette.didChangeNotification,
|
||||
object: nil, queue: .main) { _ in
|
||||
MainActor.assumeIsolated {
|
||||
changes.appearances.append(ThemePalette.appearance.name)
|
||||
}
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(observer) }
|
||||
let before = try renderedBackground(over: .black)
|
||||
try Data(swampLightTheme.utf8).write(to: theme)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
#expect(changes.appearances == [.aqua])
|
||||
#expect(!ThemePalette.reload(from: directory))
|
||||
#expect(changes.appearances.count == 1)
|
||||
let after = try renderedBackground(over: .black)
|
||||
let oldSurface = try #require(before.colorAt(x: 375, y: 240))
|
||||
let newSurface = try #require(after.colorAt(x: 375, y: 240))
|
||||
#expect(testLuminance(newSurface) > testLuminance(oldSurface))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func lightSecondaryTextRemainsReadableAcrossRenderedGlowsWithoutChangingSourceColors() throws {
|
||||
let original = ThemePalette.loaded
|
||||
let directory = try lightConfigurationDirectory()
|
||||
defer {
|
||||
ThemePalette.loaded = original
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
try Data(swampLightTheme.utf8).write(to: directory.appendingPathComponent("theme.json"))
|
||||
let source = try ColorScheme.load(from: directory)
|
||||
ThemePalette.reload(from: directory)
|
||||
#expect(ThemePalette.text == source.text)
|
||||
#expect(ThemePalette.loaded.scheme.secondaryText == source.secondaryText)
|
||||
#expect(testLuminance(ThemePalette.secondaryText) < testLuminance(source.secondaryText))
|
||||
for backing in [NSColor.black, .white] {
|
||||
let bitmap = try renderedBackground(over: backing)
|
||||
for y in stride(from: 0, to: bitmap.pixelsHigh, by: 12) {
|
||||
for x in stride(from: 0, to: bitmap.pixelsWide, by: 12) {
|
||||
let surface = try #require(bitmap.colorAt(x: x, y: y))
|
||||
let foreground = testLuminance(ThemePalette.secondaryText)
|
||||
#expect((testLuminance(surface) + 0.05) / (foreground + 0.05) >= 4.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
// An already-readable manual secondary color is not needlessly restyled.
|
||||
try Data("theme = 'tint'\ncolors.secondaryText = '#302820'\n".utf8)
|
||||
.write(to: directory.appendingPathComponent("config.toml"))
|
||||
ThemePalette.reload(from: directory)
|
||||
#expect(ThemePalette.secondaryText == ThemePalette.loaded.scheme.secondaryText)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func darkBackgroundRenderingAndSemanticColorsRemainUnchanged() throws {
|
||||
let original = ThemePalette.loaded
|
||||
defer { ThemePalette.loaded = original }
|
||||
for scheme in [ColorScheme.sunset, customDarkScheme()] {
|
||||
ThemePalette.loaded = ThemePalette.State(scheme: scheme)
|
||||
#expect(ThemePalette.secondaryText == scheme.secondaryText)
|
||||
#expect(ThemePalette.text == scheme.text)
|
||||
#expect(ThemePalette.appearance.name == .darkAqua)
|
||||
for backing in [NSColor.black, .white] {
|
||||
let actual = try renderedBackground(over: backing)
|
||||
let legacy = try renderedBackground(over: backing, legacy: scheme)
|
||||
for y in stride(from: 0, to: actual.pixelsHigh, by: 12) {
|
||||
for x in stride(from: 0, to: actual.pixelsWide, by: 12) {
|
||||
#expect(actual.colorAt(x: x, y: y) == legacy.colorAt(x: x, y: y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class LightPaletteChanges {
|
||||
var appearances: [NSAppearance.Name] = []
|
||||
}
|
||||
|
||||
private func lightConfigurationDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
@MainActor private func customDarkScheme() -> ColorScheme {
|
||||
var scheme = ColorScheme.sunset
|
||||
scheme.background = NSColor(srgbRed: 0.04, green: 0.08, blue: 0.12, alpha: 1)
|
||||
scheme.accent = .cyan
|
||||
scheme.glow = .magenta
|
||||
return scheme
|
||||
}
|
||||
|
||||
private func testLuminance(_ color: NSColor) -> CGFloat {
|
||||
let rgb = color.usingColorSpace(.sRGB)!
|
||||
func linear(_ value: CGFloat) -> CGFloat {
|
||||
value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * linear(rgb.redComponent) + 0.7152 * linear(rgb.greenComponent) + 0.0722 * linear(rgb.blueComponent)
|
||||
}
|
||||
|
||||
@MainActor private func renderedBackground(over backing: NSColor, legacy scheme: ColorScheme? = nil) throws -> NSBitmapImageRep {
|
||||
_ = NSApplication.shared
|
||||
let bounds = NSRect(x: 0, y: 0, width: 750, height: 480)
|
||||
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 750, pixelsHigh: 480,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
|
||||
let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||
NSGraphicsContext.current = context
|
||||
backing.setFill()
|
||||
bounds.fill()
|
||||
if let scheme {
|
||||
scheme.background.withAlphaComponent(0.55).setFill()
|
||||
bounds.fill()
|
||||
let amber = try #require(NSGradient(colors: [scheme.accent.withAlphaComponent(0.18),
|
||||
scheme.accent.withAlphaComponent(0.065), scheme.accent.withAlphaComponent(0)]))
|
||||
let ember = try #require(NSGradient(colors: [scheme.glow.withAlphaComponent(0.26),
|
||||
scheme.glow.withAlphaComponent(0.10), scheme.glow.withAlphaComponent(0)]))
|
||||
let upper = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94)
|
||||
ember.draw(fromCenter: upper, radius: 0, toCenter: upper, radius: bounds.width * 0.40, options: [])
|
||||
let lower = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10)
|
||||
amber.draw(fromCenter: lower, radius: 0, toCenter: lower, radius: bounds.width * 0.55, options: [])
|
||||
let left = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65)
|
||||
context.cgContext.setAlpha(0.45)
|
||||
amber.draw(fromCenter: left, radius: 0, toCenter: left, radius: bounds.width * 0.32, options: [])
|
||||
} else {
|
||||
LauncherBackground(frame: bounds).draw(bounds)
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Testing
|
||||
@testable import Conjure
|
||||
|
||||
@Test @MainActor
|
||||
func existingWindowsAndNotesFollowPaletteAppearanceWithoutResettingState() throws {
|
||||
_ = NSApplication.shared
|
||||
let originalPalette = ThemePalette.loaded
|
||||
let originalAppearance = NSApp.appearance
|
||||
defer {
|
||||
NSApp.appearance = originalAppearance
|
||||
ThemePalette.loaded = originalPalette
|
||||
NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil)
|
||||
}
|
||||
let suite = "Conjure.LightAppearanceViews.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
try Data("theme = \"tint\"".utf8).write(to: directory.appendingPathComponent("config.toml"))
|
||||
func writePalette(light: Bool) throws {
|
||||
try Data("""
|
||||
{"variant":"\(light ? "light" : "dark")","colors":{
|
||||
"background":"\(light ? "#ffffff" : "#201714")",
|
||||
"text":"\(light ? "#112233" : "#ffe3b5")",
|
||||
"secondaryText":"\(light ? "#667788" : "#d9c7a6")"
|
||||
}}
|
||||
""".utf8).write(to: directory.appendingPathComponent("theme.json"))
|
||||
}
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
func appearanceName(_ appearance: NSAppearance) -> NSAppearance.Name? {
|
||||
appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||
}
|
||||
try writePalette(light: false)
|
||||
ThemePalette.reload(from: directory)
|
||||
NSApp.appearance = NSAppearance(named: .aqua)
|
||||
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)
|
||||
defer { window.orderOut(nil) }
|
||||
#expect(appearanceName(window.effectiveAppearance) == .darkAqua)
|
||||
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
|
||||
search.stringValue = "notes"
|
||||
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
|
||||
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
|
||||
let document = NSMutableAttributedString(string: "let answer = 42\nplain")
|
||||
let code = NSRange(location: 0, length: 16)
|
||||
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: document.length, length: 0))
|
||||
editor.endInsertionSession()
|
||||
let selection = NSRange(location: 1, length: 2)
|
||||
editor.setSelectedRange(selection)
|
||||
let text = editor.string
|
||||
let undoGrouping = editor.undoManager?.groupingLevel
|
||||
let frame = window.frame
|
||||
let style = window.styleMask
|
||||
let settings = SettingsWindowController(shortcuts: GlobalShortcuts(defaults: defaults) { _ in })
|
||||
let settingsWindow = try #require(settings.window)
|
||||
defer { settingsWindow.close() }
|
||||
let settingsContent = try #require(settingsWindow.contentView)
|
||||
let destination = try #require(descendants(settingsContent).compactMap { $0 as? NSPopUpButton }.first)
|
||||
destination.selectItem(at: 2)
|
||||
let credential = try #require(descendants(settingsContent).compactMap { $0 as? NSSecureTextField }.first)
|
||||
credential.stringValue = "unsaved-value"
|
||||
let anchor = try #require(descendants(content).compactMap { $0 as? NSButton }.first {
|
||||
$0.title == "Actions" && $0.target is NotesController
|
||||
})
|
||||
let notes = try #require(anchor.target as? NotesController)
|
||||
let popup = ApplicationActionsController()
|
||||
popup.showLanguages([("en", "English"), ("ja", "Japanese")], selected: "en", source: true, relativeTo: anchor)
|
||||
defer { popup.close(animated: false) }
|
||||
let popupSearch = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first)
|
||||
popupSearch.stringValue = "Eng"
|
||||
popup.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: popupSearch))
|
||||
let table = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first)
|
||||
let rows = table.numberOfRows
|
||||
let selectedRows = table.selectedRowIndexes
|
||||
let material = try #require(popup.view.subviews.compactMap { $0 as? NSVisualEffectView }.first)
|
||||
#expect(appearanceName(editor.effectiveAppearance) == .darkAqua)
|
||||
#expect(appearanceName(settingsWindow.effectiveAppearance) == .darkAqua)
|
||||
#expect(appearanceName(material.effectiveAppearance) == .darkAqua)
|
||||
|
||||
// Deliberately oppose the light palette: app/system inheritance is not the contract.
|
||||
NSApp.appearance = NSAppearance(named: .darkAqua)
|
||||
for light in [true, false] {
|
||||
try writePalette(light: light)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
let expected: NSAppearance.Name = light ? .aqua : .darkAqua
|
||||
for appearance in [window.effectiveAppearance, content.effectiveAppearance, editor.effectiveAppearance,
|
||||
settingsWindow.effectiveAppearance, settingsContent.effectiveAppearance,
|
||||
popup.view.effectiveAppearance, material.effectiveAppearance] {
|
||||
#expect(appearanceName(appearance) == expected)
|
||||
}
|
||||
#expect(launcher.window === window)
|
||||
#expect(window.frame == frame)
|
||||
#expect(window.styleMask == style)
|
||||
#expect(editor.string == text)
|
||||
#expect(editor.selectedRange() == selection)
|
||||
#expect(editor.undoManager?.groupingLevel == undoGrouping)
|
||||
#expect(editor.undoManager?.canUndo == true)
|
||||
#expect(editor.textStorage?.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "code")
|
||||
#expect(editor.textStorage?.attribute(NoteAttributes.language, at: 0, effectiveRange: nil) as? String == "swift")
|
||||
let keyword = try #require((editor.textStorage?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor)?.usingColorSpace(.sRGB))
|
||||
let rgb: (CGFloat, CGFloat, CGFloat) = light ? (166, 38, 164) : (198, 120, 221)
|
||||
#expect(abs(keyword.redComponent * 255 - rgb.0) < 1)
|
||||
#expect(abs(keyword.greenComponent * 255 - rgb.1) < 1)
|
||||
#expect(abs(keyword.blueComponent * 255 - rgb.2) < 1)
|
||||
#expect(destination.indexOfSelectedItem == 2)
|
||||
#expect(credential.stringValue == "unsaved-value")
|
||||
#expect(popup.isShown)
|
||||
#expect(popupSearch.stringValue == "Eng")
|
||||
#expect(table.numberOfRows == rows)
|
||||
#expect(table.selectedRowIndexes == selectedRows)
|
||||
#expect(material.layer?.borderColor == ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor)
|
||||
#expect(material.layer?.borderColor?.alpha == 0.22)
|
||||
}
|
||||
popup.close(animated: false)
|
||||
editor.undoManager?.undo()
|
||||
#expect(editor.string == document.string)
|
||||
try writePalette(light: true)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
notes.goBack()
|
||||
#expect(window.styleMask == [.borderless])
|
||||
#expect(appearanceName(window.effectiveAppearance) == .aqua)
|
||||
#expect(appearanceName(content.effectiveAppearance) == .aqua)
|
||||
#expect(search.stringValue == "notes")
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func livePaletteReloadsGeneratedColorsKeepsOverridesAndRetainsLastValidPalette()
|
||||
}
|
||||
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)
|
||||
try Data(##"{"variant":"dark","colors":{"background":"#123456","accent":"#ff0000","text":"#000000"}}"##.utf8)
|
||||
.write(to: theme)
|
||||
#expect(ThemePalette.reload(from: directory))
|
||||
let firstBackground = ThemePalette.background
|
||||
|
||||
@@ -48,7 +48,7 @@ func livePaletteRepaintsNotesWithoutChangingContentSelectionOrUndo() throws {
|
||||
#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(.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")
|
||||
|
||||
Reference in New Issue
Block a user