krz/orgstar

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

Sources/OrgstarMobile/MobileEditor.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgstarMobile/MobileEditor.swift history · blame · raw

933 lines · 44843 bytes

55 symbols in this file
  1#if os(iOS)
  2import OrgCore
  3import OrgDocument
  4import OrgPresentation
  5import UIKit
  6
  7/// Ranges read by TextKit's delegate callbacks.
  8final class LockedRanges: @unchecked Sendable {
  9    private let lock = NSLock()
 10    private var ranges: [Range<Int>] = []
 11    private var starts: Set<Int> = []
 12
 13    func set(_ new: [Range<Int>], folded: Set<Int>) { lock.withLock { ranges = new; starts = folded } }
 14    var all: [Range<Int>] { lock.withLock { ranges } }
 15    func covers(_ start: Int, _ end: Int) -> Bool { lock.withLock { ranges.contains { $0.lowerBound <= start && end <= $0.upperBound } } }
 16    func isFolded(_ start: Int) -> Bool { lock.withLock { starts.contains(start) } }
 17    func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } }
 18}
 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
 29/// A text view that offers hardware-keyboard presses to the editor's keymap first.
 30final class KeyedTextView: UITextView {
 31    var keyHandler: ((UIKey) -> Bool)?
 32    /// Keys the editor took, whose release the text system doesn't see. Optional: made by
 33    /// UIKit's `init(usingTextLayoutManager:)`, the view skips Swift's default values.
 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    }
 70
 71    override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
 72        var rest: Set<UIPress> = []
 73        for press in presses {
 74            if let key = press.key, keyHandler?(key) == true {
 75                handled = (handled ?? []).union([key.keyCode])
 76            } else {
 77                rest.insert(press)
 78            }
 79        }
 80        if !rest.isEmpty { super.pressesBegan(rest, with: event) }
 81    }
 82
 83    override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
 84        let rest = presses.filter { press in
 85            guard let code = press.key?.keyCode, handled?.contains(code) == true else { return true }
 86            handled?.remove(code)
 87            return false
 88        }
 89        if !rest.isEmpty { super.pressesEnded(rest, with: event) }
 90    }
 91}
 92
 93/// The iOS editor: a TextKit 2 text view that is the open file's live state, as the Mac's
 94/// `OrgEditor` is. Typing and undo go through the text view and into the document; commands
 95/// run on the document and apply their edits as one undo step; headings, drawers and blocks
 96/// fold as TAB cycles them.
 97@MainActor
 98final class MobileEditor: NSObject, DocumentBuffer {
 99    private(set) var document: DocumentState
100    let textView: UITextView
101    var options: EditingOptions = .org {
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() } }
125    }
126    var keymap = KeymapPreset.emacs.keymap
127    var onChange: (() -> Void)?
128    var onMessage: ((String) -> Void)?
129    var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)?
130    var onLink: ((OrgLink) -> Void)?
131    /// Edits being applied, which the typing hooks leave alone.
132    private var applyingEdits = false
133    /// Source blocks to write to their files (C-c C-v t).
134    var onTangle: ((Tangle.Scope) -> Void)?
135    /// A source block to run (C-c C-c).
136    var onBabel: ((BabelJob) -> Void)?
137    /// The file's setup files to read again (C-c C-c on a keyword line).
138    var onRefreshSetup: (() -> Void)?
139    /// A block, a table's formulas or a field to edit apart (C-c ', C-c `).
140    var onEditBlock: ((BlockEdit) -> Void)?
141    /// A followed timestamp's agenda days: the first (a `Days` number) and how many.
142    var onAgenda: ((Int, Int) -> Void)?
143    /// Commands the app runs, as `app.clock.in`.
144    var onAppCommand: ((String) -> Void)?
145    /// Tags used across the workspace, for completion.
146    var knownTags: @Sendable () -> [String] = { [] }
147
148    private var theme: MobileTheme
149    private(set) var fold = FoldState()
150    private var globalVisibility: GlobalVisibility?
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>()
169    private var loading = false
170    private var restyleScheduled: Range<Int>?
171
172    init(
173        document: DocumentState, editable: Bool, options: EditingOptions = .org, theme spec: ThemeSpec = .default,
174        showMarkup: Bool = true, baseDirectory: URL? = nil
175    ) {
176        self.document = document
177        self.options = options
178        self.showMarkup = showMarkup
179        self.baseDirectory = baseDirectory
180        theme = MobileTheme(spec)
181        let keyed = KeyedTextView(usingTextLayoutManager: true)
182        textView = keyed
183        super.init()
184        keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false }
185        keyed.onLayout = { [weak self] in self?.viewResized() }
186        textView.isEditable = editable
187        textView.autocorrectionType = .no
188        textView.autocapitalizationType = .none
189        textView.smartQuotesType = .no
190        textView.smartDashesType = .no
191        textView.smartInsertDeleteType = .no
192        textView.spellCheckingType = options.spellCheck ? .yes : .no
193        textView.keyboardDismissMode = .interactive
194        textView.alwaysBounceVertical = true
195        textView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 24, right: 8)
196        textView.delegate = self
197        textView.textStorage.delegate = self
198        textView.textLayoutManager?.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()
205        load(document.text)
206        setFolds(startup.folds)
207        if startup.alignTables { _ = perform(AlignAllTables()) }
208        truncatesLines = startup.truncated
209        textView.selectedRange = NSRange(location: 0, length: 0)
210    }
211
212    // MARK: - DocumentBuffer
213
214    var selection: [Range<Int>] {
215        let selected = textView.selectedRange
216        return [selected.location..<NSMaxRange(selected)]
217    }
218
219    func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
220        let before = document.text
221        let defaultsBefore = document.defaults
222        let selected = textView.selectedRange
223        let result = try change(&document)
224        if document.text != before {
225            // A save that merged a change from disk, or a revert: the view shows the new text.
226            load(document.text)
227            let length = (document.text as NSString).length
228            textView.selectedRange = NSRange(location: min(selected.location, length), length: 0)
229            textView.undoManager?.removeAllActions()
230            setFolds(fold)
231        } else if document.defaults != defaultsBefore {
232            restyle(0..<(document.text as NSString).length)
233        }
234        return result
235    }
236
237    func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
238        let context = EditContext(
239            revision: document.revision, text: document.text, tree: document.tree, selection: selection,
240            now: now, answers: answers, hidden: hidden.all, options: options, knownTags: knownTags
241        )
242        let step = command.run(in: context)
243        switch step {
244        case .commit(let result):
245            guard textView.isEditable || result.edits.isEmpty else {
246                onMessage?("This file is read-only.")
247                return .failed("read-only")
248            }
249            applyEdits(result.edits)
250            if let selected = result.selection?.first {
251                unfold(around: selected.lowerBound)
252                textView.selectedRange = NSRange(location: selected.lowerBound, length: selected.count)
253                textView.scrollRangeToVisible(textView.selectedRange)
254            }
255            for effect in result.effects {
256                switch effect {
257                case .message(let text): onMessage?(text)
258                case .fold(let start):
259                    var state = fold
260                    state.folds.insert(start)
261                    setFolds(state)
262                case .refreshSetup: onRefreshSetup?()
263                default: break
264                }
265            }
266        case .failed(let message):
267            onMessage?(message)
268        case .prompt(let prompt):
269            onPrompt?(prompt) { [weak self] answer in
270                guard let self else { return }
271                guard let answer else { return self.onMessage?("Quit") ?? () }
272                _ = self.perform(command, now: now, answers: answers.merging([prompt.key: answer]) { $1 })
273            }
274        case .external(.editBlock(let edit)) where onEditBlock != nil:
275            onEditBlock?(edit)
276        case .external(.babel(let job)) where onBabel != nil:
277            onBabel?(job)
278        case .external(.tangle(let scope)) where onTangle != nil:
279            onTangle?(scope)
280        case .external(.agenda(let start, let count)) where onAgenda != nil:
281            onAgenda?(start, count)
282        case .external(let request):
283            onMessage?(Self.unavailable(request, title: command.title))
284        }
285        return step
286    }
287
288    /// Why a request the app doesn't handle did nothing: only table recalculation needs Emacs.
289    static func unavailable(_ request: ExternalRequest, title: String) -> String {
290        if case .emacsTable(_, _, _, let reason, _) = request { return "This table needs Emacs, which runs on the Mac: \(reason)." }
291        return "\(title) isn't available on iPhone and iPad."
292    }
293
294    // MARK: - Commands by name and key
295
296    /// Runs what `keys` (as `C-c C-t`) is bound to in the Emacs keymap, for the caret's context.
297    func run(keys: String) {
298        guard let chords = KeySequence.parse(keys) else { return }
299        let context = editContext()
300        let binding = keymap.candidates(for: chords).first { binding in
301            (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
302        }
303        guard let binding else { return onMessage?("Nothing to do here") ?? () }
304        run(binding.command)
305    }
306
307    func run(_ id: String) {
308        switch id {
309        case "org.cycle":
310            if !cycleAtCaret() { onMessage?("Not on a heading, drawer or block") }
311        case "org.cycle-global": cycleGlobal()
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")
317        case "edit.undo": textView.undoManager?.undo()
318        case "edit.redo": textView.undoManager?.redo()
319        case _ where id.hasPrefix("app."):
320            if let onAppCommand { onAppCommand(id) } else { onMessage?("Not available on iOS yet") }
321        default:
322            if let command = Commands.command(id) {
323                _ = perform(command)
324            } else {
325                onMessage?("Not available on iOS yet")
326            }
327        }
328    }
329
330    func applies(_ id: String, in context: EditContext) -> Bool {
331        if let command = Commands.command(id) { return command.applies(in: context) }
332        switch id {
333        case "org.cycle":
334            return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != 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.clock.in-recent", "app.clock.in-last",
337             "app.clock.goto-recent", "app.clock.default", "app.clock.resolve", "app.toggle-markup": return onAppCommand != nil
338        case "org.link.open": return OpenLinkAtPoint().applies(in: context)
339        default: return false
340        }
341    }
342
343    // MARK: - Hardware keyboard
344
345    private lazy var dispatcher = KeyDispatcher(keymap: keymap)
346
347    private static let named: [UIKeyboardHIDUsage: String] = [
348        .keyboardTab: "TAB", .keyboardReturnOrEnter: "RET", .keypadEnter: "RET", .keyboardSpacebar: "SPC",
349        .keyboardEscape: "ESC", .keyboardDeleteOrBackspace: "DEL", .keyboardDeleteForward: "<delete>",
350        .keyboardLeftArrow: "<left>", .keyboardRightArrow: "<right>", .keyboardUpArrow: "<up>", .keyboardDownArrow: "<down>",
351        .keyboardHome: "<home>", .keyboardEnd: "<end>", .keyboardPageUp: "<prior>", .keyboardPageDown: "<next>",
352    ]
353
354    /// A key press as a chord; Option is Meta, as `option-as-meta` makes it on the Mac.
355    static func chord(_ key: UIKey) -> KeyChord? {
356        var modifiers: KeyChord.Modifiers = []
357        let flags = key.modifierFlags
358        if flags.contains(.control) { modifiers.insert(.control) }
359        if flags.contains(.alternate) { modifiers.insert(.meta) }
360        if flags.contains(.command) { modifiers.insert(.command) }
361        if let name = named[key.keyCode] {
362            if flags.contains(.shift) { modifiers.insert(.shift) }
363            return KeyChord(name, modifiers)
364        }
365        var characters = key.charactersIgnoringModifiers
366        if flags.contains(.shift), characters.count == 1, characters.lowercased() == characters { characters = characters.uppercased() }
367        guard characters.count == 1 else { return nil }
368        return KeyChord(characters, modifiers)
369    }
370
371    /// The Emacs keymap's commands on an iPad keyboard. Keys it doesn't bind, Command shortcuts
372    /// and editing keys (C-a, C-e, C-k...) are the text system's.
373    func handleKey(_ key: UIKey) -> Bool {
374        guard let chord = Self.chord(key), !chord.modifiers.contains(.command) else { return false }
375        // Option alone types characters unless it begins a binding.
376        if chord.modifiers == [.meta], dispatcher.pending.isEmpty,
377           keymap.candidates(for: [chord]).isEmpty, !keymap.isPrefix([chord]) { return false }
378        switch dispatcher.feed(chord) {
379        case .pending(let keys):
380            onMessage?(KeySequence.format(keys) + "-")
381            return true
382        case .cancelled:
383            onMessage?("Quit")
384            return true
385        case .undefined(let keys):
386            onMessage?("\(KeySequence.format(keys)) is undefined")
387            return true
388        case .unbound:
389            return false
390        case .complete(let keys, let candidates):
391            let context = editContext()
392            let runnable = candidates.first { binding in
393                (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
394            }
395            // A sequence runs its command even where it doesn't apply, which says why, as on the Mac.
396            guard let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) else { return false }
397            if keys.count == 1, binding.command.hasPrefix("edit."), !["edit.undo", "edit.redo"].contains(binding.command) { return false }
398            run(binding.command)
399            return true
400        }
401    }
402
403    func editContext() -> EditContext {
404        EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: selection, hidden: hidden.all, options: options, knownTags: knownTags)
405    }
406
407    private func openLinkAtCaret() {
408        let caret = textView.selectedRange.location
409        if let link = Links.link(at: caret, text: document.text, tree: document.tree)?.link {
410            onLink?(link)
411        } else if let days = Links.timestampDays(at: caret, text: document.text) {
412            onAgenda?(days.start, days.count)
413        } else {
414            onMessage?("No link here")
415        }
416    }
417
418    // MARK: - Editing
419
420    /// Applies `edits` (against the current text) through the text view as one undo step.
421    func applyEdits(_ edits: [TextEdit]) {
422        guard !edits.isEmpty else { return }
423        applyingEdits = true
424        defer { applyingEdits = false }
425        // Straight into the storage: replacing through text input would take the active
426        // keyboard's substitutions (smart dashes) when another view has the keyboard.
427        let storage = textView.textStorage
428        let old = storage.string as NSString
429        // By lines, so a fold between two changes stays.
430        let ascending = edits.flatMap { splitByLines($0, in: storage.string) }.sorted { $0.range.lowerBound < $1.range.lowerBound }
431        var inverse: [TextEdit] = []
432        var delta = 0
433        for edit in ascending {
434            let start = edit.range.lowerBound + delta
435            let length = (edit.replacement as NSString).length
436            inverse.append(TextEdit(range: start..<start + length, replacement: old.substring(with: NSRange(edit.range))))
437            delta += length - edit.range.count
438        }
439        let caret = textView.selectedRange
440        // One at a time, last first: each is processed alone, so folds between them stay.
441        for edit in ascending.reversed() {
442            storage.replaceCharacters(in: NSRange(edit.range), with: edit.replacement)
443        }
444        textView.undoManager?.registerUndo(withTarget: self) { editor in
445            editor.applyEdits(inverse)
446            editor.textView.selectedRange = caret
447        }
448    }
449
450    /// The storage changed (typing, a command, undo): the document takes the same edit, and
451    /// folds and styles follow.
452    fileprivate func storageEdited(range: NSRange, delta: Int) {
453        guard !loading else { return }
454        let old = range.location..<(range.location + range.length - delta)
455        let replacement = (textView.textStorage.string as NSString).substring(with: range)
456        do {
457            try document.apply([TextEdit(range: old, replacement: replacement)], baseRevision: document.revision)
458        } catch {
459            // Out of step: the view's text wins.
460            load(textView.textStorage.string)
461            return
462        }
463        fold = FoldState(
464            folds: Self.map(fold.folds, old, delta), bodyFolds: Self.map(fold.bodyFolds, old, delta),
465            wrapperFolds: Self.map(fold.wrapperFolds, old, delta)
466        )
467        scheduleRestyle(range.location..<NSMaxRange(range))
468        onChange?()
469    }
470
471    /// Starts after the edit move with it; ones inside it go.
472    private static func map(_ starts: Set<Int>, _ old: Range<Int>, _ delta: Int) -> Set<Int> {
473        Set(starts.compactMap { start in
474            if start >= old.upperBound { return start + delta }
475            return start <= old.lowerBound ? start : nil
476        })
477    }
478
479    // MARK: - Styling
480
481    private func load(_ text: String) {
482        loading = true
483        textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
484        loading = false
485        shaded.set(Presentation.shadedBlocks(document.tree), folded: [])
486        restyle(0..<(text as NSString).length)
487        textView.typingAttributes = theme.base
488    }
489
490    /// The text view's own colors, and the block band's.
491    private func applyThemeToView() {
492        textView.backgroundColor = theme.color("background") ?? .systemBackground
493        textView.tintColor = theme.color("cursor")
494        textView.typingAttributes = theme.base
495        fragmentStyle.set(blockBackground: theme.color("block-background") ?? .clear, width: textView.bounds.width)
496    }
497
498    /// The band behind blocks spans the view's width, or the widest line's when truncated;
499    /// images are as wide as the view allows.
500    private func viewResized() {
501        let band = fragmentStyle.blockBand
502        let width = max(textView.bounds.width, truncatesLines ? textView.contentSize.width : 0)
503        let resized = imageWidth != textView.bounds.width
504        guard band.width != width || resized else { return }
505        fragmentStyle.set(blockBackground: band.color, width: width)
506        imageWidth = textView.bounds.width
507        if showsImages, resized { restyle(0..<textView.textStorage.length) }
508        if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
509    }
510
511    /// Styles the whole file again.
512    private func restyleAll() {
513        restyle(0..<textView.textStorage.length)
514        if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
515    }
516
517    /// Styles are set after the edit has been processed, as TextKit asks.
518    private func scheduleRestyle(_ range: Range<Int>) {
519        if let pending = restyleScheduled {
520            restyleScheduled = min(pending.lowerBound, range.lowerBound)..<max(pending.upperBound, range.upperBound)
521            return
522        }
523        restyleScheduled = range
524        DispatchQueue.main.async { [weak self] in
525            guard let self, let range = self.restyleScheduled else { return }
526            self.restyleScheduled = nil
527            self.shaded.set(Presentation.shadedBlocks(self.document.tree), folded: [])
528            // Small files are restyled whole: a heading or block line changes what follows it.
529            let length = self.textView.textStorage.length
530            self.restyle(length < 60_000 ? 0..<length : range)
531            self.updateHidden()
532            self.textView.typingAttributes = self.theme.base
533        }
534    }
535
536    private var caretLine: NSRange {
537        let text = textView.textStorage.string as NSString
538        return text.lineRange(for: NSRange(location: min(textView.selectedRange.location, text.length), length: 0))
539    }
540
541    /// Sets attributes for the whole lines in `range`, as the Mac editor does.
542    private func restyle(_ range: Range<Int>) {
543        let storage = textView.textStorage
544        let ns = storage.string as NSString
545        guard ns.length > 0 else { return }
546        let lower = min(range.lowerBound, ns.length)
547        let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower)))
548        let whole = lines.location..<NSMaxRange(lines)
549        storage.beginEditing()
550        storage.setAttributes(theme.base, range: lines)
551        if indented {
552            for line in Presentation.lineIndents(document.tree, in: whole) {
553                storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
554            }
555        }
556        let revealed = caretLine
557        var scriptExclusions: [Range<Int>] = []
558        for run in Presentation.styles(document.tree, in: whole) {
559            let clipped = NSIntersectionRange(NSRange(run.range), lines)
560            guard clipped.length > 0 else { continue }
561            if Presentation.skipsLeadingStars(run.role, indented: indented, hideStars: hideStars) { continue }
562            if !showMarkup, Presentation.hidesWithMarkup(run.role, options: options), NSIntersectionRange(clipped, revealed).length == 0 {
563                storage.addAttributes(theme.hidden, range: clipped)
564            } else {
565                theme.apply(run.role, to: storage, range: clipped)
566            }
567            if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) }
568        }
569        if !showMarkup, options.prettyEntities { prettify(whole, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
570        applyImages(whole, in: storage, revealed: revealed)
571        storage.endEditing()
572    }
573
574    private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
575        if let style = paragraphStyles[[firstLine, wrapped]] { return style }
576        let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
577        paragraphStyles[[firstLine, wrapped]] = style
578        return style
579    }
580
581    /// `org-pretty-entities`: entities drawn as their character over the entity's last
582    /// character (the rest hidden), sub/superscripts raised without their markers.
583    private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) {
584        let runs = Presentation.prettyRuns(
585            storage.string as NSString, in: range, revealedLine: revealed.location, inBlock: { shaded.containing($0) != nil },
586            scriptExclusions: scriptExclusions, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces
587        )
588        for (lineStart, run) in runs {
589            switch run {
590            case .entity(let r, let display):
591                let start = lineStart + r.lowerBound
592                let last = lineStart + r.upperBound - 1
593                let color = storage.attribute(.foregroundColor, at: last, effectiveRange: nil) as? UIColor ?? theme.foreground
594                if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
595                storage.addAttributes([.foregroundColor: UIColor.clear, .orgEntityDisplay: display, .orgEntityColor: color], range: NSRange(location: last, length: 1))
596            case .script(let markers, let body, let superscript):
597                for marker in markers {
598                    storage.addAttributes(theme.hidden, range: NSRange(location: lineStart + marker.lowerBound, length: marker.count))
599                }
600                let size = theme.font.pointSize
601                storage.addAttributes([
602                    .font: theme.font.withSize(size * 0.7),
603                    .baselineOffset: (superscript ? 0.3 : -0.3) * size,
604                ], range: NSRange(location: lineStart + body.lowerBound, length: body.count))
605            }
606        }
607    }
608
609    /// Moving to another line shows its markup and hides the last one's.
610    private func revealCaretLine() {
611        guard !showMarkup else { return }
612        let line = caretLine
613        guard line != revealedLine else { return }
614        let text = textView.textStorage.string as NSString
615        // The old line may have moved with edits since; restyle the line now at its start.
616        let old = revealedLine.flatMap { $0.location <= text.length ? text.lineRange(for: NSRange(location: $0.location, length: 0)) : nil }
617        revealedLine = line
618        for range in [old, line].compactMap({ $0 }) {
619            restyle(range.location..<NSMaxRange(range))
620        }
621    }
622
623    // MARK: - Inline images
624
625    private func toggleImages() {
626        showsImages.toggle()
627        restyleAll()
628        if showsImages {
629            let ns = textView.textStorage.string as NSString
630            var count = 0
631            var offset = 0
632            while offset < ns.length {
633                let line = ns.lineRange(for: NSRange(location: offset, length: 0))
634                if let image = InlineImages.image(atLine: line.location, in: ns), loadImage(image.path) != nil { count += 1 }
635                offset = NSMaxRange(line)
636            }
637            onMessage?("\(count) images displayed inline")
638        } else {
639            onMessage?("Inline image display turned off")
640        }
641    }
642
643    private func loadImage(_ path: String) -> UIImage? {
644        let expanded = (path as NSString).expandingTildeInPath
645        let url: URL
646        if expanded.hasPrefix("/") {
647            url = URL(fileURLWithPath: expanded)
648        } else if let baseDirectory {
649            url = baseDirectory.appendingPathComponent(expanded)
650        } else {
651            return nil
652        }
653        let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate?.timeIntervalSince1970 ?? 0
654        let key = "\(url.path)|\(modified)" as NSString
655        if let cached = Self.imageCache.object(forKey: key) { return cached }
656        guard let image = UIImage(contentsOfFile: url.path) else { return nil }
657        Self.imageCache.setObject(image, forKey: key)
658        return image
659    }
660
661    /// Image lines in `range` show the image over the link, which is hidden unless the caret is
662    /// on the line.
663    private func applyImages(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange) {
664        guard showsImages else { return }
665        let text = storage.string as NSString
666        let inset = textView.textContainerInset
667        let maxWidth = max(80, textView.bounds.width - inset.left - inset.right - 40)
668        var location = range.lowerBound
669        while location < range.upperBound, location < text.length {
670            let line = text.lineRange(for: NSRange(location: location, length: 0))
671            location = NSMaxRange(line)
672            guard let image = InlineImages.image(atLine: line.location, in: text), let picture = loadImage(image.path) else { continue }
673            let width = min(image.width.map(CGFloat.init) ?? picture.size.width, maxWidth)
674            let height = picture.size.width > 0 ? width * picture.size.height / picture.size.width : 0
675            let content = NSRange(location: line.location, length: line.length - (text.substring(with: line).hasSuffix("\n") ? 1 : 0))
676            // Invisible, keeping the line's height.
677            if line.location != revealed.location, content.length > 0 {
678                storage.addAttributes([.foregroundColor: UIColor.clear, .underlineStyle: 0], range: content)
679            }
680            storage.addAttribute(.orgImage, value: InlineImageBox(image: picture, size: CGSize(width: width, height: height)), range: NSRange(location: line.location, length: max(1, content.length)))
681            let style = (storage.attribute(.paragraphStyle, at: line.location, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
682            // The image sits where the line is; the spacing makes room for the rest of it.
683            style.paragraphSpacing = max(0, height + 4 - theme.font.lineHeight)
684            storage.addAttribute(.paragraphStyle, value: style, range: line)
685        }
686    }
687
688    // MARK: - Truncation
689
690    /// `toggle-truncate-lines`: long lines run off the right edge, scrolling sideways, instead
691    /// of wrapping.
692    var truncatesLines: Bool {
693        get { (textView as? KeyedTextView)?.truncates ?? false }
694        set {
695            guard newValue != truncatesLines, let view = textView as? KeyedTextView else { return }
696            view.truncates = newValue
697            if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
698        }
699    }
700
701    // MARK: - Folding
702
703    func setFolds(_ state: FoldState) {
704        fold = state
705        updateHidden()
706    }
707
708    private func updateHidden() {
709        let tree = document.tree
710        let wrappers = fold.wrapperFolds.compactMap { Wrappers.starting(at: $0, in: tree)?.hidden }
711        hidden.set(
712            Presentation.hiddenRanges(tree, folds: fold.folds, bodyFolds: fold.bodyFolds, wrapperRanges: wrappers),
713            folded: fold.folds.union(fold.bodyFolds).union(fold.wrapperFolds)
714        )
715        if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
716        textView.setNeedsDisplay()
717    }
718
719    /// TAB at the caret: a heading cycles, a drawer or block opens or closes.
720    @discardableResult
721    func cycleAtCaret() -> Bool {
722        guard let next = Presentation.cycle(at: textView.selectedRange.location, in: document.tree, fold) else { return false }
723        setFolds(next)
724        return true
725    }
726
727    /// Shift-TAB: overview, contents, show all.
728    func cycleGlobal() {
729        let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree, wrapperFolds: fold.wrapperFolds)
730        globalVisibility = visibility
731        setFolds(state)
732    }
733
734    /// Opens the folds hiding `offset`.
735    func unfold(around offset: Int) {
736        var state = fold
737        for entry in Presentation.outline(document.tree) where entry.start < offset && offset < entry.end {
738            state.folds.remove(entry.start)
739            state.bodyFolds.remove(entry.start)
740        }
741        for start in state.wrapperFolds where Wrappers.starting(at: start, in: document.tree)?.hidden.contains(offset) == true {
742            state.wrapperFolds.remove(start)
743        }
744        if state != fold { setFolds(state) }
745    }
746
747    /// Shows the heading at `offset`, unfolded, at the top.
748    func reveal(_ offset: Int) {
749        let length = textView.textStorage.length
750        let target = min(max(0, offset), length)
751        unfold(around: target)
752        textView.selectedRange = NSRange(location: target, length: 0)
753        textView.scrollRangeToVisible(NSRange(location: target, length: 0))
754    }
755
756    /// The caret left hidden text (a tap past a fold): what hides it opens.
757    fileprivate func selectionChanged() {
758        let caret = textView.selectedRange.location
759        if hidden.containing(caret) != nil { unfold(around: caret) }
760        revealCaretLine()
761    }
762}
763
764extension MobileEditor: UITextViewDelegate {
765    func textViewDidChangeSelection(_ textView: UITextView) {
766        selectionChanged()
767    }
768
769    /// `electric-pair-mode` for typed brackets and quotes, DEL and RET, from either keyboard.
770    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
771        guard options.electricPair, !applyingEdits, textView.markedTextRange == nil else { return true }
772        let selected = textView.selectedRange
773        let key: String
774        if text.isEmpty, selected.length == 0, range.length == 1, NSMaxRange(range) == selected.location {
775            key = "DEL"
776        } else if range == selected, text == "\n" {
777            key = "RET"
778        } else if range == selected, ElectricPairKey.characters.contains(text) {
779            key = text
780        } else {
781            return true
782        }
783        let command = ElectricPairKey(key: key)
784        guard command.applies(in: editContext()) else { return true }
785        _ = perform(command)
786        return false
787    }
788}
789
790extension MobileEditor: NSTextStorageDelegate {
791    nonisolated func textStorage(
792        _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int
793    ) {
794        guard editedMask.contains(.editedCharacters) else { return }
795        MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
796    }
797}
798
799extension MobileEditor: NSTextContentStorageDelegate {
800    /// Folded text isn't enumerated, so it isn't laid out or drawn.
801    nonisolated func textContentManager(
802        _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
803    ) -> Bool {
804        guard let range = textElement.elementRange else { return true }
805        let documentStart = textContentManager.documentRange.location
806        let start = textContentManager.offset(from: documentStart, to: range.location)
807        let end = textContentManager.offset(from: documentStart, to: range.endLocation)
808        return !hidden.covers(start, end)
809    }
810}
811
812extension MobileEditor: NSTextLayoutManagerDelegate {
813    /// Folded headings, drawers and blocks end with `…`; blocks are on a band; image lines
814    /// show their image; pretty entities draw their character.
815    nonisolated func textLayoutManager(
816        _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
817    ) -> NSTextLayoutFragment {
818        let range = textElement.elementRange
819        guard let range, let manager = textLayoutManager.textContentManager else { return NSTextLayoutFragment(textElement: textElement, range: range) }
820        let offset = manager.offset(from: manager.documentRange.location, to: range.location)
821        let folded = hidden.isFolded(offset)
822        let band = shaded.containing(offset) != nil ? fragmentStyle.blockBand : nil
823        let string = (textElement as? NSTextParagraph)?.attributedString
824        let image = string.flatMap(OrgLineFragment.image)
825        let entities = string.map(OrgLineFragment.hasEntities) ?? false
826        guard folded || band != nil || image != nil || entities else { return NSTextLayoutFragment(textElement: textElement, range: range) }
827        let fragment = OrgLineFragment(textElement: textElement, range: range)
828        fragment.folded = folded
829        fragment.band = band
830        fragment.image = image
831        return fragment
832    }
833}
834
835extension NSAttributedString.Key {
836    /// The character an entity shows as, on the entity's last character.
837    static let orgEntityDisplay = NSAttributedString.Key("OrgEntityDisplay")
838    /// The color the entity's character is drawn in.
839    static let orgEntityColor = NSAttributedString.Key("OrgEntityColor")
840    /// The image an image link's line shows under it.
841    static let orgImage = NSAttributedString.Key("OrgImage")
842}
843
844final class InlineImageBox: NSObject, @unchecked Sendable {
845    let image: UIImage
846    let size: CGSize
847    init(image: UIImage, size: CGSize) {
848        self.image = image
849        self.size = size
850    }
851}
852
853/// A line drawn with more than its text, as the Mac editor's fragments draw them: a folded
854/// line's ellipsis, a block's band across the view, an image under an image link, and pretty
855/// entities' characters where the entities' last (invisible) characters sit.
856final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
857    var folded = false
858    var band: (color: UIColor, width: CGFloat)?
859    var image: InlineImageBox?
860
861    static func image(_ string: NSAttributedString) -> InlineImageBox? {
862        var found: InlineImageBox?
863        string.enumerateAttribute(.orgImage, in: NSRange(location: 0, length: string.length)) { value, _, stop in
864            if let box = value as? InlineImageBox { found = box; stop.pointee = true }
865        }
866        return found
867    }
868
869    static func hasEntities(_ string: NSAttributedString) -> Bool {
870        var found = false
871        string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, _, stop in
872            if value != nil { found = true; stop.pointee = true }
873        }
874        return found
875    }
876
877    private var bandRect: CGRect? {
878        guard let band else { return nil }
879        // Not under the empty line TextKit adds after a final newline.
880        let height = textLineFragments.last { $0.characterRange.length > 0 }?.typographicBounds.maxY ?? layoutFragmentFrame.height
881        return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: max(band.width, layoutFragmentFrame.maxX), height: height)
882    }
883
884    private var imageRect: CGRect? {
885        guard let image, let line = textLineFragments.first else { return nil }
886        return CGRect(x: line.typographicBounds.minX, y: line.typographicBounds.minY + 2, width: image.size.width, height: image.size.height)
887    }
888
889    override var renderingSurfaceBounds: CGRect {
890        var bounds = super.renderingSurfaceBounds
891        if folded { bounds = bounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0) }
892        for rect in [bandRect, imageRect].compactMap({ $0 }) { bounds = bounds.union(rect) }
893        return bounds
894    }
895
896    override func draw(at point: CGPoint, in context: CGContext) {
897        if let band, let rect = bandRect {
898            context.saveGState()
899            context.setFillColor(band.color.cgColor)
900            context.fill(rect.offsetBy(dx: point.x, dy: point.y))
901            context.restoreGState()
902        }
903        super.draw(at: point, in: context)
904        UIGraphicsPushContext(context)
905        defer { UIGraphicsPopContext() }
906        drawEntities(at: point)
907        if let image, let rect = imageRect { image.image.draw(in: rect.offsetBy(dx: point.x, dy: point.y)) }
908        if folded, let line = textLineFragments.first(where: { $0.characterRange.length > 0 }) ?? textLineFragments.first {
909            let bounds = line.typographicBounds
910            let ellipsis = NSAttributedString(string: " …", attributes: [
911                .font: UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular),
912                .foregroundColor: UIColor.secondaryLabel,
913            ])
914            ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
915        }
916    }
917
918    private func drawEntities(at point: CGPoint) {
919        guard let string = (textElement as? NSTextParagraph)?.attributedString else { return }
920        string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in
921            guard let display = value as? String,
922                  let line = textLineFragments.first(where: { NSLocationInRange(range.location, $0.characterRange) }) else { return }
923            var attributes = string.attributes(at: range.location, effectiveRange: nil)
924            attributes[.foregroundColor] = attributes[.orgEntityColor] ?? UIColor.label
925            attributes[.orgEntityDisplay] = nil
926            attributes[.orgEntityColor] = nil
927            let x = line.locationForCharacter(at: range.location).x
928            let bounds = line.typographicBounds
929            NSAttributedString(string: display, attributes: attributes).draw(at: CGPoint(x: point.x + bounds.minX + x, y: point.y + bounds.minY))
930        }
931    }
932}
933#endif