krz/orgstar

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

Sources/OrgstarMobile/MobileTheme.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgstarMobile/MobileTheme.swift history · blame · raw

129 lines · 6059 bytes

  1#if os(iOS)
  2import OrgPresentation
  3import SwiftUI
  4import UIKit
  5
  6/// Attributes for each style role, from a `ThemeSpec`, as the Mac editor's theme sets them.
  7@MainActor
  8struct MobileTheme {
  9    let spec: ThemeSpec
 10    let font: UIFont
 11
 12    init(_ spec: ThemeSpec = .default) {
 13        self.spec = spec
 14        let size = CGFloat(max(6, spec.fontSize))
 15        // The theme's family when the device has it, else the monospaced system font; at the
 16        // theme's size for the default Dynamic Type size, scaled for others.
 17        let chosen = spec.font.isEmpty || UIFont.fontNames(forFamilyName: spec.font).isEmpty ? nil
 18            : UIFont(descriptor: UIFontDescriptor(fontAttributes: [.family: spec.font]), size: size)
 19        font = UIFontMetrics(forTextStyle: .body).scaledFont(for: chosen ?? .monospacedSystemFont(ofSize: size, weight: .regular))
 20    }
 21
 22    /// A color that follows the appearance, or nil when the theme has none for `key`.
 23    func color(_ key: String) -> UIColor? {
 24        // As on the Mac: a key the theme has for one appearance only takes the default for the other.
 25        let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key]
 26        let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key]
 27        guard let light, let dark else { return nil }
 28        return Self.dynamic(light, dark)
 29    }
 30
 31    static func dynamic(_ light: RGBA, _ dark: RGBA) -> UIColor {
 32        UIColor { traits in
 33            let c = traits.userInterfaceStyle == .dark ? dark : light
 34            return UIColor(red: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
 35        }
 36    }
 37
 38    var foreground: UIColor { color("foreground") ?? .label }
 39
 40    /// A TODO keyword's color: its own from `[theme.todo]`, else its class's (`todo-next`,
 41    /// `todo-waiting`, `todo-cancelled`, `todo`, `done`).
 42    func keywordColor(_ keyword: String, done: Bool) -> UIColor? {
 43        if let custom = spec.todo[keyword] { return Self.dynamic(custom, custom) }
 44        return color(ThemeSpec.TodoClass.of(keyword, isDone: done).key)
 45    }
 46
 47    /// A heading's font: larger by the heading step for levels 1 to 3, bold.
 48    func headingFont(_ level: Int) -> UIFont {
 49        let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level))
 50        return Self.adding(.traitBold, to: font.withSize(size))
 51    }
 52
 53    static func adding(_ traits: UIFontDescriptor.SymbolicTraits, to font: UIFont) -> UIFont {
 54        guard let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(traits)) else { return font }
 55        return UIFont(descriptor: descriptor, size: font.pointSize)
 56    }
 57
 58    var base: [NSAttributedString.Key: Any] {
 59        [.font: font, .foregroundColor: foreground, .paragraphStyle: paragraphStyle(firstLine: 0, wrapped: 0)]
 60    }
 61
 62    /// Markup hidden from view: no width, no color.
 63    var hidden: [NSAttributedString.Key: Any] {
 64        [.font: UIFont.monospacedSystemFont(ofSize: 0.01, weight: .regular), .foregroundColor: UIColor.clear]
 65    }
 66
 67    /// Width of one column of the font.
 68    var column: CGFloat {
 69        ("0" as NSString).size(withAttributes: [.font: font]).width
 70    }
 71
 72    func paragraphStyle(firstLine: Int, wrapped: Int) -> NSParagraphStyle {
 73        let style = NSMutableParagraphStyle()
 74        style.firstLineHeadIndent = CGFloat(firstLine) * column
 75        style.headIndent = CGFloat(wrapped) * column
 76        style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
 77        return style
 78    }
 79
 80    func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
 81        func fg(_ key: String) {
 82            if let color = color(key) { storage.addAttribute(.foregroundColor, value: color, range: range) }
 83        }
 84        func traits(_ add: UIFontDescriptor.SymbolicTraits) {
 85            let current = storage.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont ?? font
 86            storage.addAttribute(.font, value: Self.adding(add, to: current), range: range)
 87        }
 88        switch role {
 89        case .heading(let level):
 90            storage.addAttribute(.font, value: headingFont(level), range: range)
 91            fg("heading-\(min(max(level, 1), 8))")
 92        case .leadingStars:
 93            storage.addAttribute(.foregroundColor, value: UIColor.clear, range: range)
 94        case .todo, .done:
 95            traits(.traitBold)
 96            let keyword = (storage.string as NSString).substring(with: range)
 97            if let color = keywordColor(keyword, done: role == .done) { storage.addAttribute(.foregroundColor, value: color, range: range) }
 98        case .priority: fg("priority")
 99        case .tags: fg("tags")
100        case .comment: fg("comment")
101        case .keyword: fg("keyword")
102        case .metadata: fg("metadata")
103        case .bold: traits(.traitBold)
104        case .italic: traits(.traitItalic)
105        case .underline: storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
106        case .strikeThrough: 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: break
119        case .syntax(let category):
120            if let key = category.themeKey { fg(key) }
121        }
122    }
123
124    // MARK: - SwiftUI
125
126    /// A theme color for SwiftUI views.
127    func swiftColor(_ key: String) -> Color? { color(key).map { Color(uiColor: $0) } }
128}
129#endif