Sources/OrgstarMobile/MobileTheme.swift
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