krz/orgstar

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

Commit 4823cf7980

4823cf798010f635eec46420e552c4d95553472b

parent: 8af828ca78

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 07:14 UTC

Theme and display settings in the iOS editor

The editor uses the configured theme (colors, font, size scaled with Dynamic
Type, line spacing, heading step) and applies show-markup with
hide-emphasis-markers and pretty-entities, drawer and block startup folding,
org-indent, hidden leading stars, inline images, table alignment and
truncated lines, with #+STARTUP overriding them as on the Mac. Show Markup
is in the More menu and the command palette; settings changes restyle open
editors.

Layout: unified · split

Sources/OrgstarMobile/EditorScreen.swift +15 −3
@@ -67,6 +67,9 @@ struct EditorScreen: View {
67 Menu("More", systemImage: "ellipsis.circle") { 67 Menu("More", systemImage: "ellipsis.circle") {
68 Button("Commands", systemImage: "command") { sheet = .palette } 68 Button("Commands", systemImage: "command") { sheet = .palette }
69 Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") } 69 Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") }
70 Toggle(isOn: Binding(get: { model.showMarkup }, set: { model.showMarkup = $0 })) {
71 Label("Show Markup", systemImage: "chevron.left.forwardslash.chevron.right")
72 }
70 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) } 73 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) }
71 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies } 74 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
72 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery } 75 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
@@ -118,6 +121,11 @@ struct EditorScreen: View {
118 .onChange(of: session.revealRequest?.id) { 121 .onChange(of: session.revealRequest?.id) {
119 if let offset = session.revealRequest?.offset, session.path == path { editor?.reveal(offset) } 122 if let offset = session.revealRequest?.offset, session.path == path { editor?.reveal(offset) }
120 } 123 }
124 .onChange(of: model.settingsVersion) {
125 editor?.options = session.options
126 editor?.themeSpec = model.theme
127 }
128 .onChange(of: model.showMarkup) { editor?.showMarkup = model.showMarkup }
121 .onDisappear { 129 .onDisappear {
122 // Typing the reader shows next, and a save, don't wait for the autosave delay. 130 // Typing the reader shows next, and a save, don't wait for the autosave delay.
123 if session.dirtyPaths.contains(path) { _ = session.save(path) } 131 if session.dirtyPaths.contains(path) { _ = session.save(path) }
@@ -146,8 +154,10 @@ struct EditorScreen: View {
146 private func start() { 154 private func start() {
147 if session.path != path { session.open(path) } 155 if session.path != path { session.open(path) }
148 if editor == nil, let document = session.document { 156 if editor == nil, let document = session.document {
149 let editor = MobileEditor(document: document, editable: document.isEditable) 157 let editor = MobileEditor(
150 editor.options = session.options 158 document: document, editable: document.isEditable, options: session.options, theme: model.theme,
159 showMarkup: model.showMarkup, baseDirectory: URL(fileURLWithPath: path).deletingLastPathComponent()
160 )
151 editor.knownTags = { [index = workspace.index] in (try? index.tags()) ?? [] } 161 editor.knownTags = { [index = workspace.index] in (try? index.tags()) ?? [] }
152 editor.onChange = { [weak session] in session?.bufferChanged(path) } 162 editor.onChange = { [weak session] in session?.bufferChanged(path) }
153 editor.onMessage = { message = $0 } 163 editor.onMessage = { message = $0 }
@@ -171,6 +181,7 @@ struct EditorScreen: View {
171 case "app.clock.cancel": message = clock.clockOut(session, cancel: true) 181 case "app.clock.cancel": message = clock.clockOut(session, cancel: true)
172 case "app.clock.goto": 182 case "app.clock.goto":
173 if let location = clock.location(session) { model.openRequest = .offset(location.path, location.offset) } else { message = "No active clock" } 183 if let location = clock.location(session) { model.openRequest = .offset(location.path, location.offset) } else { message = "No active clock" }
184 case "app.toggle-markup": model.showMarkup.toggle()
174 default: message = "Not available on iOS yet" 185 default: message = "Not available on iOS yet"
175 } 186 }
176 } 187 }
@@ -401,7 +412,8 @@ struct CommandPalette: View {
401 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), 412 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
402 ("edit.undo", "Undo"), ("edit.redo", "Redo"), 413 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
403 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"), 414 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"),
404 ("app.clock.goto", "Go to Clocked Entry"), 415 ("app.clock.goto", "Go to Clocked Entry"), ("app.toggle-markup", "Show or Hide Markup"),
416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"),
405 ] 417 ]
406 418
407 var body: some View { 419 var body: some View {
Sources/OrgstarMobile/MobileEditor.swift +397 −30
@@ -17,12 +17,56 @@ final class LockedRanges: @unchecked Sendable {
17 func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } } 17 func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } }
18} 18}
19 19
20/// What layout fragments draw beyond the text, read by TextKit's delegate callbacks.
21final class FragmentStyle: @unchecked Sendable {
22 private let lock = NSLock()
23 private var band: (color: UIColor, width: CGFloat) = (.clear, 0)
24
25 func set(blockBackground color: UIColor, width: CGFloat) { lock.withLock { band = (color, width) } }
26 var blockBand: (color: UIColor, width: CGFloat) { lock.withLock { band } }
27}
28
20/// A text view that offers hardware-keyboard presses to the editor's keymap first. 29/// A text view that offers hardware-keyboard presses to the editor's keymap first.
21final class KeyedTextView: UITextView { 30final class KeyedTextView: UITextView {
22 var keyHandler: ((UIKey) -> Bool)? 31 var keyHandler: ((UIKey) -> Bool)?
23 /// Keys the editor took, whose release the text system doesn't see. Optional: made by 32 /// Keys the editor took, whose release the text system doesn't see. Optional: made by
24 /// UIKit's `init(usingTextLayoutManager:)`, the view skips Swift's default values. 33 /// UIKit's `init(usingTextLayoutManager:)`, the view skips Swift's default values.
25 private var handled: Set<UIKeyboardHIDUsage>? 34 private var handled: Set<UIKeyboardHIDUsage>?
35 /// Called after each layout pass, as the view's size settles.
36 var onLayout: (() -> Void)?
37 /// The text container's width while lines are truncated.
38 static let truncatedWidth: CGFloat = 100_000
39
40 /// Long lines run off the right edge and the view scrolls sideways.
41 var truncates: Bool {
42 get { !textContainer.widthTracksTextView }
43 set {
44 textContainer.widthTracksTextView = !newValue
45 if newValue {
46 textContainer.size = CGSize(width: Self.truncatedWidth, height: CGFloat.greatestFiniteMagnitude)
47 } else {
48 textContainer.size = CGSize(width: bounds.width - textContainerInset.left - textContainerInset.right, height: CGFloat.greatestFiniteMagnitude)
49 contentOffset.x = 0
50 }
51 showsHorizontalScrollIndicator = newValue
52 setNeedsLayout()
53 }
54 }
55
56 override func layoutSubviews() {
57 // The text view sets its container's width to its own when it lays out.
58 if truncates, textContainer.size.width != Self.truncatedWidth {
59 textContainer.size = CGSize(width: Self.truncatedWidth, height: CGFloat.greatestFiniteMagnitude)
60 }
61 super.layoutSubviews()
62 // The text view sizes its content to its own width; truncated lines need the widest
63 // laid-out line's.
64 if truncates, let manager = textLayoutManager {
65 let width = ceil(manager.usageBoundsForTextContainer.maxX + textContainerInset.left + textContainerInset.right)
66 if contentSize.width < width { contentSize.width = width }
67 }
68 onLayout?()
69 }
26 70
27 override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) { 71 override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
28 var rest: Set<UIPress> = [] 72 var rest: Set<UIPress> = []
@@ -55,7 +99,29 @@ final class MobileEditor: NSObject, DocumentBuffer {
55 private(set) var document: DocumentState 99 private(set) var document: DocumentState
56 let textView: UITextView 100 let textView: UITextView
57 var options: EditingOptions = .org { 101 var options: EditingOptions = .org {
58 didSet { textView.spellCheckingType = options.spellCheck ? .yes : .no } 102 didSet {
103 textView.spellCheckingType = options.spellCheck ? .yes : .no
104 if !showMarkup, options.hideEmphasisMarkers != oldValue.hideEmphasisMarkers || options.prettyEntities != oldValue.prettyEntities {
105 restyleAll()
106 }
107 }
108 }
109 /// Colors and type; a change, or a new Dynamic Type size, restyles the file.
110 var themeSpec: ThemeSpec {
111 get { theme.spec }
112 set {
113 let new = MobileTheme(newValue)
114 guard new.spec != theme.spec || new.font != theme.font else { return }
115 theme = new
116 paragraphStyles = [:]
117 applyThemeToView()
118 restyleAll()
119 }
120 }
121 /// Link brackets and targets, and emphasis markers when `org-hide-emphasis-markers` hides
122 /// them, shown. Hidden markup shows on the caret's line, so it can be edited.
123 var showMarkup = true {
124 didSet { if showMarkup != oldValue { restyleAll() } }
59 } 125 }
60 var keymap = KeymapPreset.emacs.keymap 126 var keymap = KeymapPreset.emacs.keymap
61 var onChange: (() -> Void)? 127 var onChange: (() -> Void)?
@@ -79,19 +145,44 @@ final class MobileEditor: NSObject, DocumentBuffer {
79 /// Tags used across the workspace, for completion. 145 /// Tags used across the workspace, for completion.
80 var knownTags: @Sendable () -> [String] = { [] } 146 var knownTags: @Sendable () -> [String] = { [] }
81 147
82 private let theme = MobileTheme() 148 private var theme: MobileTheme
83 private(set) var fold = FoldState() 149 private(set) var fold = FoldState()
84 private var globalVisibility: GlobalVisibility? 150 private var globalVisibility: GlobalVisibility?
85 let hidden = LockedRanges() 151 let hidden = LockedRanges()
152 /// Blocks drawn on a band.
153 let shaded = LockedRanges()
154 let fragmentStyle = FragmentStyle()
155 /// `org-indent-mode` and `org-hide-leading-stars`, from the settings and `#+STARTUP`.
156 private var indented = true
157 private var hideStars = false
158 /// One paragraph style per indent pair.
159 private var paragraphStyles: [[Int]: NSParagraphStyle] = [:]
160 /// The line last revealed while markup is hidden, to hide again when the caret leaves.
161 private var revealedLine: NSRange?
162 /// `org-toggle-inline-images`: lines that are only an image link show the image.
163 private(set) var showsImages = false
164 /// The view width images were last sized for.
165 private var imageWidth: CGFloat = 0
166 /// The file's folder, for relative image paths.
167 let baseDirectory: URL?
168 private static let imageCache = NSCache<NSString, UIImage>()
86 private var loading = false 169 private var loading = false
87 private var restyleScheduled: Range<Int>? 170 private var restyleScheduled: Range<Int>?
88 171
89 init(document: DocumentState, editable: Bool) { 172 init(
173 document: DocumentState, editable: Bool, options: EditingOptions = .org, theme spec: ThemeSpec = .default,
174 showMarkup: Bool = true, baseDirectory: URL? = nil
175 ) {
90 self.document = document 176 self.document = document
177 self.options = options
178 self.showMarkup = showMarkup
179 self.baseDirectory = baseDirectory
180 theme = MobileTheme(spec)
91 let keyed = KeyedTextView(usingTextLayoutManager: true) 181 let keyed = KeyedTextView(usingTextLayoutManager: true)
92 textView = keyed 182 textView = keyed
93 super.init() 183 super.init()
94 keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false } 184 keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false }
185 keyed.onLayout = { [weak self] in self?.viewResized() }
95 textView.isEditable = editable 186 textView.isEditable = editable
96 textView.autocorrectionType = .no 187 textView.autocorrectionType = .no
97 textView.autocapitalizationType = .none 188 textView.autocapitalizationType = .none
@@ -106,11 +197,15 @@ final class MobileEditor: NSObject, DocumentBuffer {
106 textView.textStorage.delegate = self 197 textView.textStorage.delegate = self
107 textView.textLayoutManager?.delegate = self 198 textView.textLayoutManager?.delegate = self
108 (textView.textLayoutManager?.textContentManager as? NSTextContentStorage)?.delegate = self 199 (textView.textLayoutManager?.textContentManager as? NSTextContentStorage)?.delegate = self
200 let startup = Presentation.startupDisplay(document.tree, options: options)
201 indented = startup.indented
202 hideStars = startup.hideStars
203 showsImages = startup.inlineImages
204 applyThemeToView()
109 load(document.text) 205 load(document.text)
110 let tree = document.tree 206 setFolds(startup.folds)
111 var state = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree) 207 if startup.alignTables { _ = perform(AlignAllTables()) }
112 state.wrapperFolds = Presentation.startupWrapperFolds(tree) 208 truncatesLines = startup.truncated
113 setFolds(state)
114 textView.selectedRange = NSRange(location: 0, length: 0) 209 textView.selectedRange = NSRange(location: 0, length: 0)
115 } 210 }
116 211
@@ -215,6 +310,10 @@ final class MobileEditor: NSObject, DocumentBuffer {
215 if !cycleAtCaret() { onMessage?("Not on a heading, drawer or block") } 310 if !cycleAtCaret() { onMessage?("Not on a heading, drawer or block") }
216 case "org.cycle-global": cycleGlobal() 311 case "org.cycle-global": cycleGlobal()
217 case "org.link.open": openLinkAtCaret() 312 case "org.link.open": openLinkAtCaret()
313 case "org.toggle-inline-images": toggleImages()
314 case "editor.toggle-truncate-lines":
315 truncatesLines.toggle()
316 onMessage?(truncatesLines ? "Truncate long lines enabled" : "Truncate long lines disabled")
218 case "edit.undo": textView.undoManager?.undo() 317 case "edit.undo": textView.undoManager?.undo()
219 case "edit.redo": textView.undoManager?.redo() 318 case "edit.redo": textView.undoManager?.redo()
220 case _ where id.hasPrefix("app."): 319 case _ where id.hasPrefix("app."):
@@ -233,8 +332,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
233 switch id { 332 switch id {
234 case "org.cycle": 333 case "org.cycle":
235 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil 334 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
236 case "org.cycle-global", "edit.undo", "edit.redo": return true 335 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true
237 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto": return onAppCommand != nil 336 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.toggle-markup": return onAppCommand != nil
238 case "org.link.open": return OpenLinkAtPoint().applies(in: context) 337 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
239 default: return false 338 default: return false
240 } 339 }
@@ -382,10 +481,38 @@ final class MobileEditor: NSObject, DocumentBuffer {
382 loading = true 481 loading = true
383 textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base)) 482 textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
384 loading = false 483 loading = false
484 shaded.set(Presentation.shadedBlocks(document.tree), folded: [])
385 restyle(0..<(text as NSString).length) 485 restyle(0..<(text as NSString).length)
386 textView.typingAttributes = theme.base 486 textView.typingAttributes = theme.base
387 } 487 }
388 488
489 /// The text view's own colors, and the block band's.
490 private func applyThemeToView() {
491 textView.backgroundColor = theme.color("background") ?? .systemBackground
492 textView.tintColor = theme.color("cursor")
493 textView.typingAttributes = theme.base
494 fragmentStyle.set(blockBackground: theme.color("block-background") ?? .clear, width: textView.bounds.width)
495 }
496
497 /// The band behind blocks spans the view's width, or the widest line's when truncated;
498 /// images are as wide as the view allows.
499 private func viewResized() {
500 let band = fragmentStyle.blockBand
501 let width = max(textView.bounds.width, truncatesLines ? textView.contentSize.width : 0)
502 let resized = imageWidth != textView.bounds.width
503 guard band.width != width || resized else { return }
504 fragmentStyle.set(blockBackground: band.color, width: width)
505 imageWidth = textView.bounds.width
506 if showsImages, resized { restyle(0..<textView.textStorage.length) }
507 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
508 }
509
510 /// Styles the whole file again.
511 private func restyleAll() {
512 restyle(0..<textView.textStorage.length)
513 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
514 }
515
389 /// Styles are set after the edit has been processed, as TextKit asks. 516 /// Styles are set after the edit has been processed, as TextKit asks.
390 private func scheduleRestyle(_ range: Range<Int>) { 517 private func scheduleRestyle(_ range: Range<Int>) {
391 if let pending = restyleScheduled { 518 if let pending = restyleScheduled {
@@ -396,6 +523,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
396 DispatchQueue.main.async { [weak self] in 523 DispatchQueue.main.async { [weak self] in
397 guard let self, let range = self.restyleScheduled else { return } 524 guard let self, let range = self.restyleScheduled else { return }
398 self.restyleScheduled = nil 525 self.restyleScheduled = nil
526 self.shaded.set(Presentation.shadedBlocks(self.document.tree), folded: [])
399 // Small files are restyled whole: a heading or block line changes what follows it. 527 // Small files are restyled whole: a heading or block line changes what follows it.
400 let length = self.textView.textStorage.length 528 let length = self.textView.textStorage.length
401 self.restyle(length < 60_000 ? 0..<length : range) 529 self.restyle(length < 60_000 ? 0..<length : range)
@@ -404,21 +532,171 @@ final class MobileEditor: NSObject, DocumentBuffer {
404 } 532 }
405 } 533 }
406 534
535 private var caretLine: NSRange {
536 let text = textView.textStorage.string as NSString
537 return text.lineRange(for: NSRange(location: min(textView.selectedRange.location, text.length), length: 0))
538 }
539
540 /// Sets attributes for the whole lines in `range`, as the Mac editor does.
407 private func restyle(_ range: Range<Int>) { 541 private func restyle(_ range: Range<Int>) {
408 let storage = textView.textStorage 542 let storage = textView.textStorage
409 let ns = storage.string as NSString 543 let ns = storage.string as NSString
410 guard ns.length > 0 else { return } 544 guard ns.length > 0 else { return }
411 let lower = min(range.lowerBound, ns.length) 545 let lower = min(range.lowerBound, ns.length)
412 let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower))) 546 let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower)))
547 let whole = lines.location..<NSMaxRange(lines)
413 storage.beginEditing() 548 storage.beginEditing()
414 storage.setAttributes(theme.base, range: lines) 549 storage.setAttributes(theme.base, range: lines)
415 for run in Presentation.styles(document.tree, in: lines.location..<NSMaxRange(lines)) { 550 if indented {
416 let clipped = NSIntersectionRange(NSRange(location: run.range.lowerBound, length: run.range.count), lines) 551 for line in Presentation.lineIndents(document.tree, in: whole) {
417 if clipped.length > 0 { theme.apply(run.role, to: storage, range: clipped) } 552 storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
553 }
418 } 554 }
555 let revealed = caretLine
556 var scriptExclusions: [Range<Int>] = []
557 for run in Presentation.styles(document.tree, in: whole) {
558 let clipped = NSIntersectionRange(NSRange(run.range), lines)
559 guard clipped.length > 0 else { continue }
560 if Presentation.skipsLeadingStars(run.role, indented: indented, hideStars: hideStars) { continue }
561 if !showMarkup, Presentation.hidesWithMarkup(run.role, options: options), NSIntersectionRange(clipped, revealed).length == 0 {
562 storage.addAttributes(theme.hidden, range: clipped)
563 } else {
564 theme.apply(run.role, to: storage, range: clipped)
565 }
566 if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) }
567 }
568 if !showMarkup, options.prettyEntities { prettify(whole, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
569 applyImages(whole, in: storage, revealed: revealed)
419 storage.endEditing() 570 storage.endEditing()
420 } 571 }
421 572
573 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
574 if let style = paragraphStyles[[firstLine, wrapped]] { return style }
575 let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
576 paragraphStyles[[firstLine, wrapped]] = style
577 return style
578 }
579
580 /// `org-pretty-entities`: entities drawn as their character over the entity's last
581 /// character (the rest hidden), sub/superscripts raised without their markers.
582 private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) {
583 let runs = Presentation.prettyRuns(
584 storage.string as NSString, in: range, revealedLine: revealed.location, inBlock: { shaded.containing($0) != nil },
585 scriptExclusions: scriptExclusions, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces
586 )
587 for (lineStart, run) in runs {
588 switch run {
589 case .entity(let r, let display):
590 let start = lineStart + r.lowerBound
591 let last = lineStart + r.upperBound - 1
592 let color = storage.attribute(.foregroundColor, at: last, effectiveRange: nil) as? UIColor ?? theme.foreground
593 if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
594 storage.addAttributes([.foregroundColor: UIColor.clear, .orgEntityDisplay: display, .orgEntityColor: color], range: NSRange(location: last, length: 1))
595 case .script(let markers, let body, let superscript):
596 for marker in markers {
597 storage.addAttributes(theme.hidden, range: NSRange(location: lineStart + marker.lowerBound, length: marker.count))
598 }
599 let size = theme.font.pointSize
600 storage.addAttributes([
601 .font: theme.font.withSize(size * 0.7),
602 .baselineOffset: (superscript ? 0.3 : -0.3) * size,
603 ], range: NSRange(location: lineStart + body.lowerBound, length: body.count))
604 }
605 }
606 }
607
608 /// Moving to another line shows its markup and hides the last one's.
609 private func revealCaretLine() {
610 guard !showMarkup else { return }
611 let line = caretLine
612 guard line != revealedLine else { return }
613 let text = textView.textStorage.string as NSString
614 // The old line may have moved with edits since; restyle the line now at its start.
615 let old = revealedLine.flatMap { $0.location <= text.length ? text.lineRange(for: NSRange(location: $0.location, length: 0)) : nil }
616 revealedLine = line
617 for range in [old, line].compactMap({ $0 }) {
618 restyle(range.location..<NSMaxRange(range))
619 }
620 }
621
622 // MARK: - Inline images
623
624 private func toggleImages() {
625 showsImages.toggle()
626 restyleAll()
627 if showsImages {
628 let ns = textView.textStorage.string as NSString
629 var count = 0
630 var offset = 0
631 while offset < ns.length {
632 let line = ns.lineRange(for: NSRange(location: offset, length: 0))
633 if let image = InlineImages.image(atLine: line.location, in: ns), loadImage(image.path) != nil { count += 1 }
634 offset = NSMaxRange(line)
635 }
636 onMessage?("\(count) images displayed inline")
637 } else {
638 onMessage?("Inline image display turned off")
639 }
640 }
641
642 private func loadImage(_ path: String) -> UIImage? {
643 let expanded = (path as NSString).expandingTildeInPath
644 let url: URL
645 if expanded.hasPrefix("/") {
646 url = URL(fileURLWithPath: expanded)
647 } else if let baseDirectory {
648 url = baseDirectory.appendingPathComponent(expanded)
649 } else {
650 return nil
651 }
652 let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate?.timeIntervalSince1970 ?? 0
653 let key = "\(url.path)|\(modified)" as NSString
654 if let cached = Self.imageCache.object(forKey: key) { return cached }
655 guard let image = UIImage(contentsOfFile: url.path) else { return nil }
656 Self.imageCache.setObject(image, forKey: key)
657 return image
658 }
659
660 /// Image lines in `range` show the image over the link, which is hidden unless the caret is
661 /// on the line.
662 private func applyImages(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange) {
663 guard showsImages else { return }
664 let text = storage.string as NSString
665 let inset = textView.textContainerInset
666 let maxWidth = max(80, textView.bounds.width - inset.left - inset.right - 40)
667 var location = range.lowerBound
668 while location < range.upperBound, location < text.length {
669 let line = text.lineRange(for: NSRange(location: location, length: 0))
670 location = NSMaxRange(line)
671 guard let image = InlineImages.image(atLine: line.location, in: text), let picture = loadImage(image.path) else { continue }
672 let width = min(image.width.map(CGFloat.init) ?? picture.size.width, maxWidth)
673 let height = picture.size.width > 0 ? width * picture.size.height / picture.size.width : 0
674 let content = NSRange(location: line.location, length: line.length - (text.substring(with: line).hasSuffix("\n") ? 1 : 0))
675 // Invisible, keeping the line's height.
676 if line.location != revealed.location, content.length > 0 {
677 storage.addAttributes([.foregroundColor: UIColor.clear, .underlineStyle: 0], range: content)
678 }
679 storage.addAttribute(.orgImage, value: InlineImageBox(image: picture, size: CGSize(width: width, height: height)), range: NSRange(location: line.location, length: max(1, content.length)))
680 let style = (storage.attribute(.paragraphStyle, at: line.location, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
681 // The image sits where the line is; the spacing makes room for the rest of it.
682 style.paragraphSpacing = max(0, height + 4 - theme.font.lineHeight)
683 storage.addAttribute(.paragraphStyle, value: style, range: line)
684 }
685 }
686
687 // MARK: - Truncation
688
689 /// `toggle-truncate-lines`: long lines run off the right edge, scrolling sideways, instead
690 /// of wrapping.
691 var truncatesLines: Bool {
692 get { (textView as? KeyedTextView)?.truncates ?? false }
693 set {
694 guard newValue != truncatesLines, let view = textView as? KeyedTextView else { return }
695 view.truncates = newValue
696 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
697 }
698 }
699
422 // MARK: - Folding 700 // MARK: - Folding
423 701
424 func setFolds(_ state: FoldState) { 702 func setFolds(_ state: FoldState) {
@@ -478,6 +756,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
478 fileprivate func selectionChanged() { 756 fileprivate func selectionChanged() {
479 let caret = textView.selectedRange.location 757 let caret = textView.selectedRange.location
480 if hidden.containing(caret) != nil { unfold(around: caret) } 758 if hidden.containing(caret) != nil { unfold(around: caret) }
759 revealCaretLine()
481 } 760 }
482} 761}
483 762
@@ -530,36 +809,124 @@ extension MobileEditor: NSTextContentStorageDelegate {
530} 809}
531 810
532extension MobileEditor: NSTextLayoutManagerDelegate { 811extension MobileEditor: NSTextLayoutManagerDelegate {
533 /// Folded headings, drawers and blocks end with `…`. 812 /// Folded headings, drawers and blocks end with `…`; blocks are on a band; image lines
813 /// show their image; pretty entities draw their character.
534 nonisolated func textLayoutManager( 814 nonisolated func textLayoutManager(
535 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement 815 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
536 ) -> NSTextLayoutFragment { 816 ) -> NSTextLayoutFragment {
537 let range = textElement.elementRange 817 let range = textElement.elementRange
538 if let range, let manager = textLayoutManager.textContentManager { 818 guard let range, let manager = textLayoutManager.textContentManager else { return NSTextLayoutFragment(textElement: textElement, range: range) }
539 let offset = manager.offset(from: manager.documentRange.location, to: range.location) 819 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
540 if hidden.isFolded(offset) { return FoldedLineFragment(textElement: textElement, range: range) } 820 let folded = hidden.isFolded(offset)
541 } 821 let band = shaded.containing(offset) != nil ? fragmentStyle.blockBand : nil
542 return NSTextLayoutFragment(textElement: textElement, range: range) 822 let string = (textElement as? NSTextParagraph)?.attributedString
823 let image = string.flatMap(OrgLineFragment.image)
824 let entities = string.map(OrgLineFragment.hasEntities) ?? false
825 guard folded || band != nil || image != nil || entities else { return NSTextLayoutFragment(textElement: textElement, range: range) }
826 let fragment = OrgLineFragment(textElement: textElement, range: range)
827 fragment.folded = folded
828 fragment.band = band
829 fragment.image = image
830 return fragment
543 } 831 }
544} 832}
545 833
546/// A folded line drawn with an ellipsis after it, like org's folded headings. 834extension NSAttributedString.Key {
547final class FoldedLineFragment: NSTextLayoutFragment, @unchecked Sendable { 835 /// The character an entity shows as, on the entity's last character.
836 static let orgEntityDisplay = NSAttributedString.Key("OrgEntityDisplay")
837 /// The color the entity's character is drawn in.
838 static let orgEntityColor = NSAttributedString.Key("OrgEntityColor")
839 /// The image an image link's line shows under it.
840 static let orgImage = NSAttributedString.Key("OrgImage")
841}
842
843final class InlineImageBox: NSObject, @unchecked Sendable {
844 let image: UIImage
845 let size: CGSize
846 init(image: UIImage, size: CGSize) {
847 self.image = image
848 self.size = size
849 }
850}
851
852/// A line drawn with more than its text, as the Mac editor's fragments draw them: a folded
853/// line's ellipsis, a block's band across the view, an image under an image link, and pretty
854/// entities' characters where the entities' last (invisible) characters sit.
855final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
856 var folded = false
857 var band: (color: UIColor, width: CGFloat)?
858 var image: InlineImageBox?
859
860 static func image(_ string: NSAttributedString) -> InlineImageBox? {
861 var found: InlineImageBox?
862 string.enumerateAttribute(.orgImage, in: NSRange(location: 0, length: string.length)) { value, _, stop in
863 if let box = value as? InlineImageBox { found = box; stop.pointee = true }
864 }
865 return found
866 }
867
868 static func hasEntities(_ string: NSAttributedString) -> Bool {
869 var found = false
870 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, _, stop in
871 if value != nil { found = true; stop.pointee = true }
872 }
873 return found
874 }
875
876 private var bandRect: CGRect? {
877 guard let band else { return nil }
878 // Not under the empty line TextKit adds after a final newline.
879 let height = textLineFragments.last { $0.characterRange.length > 0 }?.typographicBounds.maxY ?? layoutFragmentFrame.height
880 return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: max(band.width, layoutFragmentFrame.maxX), height: height)
881 }
882
883 private var imageRect: CGRect? {
884 guard let image, let line = textLineFragments.first else { return nil }
885 return CGRect(x: line.typographicBounds.minX, y: line.typographicBounds.minY + 2, width: image.size.width, height: image.size.height)
886 }
887
888 override var renderingSurfaceBounds: CGRect {
889 var bounds = super.renderingSurfaceBounds
890 if folded { bounds = bounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0) }
891 for rect in [bandRect, imageRect].compactMap({ $0 }) { bounds = bounds.union(rect) }
892 return bounds
893 }
894
548 override func draw(at point: CGPoint, in context: CGContext) { 895 override func draw(at point: CGPoint, in context: CGContext) {
896 if let band, let rect = bandRect {
897 context.saveGState()
898 context.setFillColor(band.color.cgColor)
899 context.fill(rect.offsetBy(dx: point.x, dy: point.y))
900 context.restoreGState()
901 }
549 super.draw(at: point, in: context) 902 super.draw(at: point, in: context)
550 guard let line = textLineFragments.first(where: { $0.characterRange.length > 0 }) ?? textLineFragments.first else { return }
551 let bounds = line.typographicBounds
552 let ellipsis = NSAttributedString(string: " …", attributes: [
553 .font: UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular),
554 .foregroundColor: UIColor.secondaryLabel,
555 ])
556 UIGraphicsPushContext(context) 903 UIGraphicsPushContext(context)
557 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY)) 904 defer { UIGraphicsPopContext() }
558 UIGraphicsPopContext() 905 drawEntities(at: point)
906 if let image, let rect = imageRect { image.image.draw(in: rect.offsetBy(dx: point.x, dy: point.y)) }
907 if folded, let line = textLineFragments.first(where: { $0.characterRange.length > 0 }) ?? textLineFragments.first {
908 let bounds = line.typographicBounds
909 let ellipsis = NSAttributedString(string: " …", attributes: [
910 .font: UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular),
911 .foregroundColor: UIColor.secondaryLabel,
912 ])
913 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
914 }
559 } 915 }
560 916
561 override var renderingSurfaceBounds: CGRect { 917 private func drawEntities(at point: CGPoint) {
562 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0) 918 guard let string = (textElement as? NSTextParagraph)?.attributedString else { return }
919 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in
920 guard let display = value as? String,
921 let line = textLineFragments.first(where: { NSLocationInRange(range.location, $0.characterRange) }) else { return }
922 var attributes = string.attributes(at: range.location, effectiveRange: nil)
923 attributes[.foregroundColor] = attributes[.orgEntityColor] ?? UIColor.label
924 attributes[.orgEntityDisplay] = nil
925 attributes[.orgEntityColor] = nil
926 let x = line.locationForCharacter(at: range.location).x
927 let bounds = line.typographicBounds
928 NSAttributedString(string: display, attributes: attributes).draw(at: CGPoint(x: point.x + bounds.minX + x, y: point.y + bounds.minY))
929 }
563 } 930 }
564} 931}
565#endif 932#endif
Sources/OrgstarMobile/MobileModel.swift +41 −11
@@ -4,7 +4,9 @@ import Observation
4import OrgApp 4import OrgApp
5import OrgCore 5import OrgCore
6import OrgIndex 6import OrgIndex
7import OrgPresentation
7import OrgWorkspace 8import OrgWorkspace
9import UIKit
8 10
9/// The app's models, and the settings from the configuration folder: a folder you choose 11/// The app's models, and the settings from the configuration folder: a folder you choose
10/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`. 12/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`.
@@ -36,7 +38,13 @@ final class MobileModel {
36 } 38 }
37 /// Bumped when settings apply, so views that read them update. 39 /// Bumped when settings apply, so views that read them update.
38 private(set) var settingsVersion = 0 40 private(set) var settingsVersion = 0
41 /// The theme from `config.toml` and its theme file, with the type settings.
42 private(set) var theme = ThemeSpec.default
43 /// `show-markup`, toggled in the editor; the folder's value applies when it changes.
44 var showMarkup = false
45 @ObservationIgnored private var configShowMarkup: Bool?
39 @ObservationIgnored private var presenter: FolderPresenter? 46 @ObservationIgnored private var presenter: FolderPresenter?
47 @ObservationIgnored private var sizeObserver: NSObjectProtocol?
40 48
41 private static let bookmarkKey = "configFolderBookmark" 49 private static let bookmarkKey = "configFolderBookmark"
42 50
@@ -44,7 +52,7 @@ final class MobileModel {
44 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder. 52 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder.
45 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) } 53 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) }
46 UserConfig.directoryOverride = folder 54 UserConfig.directoryOverride = folder
47 let problems = Self.readConfig(folder) 55 let (problems, colors) = Self.readConfig(folder)
48 let settings = OrgPreferences.orgSettings() 56 let settings = OrgPreferences.orgSettings()
49 do { 57 do {
50 let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings)) 58 let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings))
@@ -58,9 +66,13 @@ final class MobileModel {
58 } 66 }
59 configFolder = folder 67 configFolder = folder
60 configProblems = problems 68 configProblems = problems
61 applySettings() 69 applySettings(colors)
62 shareTemplates() 70 shareTemplates()
63 watchConfig() 71 watchConfig()
72 // A new Dynamic Type size restyles editors and the reader.
73 sizeObserver = NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: .main) { [weak self] _ in
74 MainActor.assumeIsolated { self?.settingsVersion += 1 }
75 }
64 } 76 }
65 77
66 /// Chooses the configuration folder, or with nil stops using one; its settings apply now. 78 /// Chooses the configuration folder, or with nil stops using one; its settings apply now.
@@ -80,12 +92,19 @@ final class MobileModel {
80 92
81 func reloadConfig() { 93 func reloadConfig() {
82 UserConfig.directoryOverride = configFolder 94 UserConfig.directoryOverride = configFolder
83 configProblems = Self.readConfig(configFolder) 95 let (problems, colors) = Self.readConfig(configFolder)
84 applySettings() 96 configProblems = problems
97 applySettings(colors)
85 shareTemplates() 98 shareTemplates()
86 } 99 }
87 100
88 private func applySettings() { 101 private func applySettings(_ colors: ThemeSpec) {
102 theme = OrgPreferences.theme(colors)
103 let markup = OrgPreferences.showMarkup()
104 if markup != configShowMarkup {
105 configShowMarkup = markup
106 showMarkup = markup
107 }
89 let settings = OrgPreferences.orgSettings() 108 let settings = OrgPreferences.orgSettings()
90 session.defaults = settings 109 session.defaults = settings
91 session.options = OrgPreferences.options() 110 session.options = OrgPreferences.options()
@@ -124,17 +143,28 @@ final class MobileModel {
124 return url 143 return url
125 } 144 }
126 145
127 /// Applies the folder's `config.toml` to the settings; returns its problems. 146 /// Applies the folder's `config.toml` to the settings; returns its problems and the theme's
128 private static func readConfig(_ folder: URL?) -> [String] { 147 /// colors.
129 guard let folder else { return [] } 148 private static func readConfig(_ folder: URL?) -> ([String], ThemeSpec) {
130 let file = folder.appendingPathComponent("config.toml") 149 guard let folder else { return ([], .default) }
150 guard let text = read(folder.appendingPathComponent("config.toml")) else {
151 return (["No config.toml in \(folder.lastPathComponent); the defaults apply."], .default)
152 }
153 let problems = UserConfig.apply(text, to: .standard)
154 guard let theme = UserConfig.theme(config: text, themeFile: UserDefaults.standard.string(forKey: "themeFile"), in: folder, read: read) else {
155 return (problems, .default)
156 }
157 return (problems + theme.problems, theme.theme)
158 }
159
160 /// A file of the folder, read in coordination with the app that syncs it.
161 private static func read(_ file: URL) -> String? {
131 var text: String? 162 var text: String?
132 var error: NSError? 163 var error: NSError?
133 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in 164 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in
134 text = try? String(contentsOf: url, encoding: .utf8) 165 text = try? String(contentsOf: url, encoding: .utf8)
135 } 166 }
136 guard let text else { return ["No config.toml in \(folder.lastPathComponent); the defaults apply."] } 167 return text
137 return UserConfig.apply(text, to: .standard)
138 } 168 }
139 169
140 /// Notifications for the next week's timed entries, at most 64, as iOS allows. 170 /// Notifications for the next week's timed entries, at most 64, as iOS allows.
Sources/OrgstarMobile/MobileTheme.swift +54 −15
@@ -1,5 +1,6 @@
1#if os(iOS) 1#if os(iOS)
2import OrgPresentation 2import OrgPresentation
3import SwiftUI
3import UIKit 4import UIKit
4 5
5/// Attributes for each style role, from a `ThemeSpec`, as the Mac editor's theme sets them. 6/// Attributes for each style role, from a `ThemeSpec`, as the Mac editor's theme sets them.
@@ -10,12 +11,17 @@ struct MobileTheme {
10 11
11 init(_ spec: ThemeSpec = .default) { 12 init(_ spec: ThemeSpec = .default) {
12 self.spec = spec 13 self.spec = spec
13 // Dynamic Type, monospaced as on the Mac. 14 let size = CGFloat(max(6, spec.fontSize))
14 font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .monospacedSystemFont(ofSize: 15, weight: .regular)) 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))
15 } 20 }
16 21
17 /// A color that follows the appearance, or nil when the theme has none for `key`. 22 /// A color that follows the appearance, or nil when the theme has none for `key`.
18 func color(_ key: String) -> UIColor? { 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.
19 let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key] 25 let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key]
20 let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key] 26 let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key]
21 guard let light, let dark else { return nil } 27 guard let light, let dark else { return nil }
@@ -29,10 +35,45 @@ struct MobileTheme {
29 } 35 }
30 } 36 }
31 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
32 var base: [NSAttributedString.Key: Any] { 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 {
33 let style = NSMutableParagraphStyle() 72 let style = NSMutableParagraphStyle()
73 style.firstLineHeadIndent = CGFloat(firstLine) * column
74 style.headIndent = CGFloat(wrapped) * column
34 style.lineSpacing = CGFloat(max(0, spec.lineSpacing)) 75 style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
35 return [.font: font, .foregroundColor: color("foreground") ?? .label, .paragraphStyle: style] 76 return style
36 } 77 }
37 78
38 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) { 79 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
@@ -41,25 +82,18 @@ struct MobileTheme {
41 } 82 }
42 func traits(_ add: UIFontDescriptor.SymbolicTraits) { 83 func traits(_ add: UIFontDescriptor.SymbolicTraits) {
43 let current = storage.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont ?? font 84 let current = storage.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont ?? font
44 if let descriptor = current.fontDescriptor.withSymbolicTraits(current.fontDescriptor.symbolicTraits.union(add)) { 85 storage.addAttribute(.font, value: Self.adding(add, to: current), range: range)
45 storage.addAttribute(.font, value: UIFont(descriptor: descriptor, size: current.pointSize), range: range)
46 }
47 } 86 }
48 switch role { 87 switch role {
49 case .heading(let level): 88 case .heading(let level):
50 let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level)) 89 storage.addAttribute(.font, value: headingFont(level), range: range)
51 storage.addAttribute(.font, value: font.withSize(size), range: range)
52 traits(.traitBold)
53 fg("heading-\(min(max(level, 1), 8))") 90 fg("heading-\(min(max(level, 1), 8))")
54 case .leadingStars: fg("markup") 91 case .leadingStars:
92 storage.addAttribute(.foregroundColor, value: UIColor.clear, range: range)
55 case .todo, .done: 93 case .todo, .done:
56 traits(.traitBold) 94 traits(.traitBold)
57 let keyword = (storage.string as NSString).substring(with: range) 95 let keyword = (storage.string as NSString).substring(with: range)
58 if let custom = spec.todo[keyword] { 96 if let color = keywordColor(keyword, done: role == .done) { storage.addAttribute(.foregroundColor, value: color, range: range) }
59 storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range)
60 } else {
61 fg(role == .todo ? "todo" : "done")
62 }
63 case .priority: fg("priority") 97 case .priority: fg("priority")
64 case .tags: fg("tags") 98 case .tags: fg("tags")
65 case .comment: fg("comment") 99 case .comment: fg("comment")
@@ -84,5 +118,10 @@ struct MobileTheme {
84 case .syntax: break 118 case .syntax: break
85 } 119 }
86 } 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) } }
87} 126}
88#endif 127#endif