krz/orgstar

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

Sources/OrgstarMobile/MobileEditor.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgstarMobile/MobileEditor.swift history · blame · raw

1087 lines · 53878 bytes

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