Sources/OrgEditorAppKit/Theme.swift
147 lines · 6715 bytes
1#if os(macOS)
2import AppKit
3import OrgPresentation
4
5/// Attributes for each style role, from a `ThemeSpec`.
6@MainActor
7struct Theme {
8 let spec: ThemeSpec
9 let font: NSFont
10
11 init(_ spec: ThemeSpec = .default) {
12 self.spec = spec
13 let size = CGFloat(max(6, spec.fontSize))
14 font = spec.font.isEmpty ? NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
15 : NSFontManager.shared.font(withFamily: spec.font, traits: [], weight: 5, size: size)
16 ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
17 Self.blockBackground = color("block-background") ?? .clear
18 }
19
20 /// A color that follows the appearance, or nil when the theme has none for `key`.
21 func color(_ key: String) -> NSColor? {
22 // Editor colors are set for both appearances by the default theme; a key the theme
23 // has for one only takes the default for the other.
24 let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key]
25 let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key]
26 guard let light, let dark else { return nil }
27 return Self.dynamic(light, dark)
28 }
29
30 /// One color object per pair, so equal styling compares equal.
31 private static var colors: [[RGBA]: NSColor] = [:]
32
33 static func dynamic(_ light: RGBA, _ dark: RGBA) -> NSColor {
34 if let cached = colors[[light, dark]] { return cached }
35 let color = NSColor(name: nil) { appearance in
36 let c = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light
37 return NSColor(srgbRed: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
38 }
39 colors[[light, dark]] = color
40 return color
41 }
42
43 var foreground: NSColor { color("foreground") ?? .textColor }
44
45 var base: [NSAttributedString.Key: Any] {
46 [.font: font, .foregroundColor: foreground, .paragraphStyle: paragraphStyle(firstLine: 0, wrapped: 0)]
47 }
48
49 /// Width of one column of the monospaced font.
50 var column: CGFloat {
51 ("0" as NSString).size(withAttributes: [.font: font]).width
52 }
53
54 /// The band behind blocks, read by layout fragments off the main actor.
55 nonisolated(unsafe) static var blockBackground = NSColor.clear
56 /// The selection's color, which shaded block lines draw over their band.
57 nonisolated(unsafe) static var selection = NSColor.selectedTextBackgroundColor
58 /// Layout managers whose view has focus in the key window, for the selection's color.
59 nonisolated(unsafe) static var focusedLayouts: Set<ObjectIdentifier> = []
60
61 /// Markup hidden from view: no width, no color.
62 var hidden: [NSAttributedString.Key: Any] {
63 [.font: NSFont.monospacedSystemFont(ofSize: 0.01, weight: .regular), .foregroundColor: NSColor.clear]
64 }
65
66 func paragraphStyle(firstLine: Int, wrapped: Int) -> NSParagraphStyle {
67 let style = NSMutableParagraphStyle()
68 style.firstLineHeadIndent = CGFloat(firstLine) * column
69 style.headIndent = CGFloat(wrapped) * column
70 style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
71 return style
72 }
73
74 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
75 func fg(_ key: String) {
76 if let color = color(key) { storage.addAttribute(.foregroundColor, value: color, range: range) }
77 }
78 switch role {
79 case .heading(let level):
80 let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level))
81 let heading = NSFontManager.shared.convert(font.withSize(size), toHaveTrait: .boldFontMask)
82 storage.addAttribute(.font, value: heading, range: range)
83 fg("heading-\(min(max(level, 1), 8))")
84 case .leadingStars:
85 storage.addAttribute(.foregroundColor, value: NSColor.clear, range: range)
86 case .todo, .done:
87 let keyword = (storage.string as NSString).substring(with: range)
88 storage.addAttribute(.font, value: bold(storage, range), range: range)
89 if let custom = spec.todo[keyword] {
90 storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range)
91 } else {
92 fg(role == .todo ? "todo" : "done")
93 }
94 case .priority: fg("priority")
95 case .tags: fg("tags")
96 case .comment: fg("comment")
97 case .keyword: fg("keyword")
98 case .metadata: fg("metadata")
99 case .bold:
100 storage.addAttribute(.font, value: bold(storage, range), range: range)
101 case .italic:
102 storage.addAttribute(.font, value: NSFontManager.shared.convert(currentFont(storage, range), toHaveTrait: .italicFontMask), range: range)
103 case .underline:
104 storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
105 case .strikeThrough:
106 storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range)
107 case .verbatim, .code, .inlineSource:
108 if let background = color("inline-background") { storage.addAttribute(.backgroundColor, value: background, range: range) }
109 fg(role == .verbatim ? "verbatim" : "code")
110 case .markup, .linkMarkup: fg("markup")
111 case .blockDelimiter: fg("block-delimiter")
112 case .link:
113 storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
114 fg("link")
115 case .timestamp: fg("timestamp")
116 case .footnote, .cookie, .target, .macro, .latex: fg("special")
117 case .table, .rule: fg("table")
118 case .block:
119 break
120 case .syntax(let category):
121 if let key = Self.slot(category) { fg(key) }
122 }
123 }
124
125 static func slot(_ category: SyntaxCategory) -> String? {
126 switch category {
127 case .keyword: "syntax-keyword"
128 case .string: "syntax-string"
129 case .comment: "syntax-comment"
130 case .function: "syntax-function"
131 case .type: "syntax-type"
132 case .number, .constant, .escape: "syntax-number"
133 case .property, .attribute, .tag: "syntax-property"
134 case .label: "syntax-label"
135 case .variable, .operator, .punctuation: nil
136 }
137 }
138
139 private func currentFont(_ storage: NSTextStorage, _ range: NSRange) -> NSFont {
140 storage.attribute(.font, at: range.location, effectiveRange: nil) as? NSFont ?? font
141 }
142
143 private func bold(_ storage: NSTextStorage, _ range: NSRange) -> NSFont {
144 NSFontManager.shared.convert(currentFont(storage, range), toHaveTrait: .boldFontMask)
145 }
146}
147#endif