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