krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgEditorAppKit/Theme.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/OrgEditorAppKit/Theme.swift history · blame · raw

133 lines · 6234 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(ThemeSpec.TodoClass.of(keyword, isDone: role == .done).key)
 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 = category.themeKey { fg(key) }
122        }
123    }
124
125    private func currentFont(_ storage: NSTextStorage, _ range: NSRange) -> NSFont {
126        storage.attribute(.font, at: range.location, effectiveRange: nil) as? NSFont ?? font
127    }
128
129    private func bold(_ storage: NSTextStorage, _ range: NSRange) -> NSFont {
130        NSFontManager.shared.convert(currentFont(storage, range), toHaveTrait: .boldFontMask)
131    }
132}
133#endif