Skip to content

Commit 78d2b5a

Browse files
committed
feat: add generated accent color themes
1 parent fa2aebd commit 78d2b5a

9 files changed

Lines changed: 712 additions & 24 deletions

File tree

‎CodeEdit/Features/Editor/Views/CodeFileView.swift‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,8 @@ struct CodeFileView: View {
3535
var overscroll
3636
@AppSettings(\.textEditing.font)
3737
var settingsFont
38-
@AppSettings(\.theme.useThemeBackground)
39-
var useThemeBackground
40-
@AppSettings(\.theme.matchAppearance)
41-
var matchAppearance
38+
@AppSettings(\.theme)
39+
var themeSettings
4240
@AppSettings(\.textEditing.letterSpacing)
4341
var letterSpacing
4442
@AppSettings(\.textEditing.bracketEmphasis)
@@ -105,7 +103,7 @@ struct CodeFileView: View {
105103
}
106104

107105
private var currentTheme: Theme {
108-
themeModel.selectedTheme ?? themeModel.themes.first!
106+
themeModel.effectiveTheme ?? themeModel.themes.first!
109107
}
110108

111109
@State private var font: NSFont = Settings[\.textEditing].font.current
@@ -120,7 +118,7 @@ struct CodeFileView: View {
120118
configuration: SourceEditorConfiguration(
121119
appearance: .init(
122120
theme: currentTheme.editor.editorTheme,
123-
useThemeBackground: useThemeBackground,
121+
useThemeBackground: themeSettings.useThemeBackgroundInEditor,
124122
font: font,
125123
lineHeightMultiple: lineHeightMultiple,
126124
letterSpacing: letterSpacing,
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//
2+
// GeneratedThemeGenerator.swift
3+
// CodeEdit
4+
//
5+
6+
import AppKit
7+
8+
/// Generates a transient, accessible syntax palette from a seed color.
9+
enum GeneratedThemeGenerator {
10+
static let minimumSyntaxContrast: CGFloat = 4.5
11+
12+
private static let darkBrightness: [CGFloat] = [
13+
0.90, 0.78, 1.00, 0.70, 0.85, 0.75, 0.95, 0.66, 0.81, 0.72
14+
]
15+
private static let lightBrightness: [CGFloat] = [
16+
0.55, 0.45, 0.62, 0.38, 0.50, 0.42, 0.58, 0.35, 0.48, 0.40
17+
]
18+
private static let saturationFactors: [CGFloat] = [
19+
1.00, 0.82, 0.94, 0.68, 0.88, 0.74, 0.97, 0.62, 0.84, 0.70
20+
]
21+
private static let darkGrayBrightness: [CGFloat] = [
22+
0.86, 0.72, 0.94, 0.64, 0.80, 0.69, 0.90, 0.60, 0.76, 0.83
23+
]
24+
private static let lightGrayBrightness: [CGFloat] = [
25+
0.28, 0.36, 0.20, 0.42, 0.32, 0.24, 0.39, 0.17, 0.45, 0.30
26+
]
27+
28+
/// Creates a generated theme by replacing only the editor's syntax palette.
29+
/// The base theme's primary text, background, styling, and terminal colors are preserved.
30+
static func generate(
31+
from baseTheme: Theme,
32+
seedColor: NSColor,
33+
strategy: GeneratedThemeStrategy
34+
) -> Theme {
35+
var generatedTheme = baseTheme
36+
let syntaxColors = syntaxColors(
37+
seedColor: seedColor,
38+
backgroundColor: baseTheme.editor.background.nsColor,
39+
appearance: baseTheme.appearance,
40+
strategy: strategy
41+
)
42+
43+
generatedTheme.editor.keywords.color = hexString(for: syntaxColors[0])
44+
generatedTheme.editor.commands.color = hexString(for: syntaxColors[1])
45+
generatedTheme.editor.types.color = hexString(for: syntaxColors[2])
46+
generatedTheme.editor.attributes.color = hexString(for: syntaxColors[3])
47+
generatedTheme.editor.variables.color = hexString(for: syntaxColors[4])
48+
generatedTheme.editor.values.color = hexString(for: syntaxColors[5])
49+
generatedTheme.editor.numbers.color = hexString(for: syntaxColors[6])
50+
generatedTheme.editor.strings.color = hexString(for: syntaxColors[7])
51+
generatedTheme.editor.characters.color = hexString(for: syntaxColors[8])
52+
generatedTheme.editor.comments.color = hexString(for: syntaxColors[9])
53+
54+
let insertionPoint = colorEnsuringContrast(
55+
resolvedSRGB(seedColor),
56+
against: resolvedSRGB(baseTheme.editor.background.nsColor),
57+
minimumRatio: 3.05
58+
)
59+
generatedTheme.editor.insertionPoint.color = hexString(for: insertionPoint)
60+
61+
generatedTheme.metadataDescription = "Generated from the macOS system accent color using a "
62+
+ "\(strategy.displayName.lowercased()) color harmony."
63+
generatedTheme.isBundled = false
64+
generatedTheme.fileURL = nil
65+
generatedTheme.name = "generated.system-accent.\(strategy.rawValue).\(baseTheme.appearance.rawValue)"
66+
generatedTheme.displayName = "System Accent — \(strategy.displayName)"
67+
68+
return generatedTheme
69+
}
70+
71+
/// Resolves the user's dynamic accent color for a specific appearance into sRGB.
72+
/// AppKit applies Desktop Tinting while rendering materials, but does not expose that tint as a color value.
73+
static func systemAccentColor(for appearance: Theme.ThemeType) -> NSColor {
74+
let appearanceName: NSAppearance.Name = appearance == .dark ? .darkAqua : .aqua
75+
guard let drawingAppearance = NSAppearance(named: appearanceName) else {
76+
return resolvedSRGB(.systemBlue)
77+
}
78+
79+
var accentColor = NSColor.systemBlue
80+
drawingAppearance.performAsCurrentDrawingAppearance {
81+
accentColor = resolvedSRGB(.controlAccentColor)
82+
}
83+
return accentColor
84+
}
85+
86+
/// Produces ten syntax colors with a readable contrast ratio against the supplied background.
87+
static func syntaxColors(
88+
seedColor: NSColor,
89+
backgroundColor: NSColor,
90+
appearance: Theme.ThemeType,
91+
strategy: GeneratedThemeStrategy
92+
) -> [NSColor] {
93+
let seed = resolvedSRGB(seedColor)
94+
let background = resolvedSRGB(backgroundColor)
95+
96+
var hue: CGFloat = 0
97+
var saturation: CGFloat = 0
98+
seed.getHue(&hue, saturation: &saturation, brightness: nil, alpha: nil)
99+
100+
if saturation < 0.08 {
101+
let brightnessValues = appearance == .dark ? darkGrayBrightness : lightGrayBrightness
102+
return brightnessValues.map {
103+
colorEnsuringContrast(
104+
NSColor(srgbRed: $0, green: $0, blue: $0, alpha: 1),
105+
against: background,
106+
minimumRatio: minimumSyntaxContrast + 0.05
107+
)
108+
}
109+
}
110+
111+
let brightnessValues = appearance == .dark ? darkBrightness : lightBrightness
112+
let baseSaturation = max(saturation, 0.55)
113+
114+
return (0..<10).map { index in
115+
let offsets = strategy.hueOffsets
116+
let offset = offsets[index % offsets.count]
117+
let variant = index / offsets.count
118+
let rotatedHue = wrappedHue(hue + offset)
119+
let adjustedSaturation = clamped(
120+
baseSaturation * saturationFactors[variant % saturationFactors.count]
121+
)
122+
let candidate = NSColor(
123+
calibratedHue: rotatedHue,
124+
saturation: adjustedSaturation,
125+
brightness: brightnessValues[variant % brightnessValues.count],
126+
alpha: 1
127+
)
128+
129+
return colorEnsuringContrast(
130+
resolvedSRGB(candidate),
131+
against: background,
132+
minimumRatio: minimumSyntaxContrast + 0.05
133+
)
134+
}
135+
}
136+
137+
static func contrastRatio(between firstColor: NSColor, and secondColor: NSColor) -> CGFloat {
138+
let firstLuminance = relativeLuminance(of: resolvedSRGB(firstColor))
139+
let secondLuminance = relativeLuminance(of: resolvedSRGB(secondColor))
140+
let lighter = max(firstLuminance, secondLuminance)
141+
let darker = min(firstLuminance, secondLuminance)
142+
return (lighter + 0.05) / (darker + 0.05)
143+
}
144+
}
145+
146+
private extension GeneratedThemeGenerator {
147+
static func resolvedSRGB(_ color: NSColor) -> NSColor {
148+
color.usingColorSpace(.sRGB) ?? NSColor(srgbRed: 0, green: 0.478, blue: 1, alpha: 1)
149+
}
150+
151+
static func hexString(for color: NSColor) -> String {
152+
let color = resolvedSRGB(color)
153+
let red = Int(round(clamped(color.redComponent) * 255))
154+
let green = Int(round(clamped(color.greenComponent) * 255))
155+
let blue = Int(round(clamped(color.blueComponent) * 255))
156+
return String(format: "#%02X%02X%02X", red, green, blue)
157+
}
158+
159+
static func wrappedHue(_ hue: CGFloat) -> CGFloat {
160+
let remainder = hue.truncatingRemainder(dividingBy: 1)
161+
return remainder < 0 ? remainder + 1 : remainder
162+
}
163+
164+
static func clamped(_ value: CGFloat) -> CGFloat {
165+
min(max(value, 0), 1)
166+
}
167+
168+
static func colorEnsuringContrast(
169+
_ color: NSColor,
170+
against background: NSColor,
171+
minimumRatio: CGFloat
172+
) -> NSColor {
173+
guard contrastRatio(between: color, and: background) < minimumRatio else {
174+
return color
175+
}
176+
177+
let black = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1)
178+
let white = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
179+
let target = contrastRatio(between: black, and: background)
180+
> contrastRatio(between: white, and: background) ? black : white
181+
182+
var lowerBound: CGFloat = 0
183+
var upperBound: CGFloat = 1
184+
for _ in 0..<24 {
185+
let fraction = (lowerBound + upperBound) / 2
186+
let mixedColor = mix(color, with: target, fraction: fraction)
187+
if contrastRatio(between: mixedColor, and: background) >= minimumRatio {
188+
upperBound = fraction
189+
} else {
190+
lowerBound = fraction
191+
}
192+
}
193+
return mix(color, with: target, fraction: upperBound)
194+
}
195+
196+
static func mix(_ color: NSColor, with target: NSColor, fraction: CGFloat) -> NSColor {
197+
let color = resolvedSRGB(color)
198+
let target = resolvedSRGB(target)
199+
return NSColor(
200+
srgbRed: color.redComponent + ((target.redComponent - color.redComponent) * fraction),
201+
green: color.greenComponent + ((target.greenComponent - color.greenComponent) * fraction),
202+
blue: color.blueComponent + ((target.blueComponent - color.blueComponent) * fraction),
203+
alpha: 1
204+
)
205+
}
206+
207+
static func relativeLuminance(of color: NSColor) -> CGFloat {
208+
func linearized(_ component: CGFloat) -> CGFloat {
209+
component <= 0.04045
210+
? component / 12.92
211+
: pow((component + 0.055) / 1.055, 2.4)
212+
}
213+
214+
return (0.2126 * linearized(color.redComponent))
215+
+ (0.7152 * linearized(color.greenComponent))
216+
+ (0.0722 * linearized(color.blueComponent))
217+
}
218+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// GeneratedThemeStrategy.swift
3+
// CodeEdit
4+
//
5+
6+
import Foundation
7+
8+
/// A color-harmony strategy used to derive syntax colors from the system accent color.
9+
enum GeneratedThemeStrategy: String, CaseIterable, Codable, Hashable, Identifiable {
10+
case complementary
11+
case monochromatic
12+
case analogous
13+
case triadic
14+
case tetradic
15+
16+
var id: Self { self }
17+
18+
var displayName: String {
19+
switch self {
20+
case .complementary:
21+
"Complementary"
22+
case .monochromatic:
23+
"Monochromatic"
24+
case .analogous:
25+
"Analogous"
26+
case .triadic:
27+
"Triadic"
28+
case .tetradic:
29+
"Tetradic"
30+
}
31+
}
32+
33+
/// Hue rotations, expressed as fractions of one full turn.
34+
var hueOffsets: [CGFloat] {
35+
switch self {
36+
case .complementary:
37+
[0, 0.5]
38+
case .monochromatic:
39+
[0]
40+
case .analogous:
41+
[0, -1.0 / 12.0, 1.0 / 12.0]
42+
case .triadic:
43+
[0, 1.0 / 3.0, 2.0 / 3.0]
44+
case .tetradic:
45+
[0, 0.25, 0.5, 0.75]
46+
}
47+
}
48+
}

‎CodeEdit/Features/Settings/Pages/ThemeSettings/Models/ThemeModel+CRUD.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ extension ThemeModel {
129129
}
130130
}
131131
}
132+
refreshGeneratedTheme()
132133
}
133134

134135
func importTheme() {

0 commit comments

Comments
 (0)