krz/orgstar

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

Sources/OrgstarMobile/MobileTheme.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Sources/OrgstarMobile/MobileTheme.swift history · blame · raw

128 lines · 5973 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 the theme's TODO or DONE.
 41    func keywordColor(_ keyword: String, done: Bool) -> UIColor? {
 42        if let custom = spec.todo[keyword] { return Self.dynamic(custom, custom) }
 43        return color(done ? "done" : "todo")
 44    }
 45
 46    /// A heading's font: larger by the heading step for levels 1 to 3, bold.
 47    func headingFont(_ level: Int) -> UIFont {
 48        let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level))
 49        return Self.adding(.traitBold, to: font.withSize(size))
 50    }
 51
 52    static func adding(_ traits: UIFontDescriptor.SymbolicTraits, to font: UIFont) -> UIFont {
 53        guard let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(traits)) else { return font }
 54        return UIFont(descriptor: descriptor, size: font.pointSize)
 55    }
 56
 57    var base: [NSAttributedString.Key: Any] {
 58        [.font: font, .foregroundColor: foreground, .paragraphStyle: paragraphStyle(firstLine: 0, wrapped: 0)]
 59    }
 60
 61    /// Markup hidden from view: no width, no color.
 62    var hidden: [NSAttributedString.Key: Any] {
 63        [.font: UIFont.monospacedSystemFont(ofSize: 0.01, weight: .regular), .foregroundColor: UIColor.clear]
 64    }
 65
 66    /// Width of one column of the font.
 67    var column: CGFloat {
 68        ("0" as NSString).size(withAttributes: [.font: font]).width
 69    }
 70
 71    func paragraphStyle(firstLine: Int, wrapped: Int) -> NSParagraphStyle {
 72        let style = NSMutableParagraphStyle()
 73        style.firstLineHeadIndent = CGFloat(firstLine) * column
 74        style.headIndent = CGFloat(wrapped) * column
 75        style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
 76        return style
 77    }
 78
 79    func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
 80        func fg(_ key: String) {
 81            if let color = color(key) { storage.addAttribute(.foregroundColor, value: color, range: range) }
 82        }
 83        func traits(_ add: UIFontDescriptor.SymbolicTraits) {
 84            let current = storage.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont ?? font
 85            storage.addAttribute(.font, value: Self.adding(add, to: current), range: range)
 86        }
 87        switch role {
 88        case .heading(let level):
 89            storage.addAttribute(.font, value: headingFont(level), range: range)
 90            fg("heading-\(min(max(level, 1), 8))")
 91        case .leadingStars:
 92            storage.addAttribute(.foregroundColor, value: UIColor.clear, range: range)
 93        case .todo, .done:
 94            traits(.traitBold)
 95            let keyword = (storage.string as NSString).substring(with: range)
 96            if let color = keywordColor(keyword, done: role == .done) { storage.addAttribute(.foregroundColor, value: color, range: range) }
 97        case .priority: fg("priority")
 98        case .tags: fg("tags")
 99        case .comment: fg("comment")
100        case .keyword: fg("keyword")
101        case .metadata: fg("metadata")
102        case .bold: traits(.traitBold)
103        case .italic: traits(.traitItalic)
104        case .underline: storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
105        case .strikeThrough: storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range)
106        case .verbatim, .code, .inlineSource:
107            if let background = color("inline-background") { storage.addAttribute(.backgroundColor, value: background, range: range) }
108            fg(role == .verbatim ? "verbatim" : "code")
109        case .markup, .linkMarkup: fg("markup")
110        case .blockDelimiter: fg("block-delimiter")
111        case .link:
112            storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
113            fg("link")
114        case .timestamp: fg("timestamp")
115        case .footnote, .cookie, .target, .macro, .latex: fg("special")
116        case .table, .rule: fg("table")
117        case .block: break
118        case .syntax(let category):
119            if let key = category.themeKey { fg(key) }
120        }
121    }
122
123    // MARK: - SwiftUI
124
125    /// A theme color for SwiftUI views.
126    func swiftColor(_ key: String) -> Color? { color(key).map { Color(uiColor: $0) } }
127}
128#endif