Sources/OrgstarMobile/MobileTheme.swift
127 lines · 5910 bytes
11 symbols in this file
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: break
119 }
120 }
121
122 // MARK: - SwiftUI
123
124 /// A theme color for SwiftUI views.
125 func swiftColor(_ key: String) -> Color? { color(key).map { Color(uiColor: $0) } }
126}
127#endif