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 {
6767 Menu("More", systemImage: "ellipsis.circle") {
6868 Button("Commands", systemImage: "command") { sheet = .palette }
6969 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 }
7073 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) }
7174 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
7275 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
@@ -118,6 +121,11 @@ struct EditorScreen: View {
118121 .onChange(of: session.revealRequest?.id) {
119122 if let offset = session.revealRequest?.offset, session.path == path { editor?.reveal(offset) }
120123 }
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 }
121129 .onDisappear {
122130 // Typing the reader shows next, and a save, don't wait for the autosave delay.
123131 if session.dirtyPaths.contains(path) { _ = session.save(path) }
@@ -146,8 +154,10 @@ struct EditorScreen: View {
146154 private func start() {
147155 if session.path != path { session.open(path) }
148156 if editor == nil, let document = session.document {
149 let editor = MobileEditor(document: document, editable: document.isEditable)
150 editor.options = session.options
157 let editor = MobileEditor(
158 document: document, editable: document.isEditable, options: session.options, theme: model.theme,
159 showMarkup: model.showMarkup, baseDirectory: URL(fileURLWithPath: path).deletingLastPathComponent()
160 )
151161 editor.knownTags = { [index = workspace.index] in (try? index.tags()) ?? [] }
152162 editor.onChange = { [weak session] in session?.bufferChanged(path) }
153163 editor.onMessage = { message = $0 }
@@ -171,6 +181,7 @@ struct EditorScreen: View {
171181 case "app.clock.cancel": message = clock.clockOut(session, cancel: true)
172182 case "app.clock.goto":
173183 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()
174185 default: message = "Not available on iOS yet"
175186 }
176187 }
@@ -401,7 +412,8 @@ struct CommandPalette: View {
401412 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
402413 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
403414 ("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"),
405417 ]
406418
407419 var body: some View {
Sources/OrgstarMobile/MobileEditor.swift +397 −30
@@ -17,12 +17,56 @@ final class LockedRanges: @unchecked Sendable {
1717 func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } }
1818}
1919
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
2029/// A text view that offers hardware-keyboard presses to the editor's keymap first.
2130final class KeyedTextView: UITextView {
2231 var keyHandler: ((UIKey) -> Bool)?
2332 /// Keys the editor took, whose release the text system doesn't see. Optional: made by
2433 /// UIKit's `init(usingTextLayoutManager:)`, the view skips Swift's default values.
2534 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 }
2670
2771 override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
2872 var rest: Set<UIPress> = []
@@ -55,7 +99,29 @@ final class MobileEditor: NSObject, DocumentBuffer {
5599 private(set) var document: DocumentState
56100 let textView: UITextView
57101 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() } }
59125 }
60126 var keymap = KeymapPreset.emacs.keymap
61127 var onChange: (() -> Void)?
@@ -79,19 +145,44 @@ final class MobileEditor: NSObject, DocumentBuffer {
79145 /// Tags used across the workspace, for completion.
80146 var knownTags: @Sendable () -> [String] = { [] }
81147
82 private let theme = MobileTheme()
148 private var theme: MobileTheme
83149 private(set) var fold = FoldState()
84150 private var globalVisibility: GlobalVisibility?
85151 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>()
86169 private var loading = false
87170 private var restyleScheduled: Range<Int>?
88171
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 ) {
90176 self.document = document
177 self.options = options
178 self.showMarkup = showMarkup
179 self.baseDirectory = baseDirectory
180 theme = MobileTheme(spec)
91181 let keyed = KeyedTextView(usingTextLayoutManager: true)
92182 textView = keyed
93183 super.init()
94184 keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false }
185 keyed.onLayout = { [weak self] in self?.viewResized() }
95186 textView.isEditable = editable
96187 textView.autocorrectionType = .no
97188 textView.autocapitalizationType = .none
@@ -106,11 +197,15 @@ final class MobileEditor: NSObject, DocumentBuffer {
106197 textView.textStorage.delegate = self
107198 textView.textLayoutManager?.delegate = self
108199 (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()
109205 load(document.text)
110 let tree = document.tree
111 var state = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
112 state.wrapperFolds = Presentation.startupWrapperFolds(tree)
113 setFolds(state)
206 setFolds(startup.folds)
207 if startup.alignTables { _ = perform(AlignAllTables()) }
208 truncatesLines = startup.truncated
114209 textView.selectedRange = NSRange(location: 0, length: 0)
115210 }
116211
@@ -215,6 +310,10 @@ final class MobileEditor: NSObject, DocumentBuffer {
215310 if !cycleAtCaret() { onMessage?("Not on a heading, drawer or block") }
216311 case "org.cycle-global": cycleGlobal()
217312 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")
218317 case "edit.undo": textView.undoManager?.undo()
219318 case "edit.redo": textView.undoManager?.redo()
220319 case _ where id.hasPrefix("app."):
@@ -233,8 +332,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
233332 switch id {
234333 case "org.cycle":
235334 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
237 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto": return onAppCommand != nil
335 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true
336 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.toggle-markup": return onAppCommand != nil
238337 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
239338 default: return false
240339 }
@@ -382,10 +481,38 @@ final class MobileEditor: NSObject, DocumentBuffer {
382481 loading = true
383482 textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
384483 loading = false
484 shaded.set(Presentation.shadedBlocks(document.tree), folded: [])
385485 restyle(0..<(text as NSString).length)
386486 textView.typingAttributes = theme.base
387487 }
388488
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
389516 /// Styles are set after the edit has been processed, as TextKit asks.
390517 private func scheduleRestyle(_ range: Range<Int>) {
391518 if let pending = restyleScheduled {
@@ -396,6 +523,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
396523 DispatchQueue.main.async { [weak self] in
397524 guard let self, let range = self.restyleScheduled else { return }
398525 self.restyleScheduled = nil
526 self.shaded.set(Presentation.shadedBlocks(self.document.tree), folded: [])
399527 // Small files are restyled whole: a heading or block line changes what follows it.
400528 let length = self.textView.textStorage.length
401529 self.restyle(length < 60_000 ? 0..<length : range)
@@ -404,21 +532,171 @@ final class MobileEditor: NSObject, DocumentBuffer {
404532 }
405533 }
406534
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.
407541 private func restyle(_ range: Range<Int>) {
408542 let storage = textView.textStorage
409543 let ns = storage.string as NSString
410544 guard ns.length > 0 else { return }
411545 let lower = min(range.lowerBound, ns.length)
412546 let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower)))
547 let whole = lines.location..<NSMaxRange(lines)
413548 storage.beginEditing()
414549 storage.setAttributes(theme.base, range: lines)
415 for run in Presentation.styles(document.tree, in: lines.location..<NSMaxRange(lines)) {
416 let clipped = NSIntersectionRange(NSRange(location: run.range.lowerBound, length: run.range.count), lines)
417 if clipped.length > 0 { theme.apply(run.role, to: storage, range: clipped) }
550 if indented {
551 for line in Presentation.lineIndents(document.tree, in: whole) {
552 storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
553 }
418554 }
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)
419570 storage.endEditing()
420571 }
421572
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
422700 // MARK: - Folding
423701
424702 func setFolds(_ state: FoldState) {
@@ -478,6 +756,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
478756 fileprivate func selectionChanged() {
479757 let caret = textView.selectedRange.location
480758 if hidden.containing(caret) != nil { unfold(around: caret) }
759 revealCaretLine()
481760 }
482761}
483762
@@ -530,36 +809,124 @@ extension MobileEditor: NSTextContentStorageDelegate {
530809}
531810
532811extension 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.
534814 nonisolated func textLayoutManager(
535815 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
536816 ) -> NSTextLayoutFragment {
537817 let range = textElement.elementRange
538 if let range, let manager = textLayoutManager.textContentManager {
539 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
540 if hidden.isFolded(offset) { return FoldedLineFragment(textElement: textElement, range: range) }
541 }
542 return NSTextLayoutFragment(textElement: textElement, range: range)
818 guard let range, let manager = textLayoutManager.textContentManager else { return NSTextLayoutFragment(textElement: textElement, range: range) }
819 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
820 let folded = hidden.isFolded(offset)
821 let band = shaded.containing(offset) != nil ? fragmentStyle.blockBand : nil
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
543831 }
544832}
545833
546/// A folded line drawn with an ellipsis after it, like org's folded headings.
547final class FoldedLineFragment: NSTextLayoutFragment, @unchecked Sendable {
834extension NSAttributedString.Key {
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
548895 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 }
549902 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 ])
556903 UIGraphicsPushContext(context)
557 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
558 UIGraphicsPopContext()
904 defer { 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 }
559915 }
560916
561 override var renderingSurfaceBounds: CGRect {
562 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
917 private func drawEntities(at point: CGPoint) {
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 }
563930 }
564931}
565932#endif
Sources/OrgstarMobile/MobileModel.swift +41 −11
@@ -4,7 +4,9 @@ import Observation
44import OrgApp
55import OrgCore
66import OrgIndex
7import OrgPresentation
78import OrgWorkspace
9import UIKit
810
911/// The app's models, and the settings from the configuration folder: a folder you choose
1012/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`.
@@ -36,7 +38,13 @@ final class MobileModel {
3638 }
3739 /// Bumped when settings apply, so views that read them update.
3840 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?
3946 @ObservationIgnored private var presenter: FolderPresenter?
47 @ObservationIgnored private var sizeObserver: NSObjectProtocol?
4048
4149 private static let bookmarkKey = "configFolderBookmark"
4250
@@ -44,7 +52,7 @@ final class MobileModel {
4452 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder.
4553 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) }
4654 UserConfig.directoryOverride = folder
47 let problems = Self.readConfig(folder)
55 let (problems, colors) = Self.readConfig(folder)
4856 let settings = OrgPreferences.orgSettings()
4957 do {
5058 let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings))
@@ -58,9 +66,13 @@ final class MobileModel {
5866 }
5967 configFolder = folder
6068 configProblems = problems
61 applySettings()
69 applySettings(colors)
6270 shareTemplates()
6371 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 }
6476 }
6577
6678 /// Chooses the configuration folder, or with nil stops using one; its settings apply now.
@@ -80,12 +92,19 @@ final class MobileModel {
8092
8193 func reloadConfig() {
8294 UserConfig.directoryOverride = configFolder
83 configProblems = Self.readConfig(configFolder)
84 applySettings()
95 let (problems, colors) = Self.readConfig(configFolder)
96 configProblems = problems
97 applySettings(colors)
8598 shareTemplates()
8699 }
87100
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 }
89108 let settings = OrgPreferences.orgSettings()
90109 session.defaults = settings
91110 session.options = OrgPreferences.options()
@@ -124,17 +143,28 @@ final class MobileModel {
124143 return url
125144 }
126145
127 /// Applies the folder's `config.toml` to the settings; returns its problems.
128 private static func readConfig(_ folder: URL?) -> [String] {
129 guard let folder else { return [] }
130 let file = folder.appendingPathComponent("config.toml")
146 /// Applies the folder's `config.toml` to the settings; returns its problems and the theme's
147 /// colors.
148 private static func readConfig(_ folder: URL?) -> ([String], ThemeSpec) {
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? {
131162 var text: String?
132163 var error: NSError?
133164 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in
134165 text = try? String(contentsOf: url, encoding: .utf8)
135166 }
136 guard let text else { return ["No config.toml in \(folder.lastPathComponent); the defaults apply."] }
137 return UserConfig.apply(text, to: .standard)
167 return text
138168 }
139169
140170 /// Notifications for the next week's timed entries, at most 64, as iOS allows.
Sources/OrgstarMobile/MobileTheme.swift +54 −15
@@ -1,5 +1,6 @@
11#if os(iOS)
22import OrgPresentation
3import SwiftUI
34import UIKit
45
56/// Attributes for each style role, from a `ThemeSpec`, as the Mac editor's theme sets them.
@@ -10,12 +11,17 @@ struct MobileTheme {
1011
1112 init(_ spec: ThemeSpec = .default) {
1213 self.spec = spec
13 // Dynamic Type, monospaced as on the Mac.
14 font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .monospacedSystemFont(ofSize: 15, weight: .regular))
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))
1520 }
1621
1722 /// A color that follows the appearance, or nil when the theme has none for `key`.
1823 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.
1925 let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key]
2026 let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key]
2127 guard let light, let dark else { return nil }
@@ -29,10 +35,45 @@ struct MobileTheme {
2935 }
3036 }
3137
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
3257 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 {
3372 let style = NSMutableParagraphStyle()
73 style.firstLineHeadIndent = CGFloat(firstLine) * column
74 style.headIndent = CGFloat(wrapped) * column
3475 style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
35 return [.font: font, .foregroundColor: color("foreground") ?? .label, .paragraphStyle: style]
76 return style
3677 }
3778
3879 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
@@ -41,25 +82,18 @@ struct MobileTheme {
4182 }
4283 func traits(_ add: UIFontDescriptor.SymbolicTraits) {
4384 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)) {
45 storage.addAttribute(.font, value: UIFont(descriptor: descriptor, size: current.pointSize), range: range)
46 }
85 storage.addAttribute(.font, value: Self.adding(add, to: current), range: range)
4786 }
4887 switch role {
4988 case .heading(let level):
50 let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level))
51 storage.addAttribute(.font, value: font.withSize(size), range: range)
52 traits(.traitBold)
89 storage.addAttribute(.font, value: headingFont(level), range: range)
5390 fg("heading-\(min(max(level, 1), 8))")
54 case .leadingStars: fg("markup")
91 case .leadingStars:
92 storage.addAttribute(.foregroundColor, value: UIColor.clear, range: range)
5593 case .todo, .done:
5694 traits(.traitBold)
5795 let keyword = (storage.string as NSString).substring(with: range)
58 if let custom = spec.todo[keyword] {
59 storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range)
60 } else {
61 fg(role == .todo ? "todo" : "done")
62 }
96 if let color = keywordColor(keyword, done: role == .done) { storage.addAttribute(.foregroundColor, value: color, range: range) }
6397 case .priority: fg("priority")
6498 case .tags: fg("tags")
6599 case .comment: fg("comment")
@@ -84,5 +118,10 @@ struct MobileTheme {
84118 case .syntax: break
85119 }
86120 }
121
122 // MARK: - SwiftUI
123
124 /// A theme color for SwiftUI views.
125 func swiftColor(_ key: String) -> Color? { color(key).map { Color(uiColor: $0) } }
87126}
88127#endif