Sources/OrgstarMobile/MobileEditor.swift
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