Sources/OrgstarMobile/MobileEditor.swift
1091 lines · 54179 bytes
64 symbols in this file
LockedRangessetcoversisFoldedcontainingFragmentStylesetsetKeyedTextViewlayoutSubviewspressesBeganpressesEndedMobileEditorupdateperformunavailablerunrunapplieschordhandleKeyeditContextopenLinkAtCaretapplyEditsstorageEditedmaploadapplyThemeToViewthemeSelectionredrawSelectedBlocksviewResizedrestyleAllscheduleRestylerestyleparagraphStyleprettifyrevealCaretLinetableColumnWidthsetShrunkapplyShrunktoggleImagesloadImageapplyImagessetFoldsupdateHiddencycleAtCaretcycleGlobalunfoldrevealselectionChangedtextViewDidChangeSelectiontextViewtextStoragetextContentManagertextLayoutManagerInlineImageBoxSelectionHighlightViewdrawOrgLineFragmentimagehasEntitiesdrawdrawSelectiondrawEntities
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.") || Self.linkCommands.contains(id):
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 /// Link commands that need the stored links or the file, which the screen has.
348 static let linkCommands: Set<String> = ["org.link.store", "org.id.store-link", "org.link.insert"]
349
350 func applies(_ id: String, in context: EditContext) -> Bool {
351 if let command = Commands.command(id) { return command.applies(in: context) }
352 switch id {
353 case "org.cycle":
354 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
355 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true
356 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.clock.in-recent", "app.clock.in-last",
357 "app.clock.goto-recent", "app.clock.default", "app.clock.resolve", "app.toggle-markup": return onAppCommand != nil
358 case _ where Self.linkCommands.contains(id): return onAppCommand != nil
359 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
360 case "org.table.toggle-column-width", "org.table.shrink", "org.table.expand":
361 return TableShrink.table(at: context.caret, in: context.text) != nil
362 default: return false
363 }
364 }
365
366 // MARK: - Hardware keyboard
367
368 private lazy var dispatcher = KeyDispatcher(keymap: keymap)
369
370 private static let named: [UIKeyboardHIDUsage: String] = [
371 .keyboardTab: "TAB", .keyboardReturnOrEnter: "RET", .keypadEnter: "RET", .keyboardSpacebar: "SPC",
372 .keyboardEscape: "ESC", .keyboardDeleteOrBackspace: "DEL", .keyboardDeleteForward: "<delete>",
373 .keyboardLeftArrow: "<left>", .keyboardRightArrow: "<right>", .keyboardUpArrow: "<up>", .keyboardDownArrow: "<down>",
374 .keyboardHome: "<home>", .keyboardEnd: "<end>", .keyboardPageUp: "<prior>", .keyboardPageDown: "<next>",
375 ]
376
377 /// A key press as a chord; Option is Meta, as `option-as-meta` makes it on the Mac.
378 static func chord(_ key: UIKey) -> KeyChord? {
379 var modifiers: KeyChord.Modifiers = []
380 let flags = key.modifierFlags
381 if flags.contains(.control) { modifiers.insert(.control) }
382 if flags.contains(.alternate) { modifiers.insert(.meta) }
383 if flags.contains(.command) { modifiers.insert(.command) }
384 if let name = named[key.keyCode] {
385 if flags.contains(.shift) { modifiers.insert(.shift) }
386 return KeyChord(name, modifiers)
387 }
388 var characters = key.charactersIgnoringModifiers
389 if flags.contains(.shift), characters.count == 1, characters.lowercased() == characters { characters = characters.uppercased() }
390 guard characters.count == 1 else { return nil }
391 return KeyChord(characters, modifiers)
392 }
393
394 /// The Emacs keymap's commands on an iPad keyboard. Keys it doesn't bind, Command shortcuts
395 /// and editing keys (C-a, C-e, C-k...) are the text system's.
396 func handleKey(_ key: UIKey) -> Bool {
397 guard let chord = Self.chord(key), !chord.modifiers.contains(.command) else { return false }
398 // Option alone types characters unless it begins a binding.
399 if chord.modifiers == [.meta], dispatcher.pending.isEmpty,
400 keymap.candidates(for: [chord]).isEmpty, !keymap.isPrefix([chord]) { return false }
401 switch dispatcher.feed(chord) {
402 case .pending(let keys):
403 onMessage?(KeySequence.format(keys) + "-")
404 return true
405 case .cancelled:
406 onMessage?("Quit")
407 return true
408 case .undefined(let keys):
409 onMessage?("\(KeySequence.format(keys)) is undefined")
410 return true
411 case .unbound:
412 return false
413 case .complete(let keys, let candidates):
414 let context = editContext()
415 let runnable = candidates.first { binding in
416 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
417 }
418 // A sequence runs its command even where it doesn't apply, which says why, as on the Mac.
419 guard let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) else { return false }
420 if keys.count == 1, binding.command.hasPrefix("edit."), !["edit.undo", "edit.redo"].contains(binding.command) { return false }
421 run(binding.command)
422 return true
423 }
424 }
425
426 func editContext() -> EditContext {
427 EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: selection, hidden: hidden.all, options: options, knownTags: knownTags)
428 }
429
430 private func openLinkAtCaret() {
431 let caret = textView.selectedRange.location
432 if let link = Links.link(at: caret, text: document.text, tree: document.tree)?.link {
433 onLink?(link)
434 } else if let days = Links.timestampDays(at: caret, text: document.text) {
435 onAgenda?(days.start, days.count)
436 } else {
437 onMessage?("No link here")
438 }
439 }
440
441 // MARK: - Editing
442
443 /// Applies `edits` (against the current text) through the text view as one undo step.
444 func applyEdits(_ edits: [TextEdit]) {
445 guard !edits.isEmpty else { return }
446 applyingEdits = true
447 defer { applyingEdits = false }
448 // Straight into the storage: replacing through text input would take the active
449 // keyboard's substitutions (smart dashes) when another view has the keyboard.
450 let storage = textView.textStorage
451 let old = storage.string as NSString
452 // By lines, so a fold between two changes stays.
453 let ascending = edits.flatMap { splitByLines($0, in: storage.string) }.sorted { $0.range.lowerBound < $1.range.lowerBound }
454 var inverse: [TextEdit] = []
455 var delta = 0
456 for edit in ascending {
457 let start = edit.range.lowerBound + delta
458 let length = (edit.replacement as NSString).length
459 inverse.append(TextEdit(range: start..<start + length, replacement: old.substring(with: NSRange(edit.range))))
460 delta += length - edit.range.count
461 }
462 let caret = textView.selectedRange
463 // One at a time, last first: each is processed alone, so folds between them stay.
464 for edit in ascending.reversed() {
465 storage.replaceCharacters(in: NSRange(edit.range), with: edit.replacement)
466 }
467 textView.undoManager?.registerUndo(withTarget: self) { editor in
468 editor.applyEdits(inverse)
469 editor.textView.selectedRange = caret
470 }
471 }
472
473 /// The storage changed (typing, a command, undo): the document takes the same edit, and
474 /// folds and styles follow.
475 fileprivate func storageEdited(range: NSRange, delta: Int) {
476 guard !loading else { return }
477 let old = range.location..<(range.location + range.length - delta)
478 let replacement = (textView.textStorage.string as NSString).substring(with: range)
479 let edit = TextEdit(range: old, replacement: replacement)
480 do {
481 try document.apply([edit], baseRevision: document.revision)
482 } catch {
483 // Out of step: the view's text wins.
484 shrunk = ShrunkColumns()
485 load(textView.textStorage.string)
486 return
487 }
488 for table in shrunk.map(through: edit, in: textView.textStorage.string, command: commandEditing) { scheduleRestyle(table) }
489 fold = FoldState(
490 folds: Self.map(fold.folds, old, delta), bodyFolds: Self.map(fold.bodyFolds, old, delta),
491 wrapperFolds: Self.map(fold.wrapperFolds, old, delta)
492 )
493 scheduleRestyle(range.location..<NSMaxRange(range))
494 onChange?()
495 }
496
497 /// Starts after the edit move with it; ones inside it go.
498 private static func map(_ starts: Set<Int>, _ old: Range<Int>, _ delta: Int) -> Set<Int> {
499 Set(starts.compactMap { start in
500 if start >= old.upperBound { return start + delta }
501 return start <= old.lowerBound ? start : nil
502 })
503 }
504
505 // MARK: - Styling
506
507 private func load(_ text: String) {
508 loading = true
509 textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
510 loading = false
511 shaded.set(Presentation.shadedBlocks(document.tree), folded: [])
512 restyle(0..<(text as NSString).length)
513 textView.typingAttributes = theme.base
514 }
515
516 /// The text view's own colors, and the block band's.
517 private func applyThemeToView() {
518 textView.backgroundColor = theme.color("background") ?? .systemBackground
519 textView.tintColor = theme.color("cursor")
520 textView.typingAttributes = theme.base
521 fragmentStyle.set(blockBackground: theme.color("block-background") ?? .clear, width: textView.bounds.width)
522 themeSelection()
523 }
524
525 /// The selection in the theme's selection color: the text view's selection display
526 /// interaction draws it with our highlight view; the cursor and handles keep the tint.
527 private func themeSelection() {
528 guard let interaction = textView.interactions.lazy.compactMap({ $0 as? UITextSelectionDisplayInteraction }).first else { return }
529 let view = interaction.highlightView as? SelectionHighlightView ?? SelectionHighlightView()
530 view.color = theme.color("selection") ?? UIColor.tintColor.withAlphaComponent(0.25)
531 if interaction.highlightView !== view { interaction.highlightView = view }
532 selectionColor = view.color
533 }
534
535 /// The highlight view draws under the text, so a block's band would cover it: blocks the
536 /// selection enters or leaves are laid out again to draw it over their band.
537 private func redrawSelectedBlocks() {
538 let selected = textView.selectedRange
539 let range = selected.length > 0 ? selected.location..<NSMaxRange(selected) : nil
540 let old = fragmentStyle.selection?.range
541 fragmentStyle.set(selection: range, color: selectionColor)
542 guard let manager = textView.textLayoutManager, let content = manager.textContentManager else { return }
543 let touched = [old, range].compactMap { $0 }
544 for block in shaded.all where touched.contains(where: { $0.overlaps(block) }) {
545 guard let start = content.location(content.documentRange.location, offsetBy: block.lowerBound),
546 let end = content.location(start, offsetBy: block.count),
547 let textRange = NSTextRange(location: start, end: end) else { continue }
548 // Fragments are kept across layout passes, so they are asked to draw again too.
549 manager.invalidateRenderingAttributes(for: textRange)
550 manager.invalidateLayout(for: textRange)
551 }
552 textView.setNeedsDisplay()
553 }
554
555 /// The band behind blocks spans the view's width, or the widest line's when truncated;
556 /// images are as wide as the view allows.
557 private func viewResized() {
558 let band = fragmentStyle.blockBand
559 let width = max(textView.bounds.width, truncatesLines ? textView.contentSize.width : 0)
560 let resized = imageWidth != textView.bounds.width
561 guard band.width != width || resized else { return }
562 fragmentStyle.set(blockBackground: band.color, width: width)
563 imageWidth = textView.bounds.width
564 if showsImages, resized { restyle(0..<textView.textStorage.length) }
565 if let manager = textView.textLayoutManager {
566 manager.invalidateRenderingAttributes(for: manager.documentRange)
567 manager.invalidateLayout(for: manager.documentRange)
568 }
569 }
570
571 /// Styles the whole file again.
572 private func restyleAll() {
573 restyle(0..<textView.textStorage.length)
574 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
575 }
576
577 /// Styles are set after the edit has been processed, as TextKit asks.
578 private func scheduleRestyle(_ range: Range<Int>) {
579 if let pending = restyleScheduled {
580 restyleScheduled = min(pending.lowerBound, range.lowerBound)..<max(pending.upperBound, range.upperBound)
581 return
582 }
583 restyleScheduled = range
584 DispatchQueue.main.async { [weak self] in
585 guard let self, let range = self.restyleScheduled else { return }
586 self.restyleScheduled = nil
587 self.shaded.set(Presentation.shadedBlocks(self.document.tree), folded: [])
588 // Small files are restyled whole: a heading or block line changes what follows it.
589 let length = self.textView.textStorage.length
590 self.restyle(length < 60_000 ? 0..<length : range)
591 self.updateHidden()
592 self.textView.typingAttributes = self.theme.base
593 }
594 }
595
596 private var caretLine: NSRange {
597 let text = textView.textStorage.string as NSString
598 return text.lineRange(for: NSRange(location: min(textView.selectedRange.location, text.length), length: 0))
599 }
600
601 /// Sets attributes for the whole lines in `range`, as the Mac editor does.
602 private func restyle(_ range: Range<Int>) {
603 let storage = textView.textStorage
604 let ns = storage.string as NSString
605 guard ns.length > 0 else { return }
606 let lower = min(range.lowerBound, ns.length)
607 let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower)))
608 let whole = lines.location..<NSMaxRange(lines)
609 storage.beginEditing()
610 storage.setAttributes(theme.base, range: lines)
611 if indented {
612 for line in Presentation.lineIndents(document.tree, in: whole) {
613 storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
614 }
615 }
616 let revealed = caretLine
617 var scriptExclusions: [Range<Int>] = []
618 for run in Presentation.styles(document.tree, in: whole, highlighter: Self.highlighter) {
619 let clipped = NSIntersectionRange(NSRange(run.range), lines)
620 guard clipped.length > 0 else { continue }
621 if Presentation.skipsLeadingStars(run.role, indented: indented, hideStars: hideStars) { continue }
622 if !showMarkup, Presentation.hidesWithMarkup(run.role, options: options), NSIntersectionRange(clipped, revealed).length == 0 {
623 storage.addAttributes(theme.hidden, range: clipped)
624 } else {
625 theme.apply(run.role, to: storage, range: clipped)
626 }
627 if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) }
628 }
629 if !showMarkup, options.prettyEntities { prettify(whole, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
630 applyShrunk(whole, in: storage)
631 applyImages(whole, in: storage, revealed: revealed)
632 storage.endEditing()
633 }
634
635 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
636 if let style = paragraphStyles[[firstLine, wrapped]] { return style }
637 let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
638 paragraphStyles[[firstLine, wrapped]] = style
639 return style
640 }
641
642 /// `org-pretty-entities`: entities drawn as their character over the entity's last
643 /// character (the rest hidden), sub/superscripts raised without their markers.
644 private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) {
645 let runs = Presentation.prettyRuns(
646 storage.string as NSString, in: range, revealedLine: revealed.location, inBlock: { shaded.containing($0) != nil },
647 scriptExclusions: scriptExclusions, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces
648 )
649 for (lineStart, run) in runs {
650 switch run {
651 case .entity(let r, let display):
652 let start = lineStart + r.lowerBound
653 let last = lineStart + r.upperBound - 1
654 let color = storage.attribute(.foregroundColor, at: last, effectiveRange: nil) as? UIColor ?? theme.foreground
655 if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
656 storage.addAttributes([.foregroundColor: UIColor.clear, .orgEntityDisplay: display, .orgEntityColor: color], range: NSRange(location: last, length: 1))
657 case .script(let markers, let body, let superscript):
658 for marker in markers {
659 storage.addAttributes(theme.hidden, range: NSRange(location: lineStart + marker.lowerBound, length: marker.count))
660 }
661 let size = theme.font.pointSize
662 storage.addAttributes([
663 .font: theme.font.withSize(size * 0.7),
664 .baselineOffset: (superscript ? 0.3 : -0.3) * size,
665 ], range: NSRange(location: lineStart + body.lowerBound, length: body.count))
666 }
667 }
668 }
669
670 /// Moving to another line shows its markup and hides the last one's.
671 private func revealCaretLine() {
672 guard !showMarkup else { return }
673 let line = caretLine
674 guard line != revealedLine else { return }
675 let text = textView.textStorage.string as NSString
676 // The old line may have moved with edits since; restyle the line now at its start.
677 let old = revealedLine.flatMap { $0.location <= text.length ? text.lineRange(for: NSRange(location: $0.location, length: 0)) : nil }
678 revealedLine = line
679 for range in [old, line].compactMap({ $0 }) {
680 restyle(range.location..<NSMaxRange(range))
681 }
682 }
683
684 // MARK: - Shrunk table columns
685
686 /// C-c TAB in a table: `org-table-toggle-column-width`; `shrink` for `org-table-shrink`,
687 /// `expand` for `org-table-expand`.
688 private func tableColumnWidth(_ id: String) {
689 switch shrunk.change(for: id, at: textView.selectedRange.location, in: textView.textStorage.string) {
690 case .notInTable: onMessage?("Not in a table")
691 case .set(let columns, let table): setShrunk(columns, in: table)
692 case .ask(let table, let current, let max):
693 onPrompt?(Prompt(key: "columns", message: "Column ranges (e.g. 2-4 6-):")) { [weak self] answer in
694 guard let self, let answer else { return }
695 self.setShrunk(ShrunkColumns.toggled(current, TableShrink.columns(answer, max: max)), in: table)
696 }
697 }
698 }
699
700 /// Narrows `columns` of `table` and widens its others.
701 private func setShrunk(_ columns: [Int], in table: Range<Int>) {
702 shrunk.set(columns, in: table, text: textView.textStorage.string)
703 restyleAll()
704 }
705
706 /// Hides each shrunk segment's text and draws its display where the text was, as pretty
707 /// entities draw theirs.
708 private func applyShrunk(_ range: Range<Int>, in storage: NSTextStorage) {
709 guard !shrunk.isEmpty else { return }
710 let cell = theme.column
711 for segment in shrunk.segments where segment.range.overlaps(range) && segment.range.upperBound <= storage.length {
712 let r = segment.range
713 let shown = segment.display.count
714 let kept = min(shown, r.count)
715 if r.count > kept { storage.addAttributes(theme.hidden, range: NSRange(location: r.lowerBound, length: r.count - kept)) }
716 guard kept > 0 else { continue }
717 let visible = NSRange(location: r.upperBound - kept, length: kept)
718 let color = storage.attribute(.foregroundColor, at: visible.location, effectiveRange: nil) as? UIColor ?? theme.foreground
719 storage.addAttribute(.foregroundColor, value: UIColor.clear, range: visible)
720 storage.addAttributes([.orgEntityDisplay: segment.display, .orgEntityColor: color], range: NSRange(location: visible.location, length: 1))
721 if shown > kept { storage.addAttribute(.kern, value: CGFloat(shown - kept) * cell, range: NSRange(location: NSMaxRange(visible) - 1, length: 1)) }
722 }
723 }
724
725 // MARK: - Inline images
726
727 private func toggleImages() {
728 showsImages.toggle()
729 restyleAll()
730 if showsImages {
731 let ns = textView.textStorage.string as NSString
732 var count = 0
733 var offset = 0
734 while offset < ns.length {
735 let line = ns.lineRange(for: NSRange(location: offset, length: 0))
736 if let image = InlineImages.image(atLine: line.location, in: ns), Self.loadImage(image.path, relativeTo: baseDirectory) != nil { count += 1 }
737 offset = NSMaxRange(line)
738 }
739 onMessage?("\(count) images displayed inline")
740 } else {
741 onMessage?("Inline image display turned off")
742 }
743 }
744
745 /// An image link's image, by its path or one relative to `baseDirectory`.
746 static func loadImage(_ path: String, relativeTo baseDirectory: URL?) -> UIImage? {
747 let expanded = (path as NSString).expandingTildeInPath
748 let url: URL
749 if expanded.hasPrefix("/") {
750 url = URL(fileURLWithPath: expanded)
751 } else if let baseDirectory {
752 url = baseDirectory.appendingPathComponent(expanded)
753 } else {
754 return nil
755 }
756 let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate?.timeIntervalSince1970 ?? 0
757 let key = "\(url.path)|\(modified)" as NSString
758 if let cached = Self.imageCache.object(forKey: key) { return cached }
759 guard let image = UIImage(contentsOfFile: url.path) else { return nil }
760 Self.imageCache.setObject(image, forKey: key)
761 return image
762 }
763
764 /// Image lines in `range` show the image over the link, which is hidden unless the caret is
765 /// on the line.
766 private func applyImages(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange) {
767 guard showsImages else { return }
768 let text = storage.string as NSString
769 let inset = textView.textContainerInset
770 let maxWidth = max(80, textView.bounds.width - inset.left - inset.right - 40)
771 var location = range.lowerBound
772 while location < range.upperBound, location < text.length {
773 let line = text.lineRange(for: NSRange(location: location, length: 0))
774 location = NSMaxRange(line)
775 guard let image = InlineImages.image(atLine: line.location, in: text), let picture = Self.loadImage(image.path, relativeTo: baseDirectory) else { continue }
776 let width = min(image.width.map(CGFloat.init) ?? picture.size.width, maxWidth)
777 let height = picture.size.width > 0 ? width * picture.size.height / picture.size.width : 0
778 let content = NSRange(location: line.location, length: line.length - (text.substring(with: line).hasSuffix("\n") ? 1 : 0))
779 // Invisible, keeping the line's height.
780 if line.location != revealed.location, content.length > 0 {
781 storage.addAttributes([.foregroundColor: UIColor.clear, .underlineStyle: 0], range: content)
782 }
783 storage.addAttribute(.orgImage, value: InlineImageBox(image: picture, size: CGSize(width: width, height: height)), range: NSRange(location: line.location, length: max(1, content.length)))
784 let style = (storage.attribute(.paragraphStyle, at: line.location, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
785 // The image sits where the line is; the spacing makes room for the rest of it.
786 style.paragraphSpacing = max(0, height + 4 - theme.font.lineHeight)
787 storage.addAttribute(.paragraphStyle, value: style, range: line)
788 }
789 }
790
791 // MARK: - Truncation
792
793 /// `toggle-truncate-lines`: long lines run off the right edge, scrolling sideways, instead
794 /// of wrapping.
795 var truncatesLines: Bool {
796 get { (textView as? KeyedTextView)?.truncates ?? false }
797 set {
798 guard newValue != truncatesLines, let view = textView as? KeyedTextView else { return }
799 view.truncates = newValue
800 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
801 }
802 }
803
804 // MARK: - Folding
805
806 func setFolds(_ state: FoldState) {
807 fold = state
808 updateHidden()
809 }
810
811 private func updateHidden() {
812 let tree = document.tree
813 let wrappers = fold.wrapperFolds.compactMap { Wrappers.starting(at: $0, in: tree)?.hidden }
814 hidden.set(
815 Presentation.hiddenRanges(tree, folds: fold.folds, bodyFolds: fold.bodyFolds, wrapperRanges: wrappers),
816 folded: fold.folds.union(fold.bodyFolds).union(fold.wrapperFolds)
817 )
818 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
819 textView.setNeedsDisplay()
820 }
821
822 /// TAB at the caret: a heading cycles, a drawer or block opens or closes.
823 @discardableResult
824 func cycleAtCaret() -> Bool {
825 guard let next = Presentation.cycle(at: textView.selectedRange.location, in: document.tree, fold) else { return false }
826 setFolds(next)
827 return true
828 }
829
830 /// Shift-TAB: overview, contents, show all.
831 func cycleGlobal() {
832 let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree, wrapperFolds: fold.wrapperFolds)
833 globalVisibility = visibility
834 setFolds(state)
835 }
836
837 /// Opens the folds hiding `offset`.
838 func unfold(around offset: Int) {
839 var state = fold
840 for entry in Presentation.outline(document.tree) where entry.start < offset && offset < entry.end {
841 state.folds.remove(entry.start)
842 state.bodyFolds.remove(entry.start)
843 }
844 for start in state.wrapperFolds where Wrappers.starting(at: start, in: document.tree)?.hidden.contains(offset) == true {
845 state.wrapperFolds.remove(start)
846 }
847 if state != fold { setFolds(state) }
848 }
849
850 /// Shows the heading at `offset`, unfolded, at the top.
851 func reveal(_ offset: Int) {
852 let length = textView.textStorage.length
853 let target = min(max(0, offset), length)
854 unfold(around: target)
855 textView.selectedRange = NSRange(location: target, length: 0)
856 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
857 }
858
859 /// The caret left hidden text (a tap past a fold): what hides it opens.
860 fileprivate func selectionChanged() {
861 redrawSelectedBlocks()
862 let caret = textView.selectedRange.location
863 if hidden.containing(caret) != nil { unfold(around: caret) }
864 revealCaretLine()
865 }
866}
867
868extension MobileEditor: UITextViewDelegate {
869 func textViewDidChangeSelection(_ textView: UITextView) {
870 selectionChanged()
871 }
872
873 /// `electric-pair-mode` for typed brackets and quotes, DEL and RET, from either keyboard.
874 func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
875 guard options.electricPair, !applyingEdits, textView.markedTextRange == nil else { return true }
876 let selected = textView.selectedRange
877 let key: String
878 if text.isEmpty, selected.length == 0, range.length == 1, NSMaxRange(range) == selected.location {
879 key = "DEL"
880 } else if range == selected, text == "\n" {
881 key = "RET"
882 } else if range == selected, ElectricPairKey.characters.contains(text) {
883 key = text
884 } else {
885 return true
886 }
887 let command = ElectricPairKey(key: key)
888 guard command.applies(in: editContext()) else { return true }
889 _ = perform(command)
890 return false
891 }
892}
893
894extension MobileEditor: NSTextStorageDelegate {
895 nonisolated func textStorage(
896 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int
897 ) {
898 guard editedMask.contains(.editedCharacters) else { return }
899 MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
900 }
901}
902
903extension MobileEditor: NSTextContentStorageDelegate {
904 /// Folded text isn't enumerated, so it isn't laid out or drawn.
905 nonisolated func textContentManager(
906 _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
907 ) -> Bool {
908 guard let range = textElement.elementRange else { return true }
909 let documentStart = textContentManager.documentRange.location
910 let start = textContentManager.offset(from: documentStart, to: range.location)
911 let end = textContentManager.offset(from: documentStart, to: range.endLocation)
912 return !hidden.covers(start, end)
913 }
914}
915
916extension MobileEditor: NSTextLayoutManagerDelegate {
917 /// Folded headings, drawers and blocks end with `…`; blocks are on a band; image lines
918 /// show their image; pretty entities draw their character.
919 nonisolated func textLayoutManager(
920 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
921 ) -> NSTextLayoutFragment {
922 let range = textElement.elementRange
923 guard let range, let manager = textLayoutManager.textContentManager else { return NSTextLayoutFragment(textElement: textElement, range: range) }
924 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
925 let folded = hidden.isFolded(offset)
926 let band = shaded.containing(offset) != nil ? fragmentStyle.blockBand : nil
927 let string = (textElement as? NSTextParagraph)?.attributedString
928 let image = string.flatMap(OrgLineFragment.image)
929 let entities = string.map(OrgLineFragment.hasEntities) ?? false
930 guard folded || band != nil || image != nil || entities else { return NSTextLayoutFragment(textElement: textElement, range: range) }
931 let fragment = OrgLineFragment(textElement: textElement, range: range)
932 fragment.folded = folded
933 fragment.band = band
934 if band != nil {
935 fragment.start = offset
936 fragment.style = fragmentStyle
937 }
938 fragment.image = image
939 return fragment
940 }
941}
942
943extension NSAttributedString.Key {
944 /// The character an entity shows as, on the entity's last character.
945 static let orgEntityDisplay = NSAttributedString.Key("OrgEntityDisplay")
946 /// The color the entity's character is drawn in.
947 static let orgEntityColor = NSAttributedString.Key("OrgEntityColor")
948 /// The image an image link's line shows under it.
949 static let orgImage = NSAttributedString.Key("OrgImage")
950}
951
952final class InlineImageBox: NSObject, @unchecked Sendable {
953 let image: UIImage
954 let size: CGSize
955 init(image: UIImage, size: CGSize) {
956 self.image = image
957 self.size = size
958 }
959}
960
961/// Selected text's background, in one color however the rects overlap.
962final class SelectionHighlightView: UIView, UITextSelectionHighlightView {
963 var selectionRects: [UITextSelectionRect] = [] { didSet { setNeedsDisplay() } }
964 var color: UIColor = .clear { didSet { setNeedsDisplay() } }
965
966 override init(frame: CGRect) {
967 super.init(frame: frame)
968 isOpaque = false
969 backgroundColor = .clear
970 isUserInteractionEnabled = false
971 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: SelectionHighlightView, _) in view.setNeedsDisplay() }
972 }
973
974 required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
975
976 override func draw(_ rect: CGRect) {
977 let path = UIBezierPath()
978 for selection in selectionRects where !selection.rect.isEmpty { path.append(UIBezierPath(rect: selection.rect)) }
979 color.setFill()
980 path.fill()
981 }
982}
983
984/// A line drawn with more than its text, as the Mac editor's fragments draw them: a folded
985/// line's ellipsis, a block's band across the view, an image under an image link, and pretty
986/// entities' characters where the entities' last (invisible) characters sit.
987final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
988 var folded = false
989 var band: (color: UIColor, width: CGFloat)?
990 var image: InlineImageBox?
991 /// For a block's lines: where the fragment starts, and where the selection is read.
992 var start = 0
993 var style: FragmentStyle?
994
995 static func image(_ string: NSAttributedString) -> InlineImageBox? {
996 var found: InlineImageBox?
997 string.enumerateAttribute(.orgImage, in: NSRange(location: 0, length: string.length)) { value, _, stop in
998 if let box = value as? InlineImageBox { found = box; stop.pointee = true }
999 }
1000 return found
1001 }
1002
1003 static func hasEntities(_ string: NSAttributedString) -> Bool {
1004 var found = false
1005 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, _, stop in
1006 if value != nil { found = true; stop.pointee = true }
1007 }
1008 return found
1009 }
1010
1011 private var bandRect: CGRect? {
1012 guard let band else { return nil }
1013 // Not under the empty line TextKit adds after a final newline.
1014 let height = textLineFragments.last { $0.characterRange.length > 0 }?.typographicBounds.maxY ?? layoutFragmentFrame.height
1015 // The width as it is now: the view may have been laid out since this fragment was made.
1016 let width = style?.blockBand.width ?? band.width
1017 return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: max(width, layoutFragmentFrame.maxX), height: height)
1018 }
1019
1020 private var imageRect: CGRect? {
1021 guard let image, let line = textLineFragments.first else { return nil }
1022 return CGRect(x: line.typographicBounds.minX, y: line.typographicBounds.minY + 2, width: image.size.width, height: image.size.height)
1023 }
1024
1025 override var renderingSurfaceBounds: CGRect {
1026 var bounds = super.renderingSurfaceBounds
1027 if folded { bounds = bounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0) }
1028 for rect in [bandRect, imageRect].compactMap({ $0 }) { bounds = bounds.union(rect) }
1029 return bounds
1030 }
1031
1032 override func draw(at point: CGPoint, in context: CGContext) {
1033 if let band, let rect = bandRect {
1034 context.saveGState()
1035 context.setFillColor(band.color.cgColor)
1036 context.fill(rect.offsetBy(dx: point.x, dy: point.y))
1037 context.restoreGState()
1038 drawSelection(at: point, in: context, bandRect: rect)
1039 }
1040 super.draw(at: point, in: context)
1041 UIGraphicsPushContext(context)
1042 defer { UIGraphicsPopContext() }
1043 drawEntities(at: point)
1044 if let image, let rect = imageRect { image.image.draw(in: rect.offsetBy(dx: point.x, dy: point.y)) }
1045 if folded, let line = textLineFragments.first(where: { $0.characterRange.length > 0 }) ?? textLineFragments.first {
1046 let bounds = line.typographicBounds
1047 let ellipsis = NSAttributedString(string: " …", attributes: [
1048 .font: UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular),
1049 .foregroundColor: UIColor.secondaryLabel,
1050 ])
1051 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
1052 }
1053 }
1054
1055 /// The selected part of each line, over the band; a selection running past a line's end
1056 /// fills to the band's edge.
1057 private func drawSelection(at point: CGPoint, in context: CGContext, bandRect: CGRect) {
1058 guard let selection = style?.selection else { return }
1059 context.saveGState()
1060 context.setFillColor(selection.color.cgColor)
1061 for line in textLineFragments where line.characterRange.length > 0 {
1062 let lineStart = start + line.characterRange.location
1063 let lineEnd = lineStart + line.characterRange.length
1064 let from = max(selection.range.lowerBound, lineStart), to = min(selection.range.upperBound, lineEnd)
1065 guard from < to || (from == to && selection.range.upperBound > lineEnd && from == lineEnd) else { continue }
1066 let bounds = line.typographicBounds
1067 // From the margin when the selection began on an earlier line, as the system draws it.
1068 let margin = bandRect.minX + (textLayoutManager?.textContainer?.lineFragmentPadding ?? 0)
1069 let x1 = selection.range.lowerBound < lineStart ? margin : bounds.minX + line.locationForCharacter(at: from - start).x
1070 let x2 = selection.range.upperBound >= lineEnd ? bandRect.maxX : bounds.minX + line.locationForCharacter(at: to - start).x
1071 context.fill(CGRect(x: point.x + x1, y: point.y + bounds.minY, width: max(0, x2 - x1), height: bounds.height))
1072 }
1073 context.restoreGState()
1074 }
1075
1076 private func drawEntities(at point: CGPoint) {
1077 guard let string = (textElement as? NSTextParagraph)?.attributedString else { return }
1078 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in
1079 guard let display = value as? String,
1080 let line = textLineFragments.first(where: { NSLocationInRange(range.location, $0.characterRange) }) else { return }
1081 var attributes = string.attributes(at: range.location, effectiveRange: nil)
1082 attributes[.foregroundColor] = attributes[.orgEntityColor] ?? UIColor.label
1083 attributes[.orgEntityDisplay] = nil
1084 attributes[.orgEntityColor] = nil
1085 let x = line.locationForCharacter(at: range.location).x
1086 let bounds = line.typographicBounds
1087 NSAttributedString(string: display, attributes: attributes).draw(at: CGPoint(x: point.x + bounds.minX + x, y: point.y + bounds.minY))
1088 }
1089 }
1090}
1091#endif