195 lines
9.5 KiB
Swift
195 lines
9.5 KiB
Swift
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
|
|
}
|