From 78d2b5a4167c87a7bcc818f7572443fbc279e2f6 Mon Sep 17 00:00:00 2001 From: culpen90 Date: Sat, 22 Aug 2026 13:36:52 -0400 Subject: [PATCH] feat: add generated accent color themes --- .../Features/Editor/Views/CodeFileView.swift | 10 +- .../Models/GeneratedThemeGenerator.swift | 218 +++++++++++++ .../Models/GeneratedThemeStrategy.swift | 48 +++ .../Models/ThemeModel+CRUD.swift | 1 + .../ThemeSettings/Models/ThemeModel.swift | 72 ++++- .../ThemeSettings/Models/ThemeSettings.swift | 26 ++ .../ThemeSettings/ThemeSettingsView.swift | 52 ++- CodeEdit/WorkspaceView.swift | 12 +- .../SettingsGeneratedThemeTests.swift | 297 ++++++++++++++++++ 9 files changed, 712 insertions(+), 24 deletions(-) create mode 100644 CodeEdit/Features/Settings/Pages/ThemeSettings/Models/GeneratedThemeGenerator.swift create mode 100644 CodeEdit/Features/Settings/Pages/ThemeSettings/Models/GeneratedThemeStrategy.swift create mode 100644 CodeEditTests/Features/SettingsGeneratedThemeTests.swift diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index f22f6cce3d..8cfbd40b34 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -35,10 +35,8 @@ struct CodeFileView: View { var overscroll @AppSettings(\.textEditing.font) var settingsFont - @AppSettings(\.theme.useThemeBackground) - var useThemeBackground - @AppSettings(\.theme.matchAppearance) - var matchAppearance + @AppSettings(\.theme) + var themeSettings @AppSettings(\.textEditing.letterSpacing) var letterSpacing @AppSettings(\.textEditing.bracketEmphasis) @@ -105,7 +103,7 @@ struct CodeFileView: View { } private var currentTheme: Theme { - themeModel.selectedTheme ?? themeModel.themes.first! + themeModel.effectiveTheme ?? themeModel.themes.first! } @State private var font: NSFont = Settings[\.textEditing].font.current @@ -120,7 +118,7 @@ struct CodeFileView: View { configuration: SourceEditorConfiguration( appearance: .init( theme: currentTheme.editor.editorTheme, - useThemeBackground: useThemeBackground, + useThemeBackground: themeSettings.useThemeBackgroundInEditor, font: font, lineHeightMultiple: lineHeightMultiple, letterSpacing: letterSpacing, diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/GeneratedThemeGenerator.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/GeneratedThemeGenerator.swift new file mode 100644 index 0000000000..16c7e163b1 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/GeneratedThemeGenerator.swift @@ -0,0 +1,218 @@ +// +// GeneratedThemeGenerator.swift +// CodeEdit +// + +import AppKit + +/// Generates a transient, accessible syntax palette from a seed color. +enum GeneratedThemeGenerator { + static let minimumSyntaxContrast: CGFloat = 4.5 + + private static let darkBrightness: [CGFloat] = [ + 0.90, 0.78, 1.00, 0.70, 0.85, 0.75, 0.95, 0.66, 0.81, 0.72 + ] + private static let lightBrightness: [CGFloat] = [ + 0.55, 0.45, 0.62, 0.38, 0.50, 0.42, 0.58, 0.35, 0.48, 0.40 + ] + private static let saturationFactors: [CGFloat] = [ + 1.00, 0.82, 0.94, 0.68, 0.88, 0.74, 0.97, 0.62, 0.84, 0.70 + ] + private static let darkGrayBrightness: [CGFloat] = [ + 0.86, 0.72, 0.94, 0.64, 0.80, 0.69, 0.90, 0.60, 0.76, 0.83 + ] + private static let lightGrayBrightness: [CGFloat] = [ + 0.28, 0.36, 0.20, 0.42, 0.32, 0.24, 0.39, 0.17, 0.45, 0.30 + ] + + /// Creates a generated theme by replacing only the editor's syntax palette. + /// The base theme's primary text, background, styling, and terminal colors are preserved. + static func generate( + from baseTheme: Theme, + seedColor: NSColor, + strategy: GeneratedThemeStrategy + ) -> Theme { + var generatedTheme = baseTheme + let syntaxColors = syntaxColors( + seedColor: seedColor, + backgroundColor: baseTheme.editor.background.nsColor, + appearance: baseTheme.appearance, + strategy: strategy + ) + + generatedTheme.editor.keywords.color = hexString(for: syntaxColors[0]) + generatedTheme.editor.commands.color = hexString(for: syntaxColors[1]) + generatedTheme.editor.types.color = hexString(for: syntaxColors[2]) + generatedTheme.editor.attributes.color = hexString(for: syntaxColors[3]) + generatedTheme.editor.variables.color = hexString(for: syntaxColors[4]) + generatedTheme.editor.values.color = hexString(for: syntaxColors[5]) + generatedTheme.editor.numbers.color = hexString(for: syntaxColors[6]) + generatedTheme.editor.strings.color = hexString(for: syntaxColors[7]) + generatedTheme.editor.characters.color = hexString(for: syntaxColors[8]) + generatedTheme.editor.comments.color = hexString(for: syntaxColors[9]) + + let insertionPoint = colorEnsuringContrast( + resolvedSRGB(seedColor), + against: resolvedSRGB(baseTheme.editor.background.nsColor), + minimumRatio: 3.05 + ) + generatedTheme.editor.insertionPoint.color = hexString(for: insertionPoint) + + generatedTheme.metadataDescription = "Generated from the macOS system accent color using a " + + "\(strategy.displayName.lowercased()) color harmony." + generatedTheme.isBundled = false + generatedTheme.fileURL = nil + generatedTheme.name = "generated.system-accent.\(strategy.rawValue).\(baseTheme.appearance.rawValue)" + generatedTheme.displayName = "System Accent — \(strategy.displayName)" + + return generatedTheme + } + + /// Resolves the user's dynamic accent color for a specific appearance into sRGB. + /// AppKit applies Desktop Tinting while rendering materials, but does not expose that tint as a color value. + static func systemAccentColor(for appearance: Theme.ThemeType) -> NSColor { + let appearanceName: NSAppearance.Name = appearance == .dark ? .darkAqua : .aqua + guard let drawingAppearance = NSAppearance(named: appearanceName) else { + return resolvedSRGB(.systemBlue) + } + + var accentColor = NSColor.systemBlue + drawingAppearance.performAsCurrentDrawingAppearance { + accentColor = resolvedSRGB(.controlAccentColor) + } + return accentColor + } + + /// Produces ten syntax colors with a readable contrast ratio against the supplied background. + static func syntaxColors( + seedColor: NSColor, + backgroundColor: NSColor, + appearance: Theme.ThemeType, + strategy: GeneratedThemeStrategy + ) -> [NSColor] { + let seed = resolvedSRGB(seedColor) + let background = resolvedSRGB(backgroundColor) + + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + seed.getHue(&hue, saturation: &saturation, brightness: nil, alpha: nil) + + if saturation < 0.08 { + let brightnessValues = appearance == .dark ? darkGrayBrightness : lightGrayBrightness + return brightnessValues.map { + colorEnsuringContrast( + NSColor(srgbRed: $0, green: $0, blue: $0, alpha: 1), + against: background, + minimumRatio: minimumSyntaxContrast + 0.05 + ) + } + } + + let brightnessValues = appearance == .dark ? darkBrightness : lightBrightness + let baseSaturation = max(saturation, 0.55) + + return (0..<10).map { index in + let offsets = strategy.hueOffsets + let offset = offsets[index % offsets.count] + let variant = index / offsets.count + let rotatedHue = wrappedHue(hue + offset) + let adjustedSaturation = clamped( + baseSaturation * saturationFactors[variant % saturationFactors.count] + ) + let candidate = NSColor( + calibratedHue: rotatedHue, + saturation: adjustedSaturation, + brightness: brightnessValues[variant % brightnessValues.count], + alpha: 1 + ) + + return colorEnsuringContrast( + resolvedSRGB(candidate), + against: background, + minimumRatio: minimumSyntaxContrast + 0.05 + ) + } + } + + static func contrastRatio(between firstColor: NSColor, and secondColor: NSColor) -> CGFloat { + let firstLuminance = relativeLuminance(of: resolvedSRGB(firstColor)) + let secondLuminance = relativeLuminance(of: resolvedSRGB(secondColor)) + let lighter = max(firstLuminance, secondLuminance) + let darker = min(firstLuminance, secondLuminance) + return (lighter + 0.05) / (darker + 0.05) + } +} + +private extension GeneratedThemeGenerator { + static func resolvedSRGB(_ color: NSColor) -> NSColor { + color.usingColorSpace(.sRGB) ?? NSColor(srgbRed: 0, green: 0.478, blue: 1, alpha: 1) + } + + static func hexString(for color: NSColor) -> String { + let color = resolvedSRGB(color) + let red = Int(round(clamped(color.redComponent) * 255)) + let green = Int(round(clamped(color.greenComponent) * 255)) + let blue = Int(round(clamped(color.blueComponent) * 255)) + return String(format: "#%02X%02X%02X", red, green, blue) + } + + static func wrappedHue(_ hue: CGFloat) -> CGFloat { + let remainder = hue.truncatingRemainder(dividingBy: 1) + return remainder < 0 ? remainder + 1 : remainder + } + + static func clamped(_ value: CGFloat) -> CGFloat { + min(max(value, 0), 1) + } + + static func colorEnsuringContrast( + _ color: NSColor, + against background: NSColor, + minimumRatio: CGFloat + ) -> NSColor { + guard contrastRatio(between: color, and: background) < minimumRatio else { + return color + } + + let black = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1) + let white = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) + let target = contrastRatio(between: black, and: background) + > contrastRatio(between: white, and: background) ? black : white + + var lowerBound: CGFloat = 0 + var upperBound: CGFloat = 1 + for _ in 0..<24 { + let fraction = (lowerBound + upperBound) / 2 + let mixedColor = mix(color, with: target, fraction: fraction) + if contrastRatio(between: mixedColor, and: background) >= minimumRatio { + upperBound = fraction + } else { + lowerBound = fraction + } + } + return mix(color, with: target, fraction: upperBound) + } + + static func mix(_ color: NSColor, with target: NSColor, fraction: CGFloat) -> NSColor { + let color = resolvedSRGB(color) + let target = resolvedSRGB(target) + return NSColor( + srgbRed: color.redComponent + ((target.redComponent - color.redComponent) * fraction), + green: color.greenComponent + ((target.greenComponent - color.greenComponent) * fraction), + blue: color.blueComponent + ((target.blueComponent - color.blueComponent) * fraction), + alpha: 1 + ) + } + + static func relativeLuminance(of color: NSColor) -> CGFloat { + func linearized(_ component: CGFloat) -> CGFloat { + component <= 0.04045 + ? component / 12.92 + : pow((component + 0.055) / 1.055, 2.4) + } + + return (0.2126 * linearized(color.redComponent)) + + (0.7152 * linearized(color.greenComponent)) + + (0.0722 * linearized(color.blueComponent)) + } +} diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/GeneratedThemeStrategy.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/GeneratedThemeStrategy.swift new file mode 100644 index 0000000000..9fb6a59504 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/GeneratedThemeStrategy.swift @@ -0,0 +1,48 @@ +// +// GeneratedThemeStrategy.swift +// CodeEdit +// + +import Foundation + +/// A color-harmony strategy used to derive syntax colors from the system accent color. +enum GeneratedThemeStrategy: String, CaseIterable, Codable, Hashable, Identifiable { + case complementary + case monochromatic + case analogous + case triadic + case tetradic + + var id: Self { self } + + var displayName: String { + switch self { + case .complementary: + "Complementary" + case .monochromatic: + "Monochromatic" + case .analogous: + "Analogous" + case .triadic: + "Triadic" + case .tetradic: + "Tetradic" + } + } + + /// Hue rotations, expressed as fractions of one full turn. + var hueOffsets: [CGFloat] { + switch self { + case .complementary: + [0, 0.5] + case .monochromatic: + [0] + case .analogous: + [0, -1.0 / 12.0, 1.0 / 12.0] + case .triadic: + [0, 1.0 / 3.0, 2.0 / 3.0] + case .tetradic: + [0, 0.25, 0.5, 0.75] + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift index 3c9e5e0936..b23ddfe8d5 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift @@ -129,6 +129,7 @@ extension ThemeModel { } } } + refreshGeneratedTheme() } func importTheme() { diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel.swift index fb755a9fea..cbe02cdd4b 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel.swift @@ -49,7 +49,13 @@ final class ThemeModel: ObservableObject { } /// System color scheme - @Published var colorScheme: ColorScheme = .light + @Published var colorScheme: ColorScheme = .light { + didSet { + refreshGeneratedTheme() + } + } + + private var systemColorsObserver: NSObjectProtocol? /// Selected 'light' theme /// Used for auto-switching theme to match macOS system appearance @@ -82,6 +88,9 @@ final class ThemeModel: ObservableObject { /// An array of loaded ``Theme``. @Published var themes: [Theme] = [] + /// A transient editor theme derived from the current system accent color. + @Published private(set) var generatedTheme: Theme? + /// The currently selected ``Theme``. @Published var selectedTheme: Theme? { didSet { @@ -93,6 +102,11 @@ final class ThemeModel: ObservableObject { @Published var previousTheme: Theme? + /// The theme currently used by the source editor. + var effectiveTheme: Theme? { + generatedTheme ?? selectedTheme + } + /// Only themes where ``Theme/appearance`` == ``Theme/ThemeType/dark`` var darkThemes: [Theme] { themes.filter { $0.appearance == .dark } @@ -104,11 +118,61 @@ final class ThemeModel: ObservableObject { } private init() { + colorScheme = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + ? .dark + : .light do { try loadThemes() } catch { print(error) } + + systemColorsObserver = NotificationCenter.default.addObserver( + forName: NSColor.systemColorsDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshGeneratedTheme() + } + refreshGeneratedTheme() + } + + deinit { + if let systemColorsObserver { + NotificationCenter.default.removeObserver(systemColorsObserver) + } + } + + /// Rebuilds the transient generated theme, or removes it when generation is disabled. + func refreshGeneratedTheme() { + guard settings.automaticallyGenerateTheme else { + generatedTheme = nil + return + } + + let baseTheme: Theme? = if settings.matchAppearance { + colorScheme == .dark ? selectedDarkTheme : selectedLightTheme + } else { + selectedTheme + } + + guard let baseTheme else { + generatedTheme = nil + return + } + + generatedTheme = GeneratedThemeGenerator.generate( + from: baseTheme, + seedColor: GeneratedThemeGenerator.systemAccentColor(for: baseTheme.appearance), + strategy: settings.generatedThemeStrategy + ) + } + + /// Synchronizes the model with the active appearance and selects the matching manual theme when requested. + func syncAppearance(with colorScheme: ColorScheme) { + self.colorScheme = colorScheme + guard settings.matchAppearance else { return } + selectedTheme = colorScheme == .dark ? selectedDarkTheme : selectedLightTheme } /// This function stores 'dark' and 'light' themes into `ThemePreferences` if user happens to select a theme @@ -139,13 +203,17 @@ final class ThemeModel: ObservableObject { } func getThemeActive(_ theme: Theme) -> Bool { - return selectedTheme == theme + return generatedTheme == nil && selectedTheme == theme } /// Activates the current theme, setting ``selectedTheme`` and ``selectedLightTheme``/``selectedDarkTheme`` as /// necessary. /// - Parameter theme: The theme to activate. func activateTheme(_ theme: Theme) { + if settings.automaticallyGenerateTheme { + settings.automaticallyGenerateTheme = false + generatedTheme = nil + } selectedTheme = theme if colorScheme == .light { selectedLightTheme = theme diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeSettings.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeSettings.swift index b37f986be3..81abcdef30 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeSettings.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeSettings.swift @@ -35,6 +35,13 @@ extension SettingsData { var searchKeys: [String] { [ "Automatically Change theme based on system appearance", + "Automatically generate syntax colors from system accent color", + "Color harmony", + "Complementary", + "Monochromatic", + "Analogous", + "Triadic", + "Tetradic", "Always use dark terminal appearance", "Use theme background", "Light Appearance", @@ -64,6 +71,19 @@ extension SettingsData { /// Automatically change theme based on system appearance var matchAppearance: Bool = true + /// Automatically derive editor syntax colors from the macOS system accent color. + var automaticallyGenerateTheme: Bool = false + + /// The color harmony used when generating syntax colors. + var generatedThemeStrategy: GeneratedThemeStrategy = .analogous + + /// Whether the source editor should render an opaque theme background. + /// Generated palettes need a known surface color to preserve their contrast guarantees, + /// while the stored preference continues to control other theme-backed surfaces. + var useThemeBackgroundInEditor: Bool { + useThemeBackground || automaticallyGenerateTheme + } + /// Dictionary of themes containing overrides /// /// ```json @@ -108,6 +128,12 @@ extension SettingsData { self.matchAppearance = try container.decodeIfPresent( Bool.self, forKey: .matchAppearance ) ?? true + self.automaticallyGenerateTheme = try container.decodeIfPresent( + Bool.self, forKey: .automaticallyGenerateTheme + ) ?? false + self.generatedThemeStrategy = try container.decodeIfPresent( + GeneratedThemeStrategy.self, forKey: .generatedThemeStrategy + ) ?? .analogous self.overrides = try container.decodeIfPresent([String: ThemeOverrides].self, forKey: .overrides) ?? [:] } } diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift index 04c94db1b6..9c7d2a6b30 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift @@ -63,6 +63,11 @@ struct ThemeSettingsView: View { if themeSearchQuery.isEmpty { Section { changeThemeOnSystemAppearance + automaticThemeGeneration + if settings.automaticallyGenerateTheme { + generatedThemeStrategy + generatedThemePreview + } if settings.matchAppearance { alwaysUseDarkTerminalAppearance } @@ -114,6 +119,7 @@ struct ThemeSettingsView: View { } }) .onAppear { + themeModel.syncAppearance(with: colorScheme) updateFilteredThemes() } .onChange(of: themeSearchQuery) { _, _ in @@ -123,6 +129,7 @@ struct ThemeSettingsView: View { updateFilteredThemes() } .onChange(of: colorScheme) { _, newColorScheme in + themeModel.syncAppearance(with: newColorScheme) updateFilteredThemes(overrideColorScheme: newColorScheme) } } @@ -152,8 +159,44 @@ struct ThemeSettingsView: View { } private extension ThemeSettingsView { + private var automaticThemeGeneration: some View { + Toggle( + "Automatically generate syntax colors from system accent color", + isOn: $settings.automaticallyGenerateTheme + ) + .onChange(of: settings.automaticallyGenerateTheme) { _, _ in + themeModel.refreshGeneratedTheme() + } + .help("Uses the accent color selected in System Settings and updates when it changes.") + } + + private var generatedThemeStrategy: some View { + Picker("Color harmony", selection: $settings.generatedThemeStrategy) { + ForEach(GeneratedThemeStrategy.allCases) { strategy in + Text(strategy.displayName).tag(strategy) + } + } + .onChange(of: settings.generatedThemeStrategy) { _, _ in + themeModel.refreshGeneratedTheme() + } + } + + @ViewBuilder private var generatedThemePreview: some View { + if let generatedTheme = themeModel.generatedTheme { + LabeledContent("Generated palette") { + ThemeSettingsColorPreview(generatedTheme) + } + } + } + private var useThemeBackground: some View { - Toggle("Use theme background ", isOn: $settings.useThemeBackground) + Toggle("Use theme background", isOn: $settings.useThemeBackground) + .help( + settings.automaticallyGenerateTheme + ? "Generated syntax colors keep the source editor's theme background on to maintain readable " + + "contrast; this setting continues to control other surfaces." + : "Use the selected theme's background color in the editor." + ) } private var alwaysUseDarkTerminalAppearance: some View { @@ -167,16 +210,13 @@ private extension ThemeSettingsView { ) .onChange(of: settings.matchAppearance) { _, value in if value { - if colorScheme == .dark { - themeModel.selectedTheme = themeModel.selectedDarkTheme - } else { - themeModel.selectedTheme = themeModel.selectedLightTheme - } + themeModel.syncAppearance(with: colorScheme) } else { themeModel.selectedTheme = themeModel.themes.first { $0.name == settings.selectedTheme } } + themeModel.refreshGeneratedTheme() } } } diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index 81cd46b5f3..f79bc12cb5 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -17,9 +17,6 @@ struct WorkspaceView: View { @FocusState var focusedEditor: Editor? - @AppSettings(\.theme.matchAppearance) - var matchAppearance - @AppSettings(\.sourceControl.general.sourceControlIsEnabled) var sourceControlIsEnabled @@ -69,15 +66,10 @@ struct WorkspaceView: View { // MARK: - Theme Color Scheme .task { - themeModel.colorScheme = colorScheme + themeModel.syncAppearance(with: colorScheme) } .onChange(of: colorScheme) { _, newValue in - themeModel.colorScheme = newValue - if matchAppearance { - themeModel.selectedTheme = newValue == .dark - ? themeModel.selectedDarkTheme - : themeModel.selectedLightTheme - } + themeModel.syncAppearance(with: newValue) } // MARK: - Source Control diff --git a/CodeEditTests/Features/SettingsGeneratedThemeTests.swift b/CodeEditTests/Features/SettingsGeneratedThemeTests.swift new file mode 100644 index 0000000000..f6f7573839 --- /dev/null +++ b/CodeEditTests/Features/SettingsGeneratedThemeTests.swift @@ -0,0 +1,297 @@ +// +// SettingsGeneratedThemeTests.swift +// CodeEditTests +// + +import AppKit +import XCTest +@testable import CodeEdit + +final class SettingsGeneratedThemeTests: XCTestCase { + func testLegacyThemeSettingsUseGenerationDefaults() throws { + let settings = try JSONDecoder().decode( + SettingsData.ThemeSettings.self, + from: Data("{}".utf8) + ) + + XCTAssertFalse(settings.automaticallyGenerateTheme) + XCTAssertEqual(settings.generatedThemeStrategy, .analogous) + } + + func testGeneratedThemeSettingsRoundTrip() throws { + for strategy in GeneratedThemeStrategy.allCases { + var settings = SettingsData.ThemeSettings() + settings.automaticallyGenerateTheme = true + settings.generatedThemeStrategy = strategy + + let data = try JSONEncoder().encode(settings) + let decodedSettings = try JSONDecoder().decode(SettingsData.ThemeSettings.self, from: data) + + XCTAssertTrue(decodedSettings.automaticallyGenerateTheme) + XCTAssertEqual(decodedSettings.generatedThemeStrategy, strategy) + } + } + + func testGeneratedThemeRequiresOpaqueEditorBackground() { + var settings = SettingsData.ThemeSettings() + settings.useThemeBackground = false + + XCTAssertFalse(settings.useThemeBackgroundInEditor) + + settings.automaticallyGenerateTheme = true + + XCTAssertTrue(settings.useThemeBackgroundInEditor) + } + + func testStrategiesGenerateExpectedHueHarmonies() { + let seedHue: CGFloat = 0.02 + let seedColor = NSColor(calibratedHue: seedHue, saturation: 0.6, brightness: 0.9, alpha: 1) + let backgroundColor = NSColor.black + let expectedOffsets: [GeneratedThemeStrategy: [CGFloat]] = [ + .complementary: [0, 0.5], + .monochromatic: [0], + .analogous: [0, -1.0 / 12.0, 1.0 / 12.0], + .triadic: [0, 1.0 / 3.0, 2.0 / 3.0], + .tetradic: [0, 0.25, 0.5, 0.75] + ] + + for strategy in GeneratedThemeStrategy.allCases { + let colors = GeneratedThemeGenerator.syntaxColors( + seedColor: seedColor, + backgroundColor: backgroundColor, + appearance: .dark, + strategy: strategy + ) + let offsets = expectedOffsets[strategy, default: []] + + for (index, color) in colors.enumerated() { + let offset = offsets[index % offsets.count] + XCTAssertEqual( + hue(of: color), + wrappedHue(seedHue + offset), + accuracy: 0.02, + "Unexpected hue for \(strategy.displayName)" + ) + } + } + } + + func testEveryStrategyMeetsSyntaxContrastInLightAndDarkThemes() { + let seedColor = NSColor(srgbRed: 0.12, green: 0.48, blue: 0.92, alpha: 1) + let backgrounds: [(Theme.ThemeType, NSColor)] = [ + (.light, NSColor(hex: "#FFFFFF")), + (.dark, NSColor(hex: "#292A30")) + ] + + for strategy in GeneratedThemeStrategy.allCases { + for (appearance, backgroundColor) in backgrounds { + let generatedTheme = GeneratedThemeGenerator.generate( + from: makeTheme(appearance: appearance, background: backgroundColor.hexString), + seedColor: seedColor, + strategy: strategy + ) + let colors = syntaxAttributes(from: generatedTheme).map(\.nsColor) + + XCTAssertEqual(colors.count, 10) + for color in colors { + XCTAssertGreaterThanOrEqual( + GeneratedThemeGenerator.contrastRatio(between: color, and: backgroundColor), + GeneratedThemeGenerator.minimumSyntaxContrast + ) + } + } + } + } + + func testGraphiteAccentProducesAReadableDeliberateGrayPalette() { + let backgroundColor = NSColor(hex: "#292A30") + let colors = GeneratedThemeGenerator.syntaxColors( + seedColor: NSColor(white: 0.55, alpha: 1), + backgroundColor: backgroundColor, + appearance: .dark, + strategy: .tetradic + ) + + let resolvedColors = colors.compactMap { $0.usingColorSpace(.sRGB) } + XCTAssertEqual(resolvedColors.count, 10) + XCTAssertGreaterThan(Set(resolvedColors.map { Int(round($0.redComponent * 255)) }).count, 3) + for color in resolvedColors { + XCTAssertEqual(color.redComponent, color.greenComponent, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, color.blueComponent, accuracy: 0.001) + XCTAssertGreaterThanOrEqual( + GeneratedThemeGenerator.contrastRatio(between: color, and: backgroundColor), + GeneratedThemeGenerator.minimumSyntaxContrast + ) + } + } + + func testGenerationPreservesBaseThemeAndTerminalSemantics() { + let baseTheme = makeTheme(appearance: .light, background: "#FFFFFF") + let generatedTheme = GeneratedThemeGenerator.generate( + from: baseTheme, + seedColor: NSColor.systemYellow, + strategy: .triadic + ) + + XCTAssertEqual(generatedTheme.appearance, baseTheme.appearance) + XCTAssertEqual(generatedTheme.editor.background, baseTheme.editor.background) + XCTAssertEqual(generatedTheme.editor.text, baseTheme.editor.text) + XCTAssertEqual(generatedTheme.terminal, baseTheme.terminal) + XCTAssertEqual(generatedTheme.editor.keywords.bold, baseTheme.editor.keywords.bold) + XCTAssertEqual(generatedTheme.editor.comments.italic, baseTheme.editor.comments.italic) + XCTAssertNotEqual(generatedTheme.editor.keywords.color, baseTheme.editor.keywords.color) + XCTAssertNil(generatedTheme.fileURL) + XCTAssertFalse(generatedTheme.isBundled) + XCTAssertGreaterThanOrEqual( + GeneratedThemeGenerator.contrastRatio( + between: generatedTheme.editor.insertionPoint.nsColor, + and: generatedTheme.editor.background.nsColor + ), + 3.0 + ) + + for attributes in syntaxAttributes(from: generatedTheme) { + XCTAssertGreaterThanOrEqual( + GeneratedThemeGenerator.contrastRatio( + between: attributes.nsColor, + and: generatedTheme.editor.background.nsColor + ), + GeneratedThemeGenerator.minimumSyntaxContrast + ) + } + } + + func testGenerationIsDeterministic() { + let baseTheme = makeTheme(appearance: .light, background: "#FFFFFF") + let seedColor = NSColor(srgbRed: 0.82, green: 0.18, blue: 0.45, alpha: 1) + + let firstTheme = GeneratedThemeGenerator.generate( + from: baseTheme, + seedColor: seedColor, + strategy: .analogous + ) + let secondTheme = GeneratedThemeGenerator.generate( + from: baseTheme, + seedColor: seedColor, + strategy: .analogous + ) + + XCTAssertEqual(firstTheme.editor, secondTheme.editor) + XCTAssertEqual(firstTheme.terminal, secondTheme.terminal) + XCTAssertEqual(firstTheme.name, secondTheme.name) + } +} + +private extension SettingsGeneratedThemeTests { + func hue(of color: NSColor) -> CGFloat { + let color = color.usingColorSpace(.sRGB) ?? color + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + return hue + } + + func wrappedHue(_ hue: CGFloat) -> CGFloat { + let remainder = hue.truncatingRemainder(dividingBy: 1) + return remainder < 0 ? remainder + 1 : remainder + } + + func syntaxAttributes(from theme: Theme) -> [Theme.Attributes] { + [ + theme.editor.keywords, + theme.editor.commands, + theme.editor.types, + theme.editor.attributes, + theme.editor.variables, + theme.editor.values, + theme.editor.numbers, + theme.editor.strings, + theme.editor.characters, + theme.editor.comments + ] + } + + func makeTheme(appearance: Theme.ThemeType, background: String) -> Theme { + return Theme( + editor: makeEditorColors(appearance: appearance, background: background), + terminal: makeTerminalColors(appearance: appearance, background: background), + author: "Test", + license: "MIT", + metadataDescription: "Test theme", + distributionURL: "", + isBundled: true, + name: "test.\(appearance.rawValue)", + displayName: "Test \(appearance.rawValue)", + appearance: appearance, + version: "1.0.0" + ) + } + + func makeEditorColors( + appearance: Theme.ThemeType, + background: String + ) -> Theme.EditorColors { + let foreground = Theme.Attributes(color: appearance == .dark ? "#FFFFFF" : "#000000") + let muted = Theme.Attributes(color: appearance == .dark ? "#A0A0A0" : "#555555") + let accent = Theme.Attributes(color: "#007AFF") + let syntax = Theme.Attributes(color: "#FF0000") + let boldSyntax = Theme.Attributes(color: "#FF0000", bold: true) + let italicSyntax = Theme.Attributes(color: "#888888", italic: true) + let background = Theme.Attributes(color: background) + + return Theme.EditorColors( + text: foreground, + insertionPoint: accent, + invisibles: muted, + background: background, + lineHighlight: background, + selection: muted, + keywords: boldSyntax, + commands: syntax, + types: syntax, + attributes: syntax, + variables: syntax, + values: syntax, + numbers: syntax, + strings: syntax, + characters: syntax, + comments: italicSyntax + ) + } + + func makeTerminalColors( + appearance: Theme.ThemeType, + background: String + ) -> Theme.TerminalColors { + let foreground = Theme.Attributes(color: appearance == .dark ? "#FFFFFF" : "#000000") + let muted = Theme.Attributes(color: appearance == .dark ? "#A0A0A0" : "#555555") + let syntax = Theme.Attributes(color: "#FF0000") + let background = Theme.Attributes(color: background) + + return Theme.TerminalColors( + text: foreground, + boldText: foreground, + cursor: foreground, + background: background, + selection: muted, + black: muted, + red: syntax, + green: syntax, + yellow: syntax, + blue: syntax, + magenta: syntax, + cyan: syntax, + white: foreground, + brightBlack: muted, + brightRed: syntax, + brightGreen: syntax, + brightYellow: syntax, + brightBlue: syntax, + brightMagenta: syntax, + brightCyan: syntax, + brightWhite: foreground + ) + } +}