| @@ -17,12 +17,56 @@ final class LockedRanges: @unchecked Sendable { |
| 17 | 17 | func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } } |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | /// What layout fragments draw beyond the text, read by TextKit's delegate callbacks. |
| 21 | final 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 | |
| 20 | 29 | /// A text view that offers hardware-keyboard presses to the editor's keymap first. |
| 21 | 30 | final class KeyedTextView: UITextView { |
| 22 | 31 | var keyHandler: ((UIKey) -> Bool)? |
| 23 | 32 | /// Keys the editor took, whose release the text system doesn't see. Optional: made by |
| 24 | 33 | /// UIKit's `init(usingTextLayoutManager:)`, the view skips Swift's default values. |
| 25 | 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 | } |
| 26 | 70 | |
| 27 | 71 | override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) { |
| 28 | 72 | var rest: Set<UIPress> = [] |
| @@ -55,7 +99,29 @@ final class MobileEditor: NSObject, DocumentBuffer { |
| 55 | 99 | private(set) var document: DocumentState |
| 56 | 100 | let textView: UITextView |
| 57 | 101 | var options: EditingOptions = .org { |
| 58 | | didSet { textView.spellCheckingType = options.spellCheck ? .yes : .no } |
| 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() } } |
| 59 | 125 | } |
| 60 | 126 | var keymap = KeymapPreset.emacs.keymap |
| 61 | 127 | var onChange: (() -> Void)? |
| @@ -79,19 +145,44 @@ final class MobileEditor: NSObject, DocumentBuffer { |
| 79 | 145 | /// Tags used across the workspace, for completion. |
| 80 | 146 | var knownTags: @Sendable () -> [String] = { [] } |
| 81 | 147 | |
| 82 | | private let theme = MobileTheme() |
| 148 | private var theme: MobileTheme |
| 83 | 149 | private(set) var fold = FoldState() |
| 84 | 150 | private var globalVisibility: GlobalVisibility? |
| 85 | 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>() |
| 86 | 169 | private var loading = false |
| 87 | 170 | private var restyleScheduled: Range<Int>? |
| 88 | 171 | |
| 89 | | init(document: DocumentState, editable: Bool) { |
| 172 | init( |
| 173 | document: DocumentState, editable: Bool, options: EditingOptions = .org, theme spec: ThemeSpec = .default, |
| 174 | showMarkup: Bool = true, baseDirectory: URL? = nil |
| 175 | ) { |
| 90 | 176 | self.document = document |
| 177 | self.options = options |
| 178 | self.showMarkup = showMarkup |
| 179 | self.baseDirectory = baseDirectory |
| 180 | theme = MobileTheme(spec) |
| 91 | 181 | let keyed = KeyedTextView(usingTextLayoutManager: true) |
| 92 | 182 | textView = keyed |
| 93 | 183 | super.init() |
| 94 | 184 | keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false } |
| 185 | keyed.onLayout = { [weak self] in self?.viewResized() } |
| 95 | 186 | textView.isEditable = editable |
| 96 | 187 | textView.autocorrectionType = .no |
| 97 | 188 | textView.autocapitalizationType = .none |
| @@ -106,11 +197,15 @@ final class MobileEditor: NSObject, DocumentBuffer { |
| 106 | 197 | textView.textStorage.delegate = self |
| 107 | 198 | textView.textLayoutManager?.delegate = self |
| 108 | 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() |
| 109 | 205 | load(document.text) |
| 110 | | let tree = document.tree |
| 111 | | var state = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree) |
| 112 | | state.wrapperFolds = Presentation.startupWrapperFolds(tree) |
| 113 | | setFolds(state) |
| 206 | setFolds(startup.folds) |
| 207 | if startup.alignTables { _ = perform(AlignAllTables()) } |
| 208 | truncatesLines = startup.truncated |
| 114 | 209 | textView.selectedRange = NSRange(location: 0, length: 0) |
| 115 | 210 | } |
| 116 | 211 | |
| @@ -215,6 +310,10 @@ final class MobileEditor: NSObject, DocumentBuffer { |
| 215 | 310 | if !cycleAtCaret() { onMessage?("Not on a heading, drawer or block") } |
| 216 | 311 | case "org.cycle-global": cycleGlobal() |
| 217 | 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") |
| 218 | 317 | case "edit.undo": textView.undoManager?.undo() |
| 219 | 318 | case "edit.redo": textView.undoManager?.redo() |
| 220 | 319 | case _ where id.hasPrefix("app."): |
| @@ -233,8 +332,8 @@ final class MobileEditor: NSObject, DocumentBuffer { |
| 233 | 332 | switch id { |
| 234 | 333 | case "org.cycle": |
| 235 | 334 | return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil |
| 236 | | case "org.cycle-global", "edit.undo", "edit.redo": return true |
| 237 | | case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto": return onAppCommand != 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.toggle-markup": return onAppCommand != nil |
| 238 | 337 | case "org.link.open": return OpenLinkAtPoint().applies(in: context) |
| 239 | 338 | default: return false |
| 240 | 339 | } |
| @@ -382,10 +481,38 @@ final class MobileEditor: NSObject, DocumentBuffer { |
| 382 | 481 | loading = true |
| 383 | 482 | textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base)) |
| 384 | 483 | loading = false |
| 484 | shaded.set(Presentation.shadedBlocks(document.tree), folded: []) |
| 385 | 485 | restyle(0..<(text as NSString).length) |
| 386 | 486 | textView.typingAttributes = theme.base |
| 387 | 487 | } |
| 388 | 488 | |
| 489 | /// The text view's own colors, and the block band's. |
| 490 | private func applyThemeToView() { |
| 491 | textView.backgroundColor = theme.color("background") ?? .systemBackground |
| 492 | textView.tintColor = theme.color("cursor") |
| 493 | textView.typingAttributes = theme.base |
| 494 | fragmentStyle.set(blockBackground: theme.color("block-background") ?? .clear, width: textView.bounds.width) |
| 495 | } |
| 496 | |
| 497 | /// The band behind blocks spans the view's width, or the widest line's when truncated; |
| 498 | /// images are as wide as the view allows. |
| 499 | private func viewResized() { |
| 500 | let band = fragmentStyle.blockBand |
| 501 | let width = max(textView.bounds.width, truncatesLines ? textView.contentSize.width : 0) |
| 502 | let resized = imageWidth != textView.bounds.width |
| 503 | guard band.width != width || resized else { return } |
| 504 | fragmentStyle.set(blockBackground: band.color, width: width) |
| 505 | imageWidth = textView.bounds.width |
| 506 | if showsImages, resized { restyle(0..<textView.textStorage.length) } |
| 507 | if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) } |
| 508 | } |
| 509 | |
| 510 | /// Styles the whole file again. |
| 511 | private func restyleAll() { |
| 512 | restyle(0..<textView.textStorage.length) |
| 513 | if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) } |
| 514 | } |
| 515 | |
| 389 | 516 | /// Styles are set after the edit has been processed, as TextKit asks. |
| 390 | 517 | private func scheduleRestyle(_ range: Range<Int>) { |
| 391 | 518 | if let pending = restyleScheduled { |
| @@ -396,6 +523,7 @@ final class MobileEditor: NSObject, DocumentBuffer { |
| 396 | 523 | DispatchQueue.main.async { [weak self] in |
| 397 | 524 | guard let self, let range = self.restyleScheduled else { return } |
| 398 | 525 | self.restyleScheduled = nil |
| 526 | self.shaded.set(Presentation.shadedBlocks(self.document.tree), folded: []) |
| 399 | 527 | // Small files are restyled whole: a heading or block line changes what follows it. |
| 400 | 528 | let length = self.textView.textStorage.length |
| 401 | 529 | self.restyle(length < 60_000 ? 0..<length : range) |
| @@ -404,21 +532,171 @@ final class MobileEditor: NSObject, DocumentBuffer { |
| 404 | 532 | } |
| 405 | 533 | } |
| 406 | 534 | |
| 535 | private var caretLine: NSRange { |
| 536 | let text = textView.textStorage.string as NSString |
| 537 | return text.lineRange(for: NSRange(location: min(textView.selectedRange.location, text.length), length: 0)) |
| 538 | } |
| 539 | |
| 540 | /// Sets attributes for the whole lines in `range`, as the Mac editor does. |
| 407 | 541 | private func restyle(_ range: Range<Int>) { |
| 408 | 542 | let storage = textView.textStorage |
| 409 | 543 | let ns = storage.string as NSString |
| 410 | 544 | guard ns.length > 0 else { return } |
| 411 | 545 | let lower = min(range.lowerBound, ns.length) |
| 412 | 546 | let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower))) |
| 547 | let whole = lines.location..<NSMaxRange(lines) |
| 413 | 548 | storage.beginEditing() |
| 414 | 549 | storage.setAttributes(theme.base, range: lines) |
| 415 | | for run in Presentation.styles(document.tree, in: lines.location..<NSMaxRange(lines)) { |
| 416 | | let clipped = NSIntersectionRange(NSRange(location: run.range.lowerBound, length: run.range.count), lines) |
| 417 | | if clipped.length > 0 { theme.apply(run.role, to: storage, range: clipped) } |
| 550 | if indented { |
| 551 | for line in Presentation.lineIndents(document.tree, in: whole) { |
| 552 | storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range)) |
| 553 | } |
| 418 | 554 | } |
| 555 | let revealed = caretLine |
| 556 | var scriptExclusions: [Range<Int>] = [] |
| 557 | for run in Presentation.styles(document.tree, in: whole) { |
| 558 | let clipped = NSIntersectionRange(NSRange(run.range), lines) |
| 559 | guard clipped.length > 0 else { continue } |
| 560 | if Presentation.skipsLeadingStars(run.role, indented: indented, hideStars: hideStars) { continue } |
| 561 | if !showMarkup, Presentation.hidesWithMarkup(run.role, options: options), NSIntersectionRange(clipped, revealed).length == 0 { |
| 562 | storage.addAttributes(theme.hidden, range: clipped) |
| 563 | } else { |
| 564 | theme.apply(run.role, to: storage, range: clipped) |
| 565 | } |
| 566 | if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) } |
| 567 | } |
| 568 | if !showMarkup, options.prettyEntities { prettify(whole, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) } |
| 569 | applyImages(whole, in: storage, revealed: revealed) |
| 419 | 570 | storage.endEditing() |
| 420 | 571 | } |
| 421 | 572 | |
| 573 | private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle { |
| 574 | if let style = paragraphStyles[[firstLine, wrapped]] { return style } |
| 575 | let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped) |
| 576 | paragraphStyles[[firstLine, wrapped]] = style |
| 577 | return style |
| 578 | } |
| 579 | |
| 580 | /// `org-pretty-entities`: entities drawn as their character over the entity's last |
| 581 | /// character (the rest hidden), sub/superscripts raised without their markers. |
| 582 | private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) { |
| 583 | let runs = Presentation.prettyRuns( |
| 584 | storage.string as NSString, in: range, revealedLine: revealed.location, inBlock: { shaded.containing($0) != nil }, |
| 585 | scriptExclusions: scriptExclusions, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces |
| 586 | ) |
| 587 | for (lineStart, run) in runs { |
| 588 | switch run { |
| 589 | case .entity(let r, let display): |
| 590 | let start = lineStart + r.lowerBound |
| 591 | let last = lineStart + r.upperBound - 1 |
| 592 | let color = storage.attribute(.foregroundColor, at: last, effectiveRange: nil) as? UIColor ?? theme.foreground |
| 593 | if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) } |
| 594 | storage.addAttributes([.foregroundColor: UIColor.clear, .orgEntityDisplay: display, .orgEntityColor: color], range: NSRange(location: last, length: 1)) |
| 595 | case .script(let markers, let body, let superscript): |
| 596 | for marker in markers { |
| 597 | storage.addAttributes(theme.hidden, range: NSRange(location: lineStart + marker.lowerBound, length: marker.count)) |
| 598 | } |
| 599 | let size = theme.font.pointSize |
| 600 | storage.addAttributes([ |
| 601 | .font: theme.font.withSize(size * 0.7), |
| 602 | .baselineOffset: (superscript ? 0.3 : -0.3) * size, |
| 603 | ], range: NSRange(location: lineStart + body.lowerBound, length: body.count)) |
| 604 | } |
| 605 | } |
| 606 | } |
| 607 | |
| 608 | /// Moving to another line shows its markup and hides the last one's. |
| 609 | private func revealCaretLine() { |
| 610 | guard !showMarkup else { return } |
| 611 | let line = caretLine |
| 612 | guard line != revealedLine else { return } |
| 613 | let text = textView.textStorage.string as NSString |
| 614 | // The old line may have moved with edits since; restyle the line now at its start. |
| 615 | let old = revealedLine.flatMap { $0.location <= text.length ? text.lineRange(for: NSRange(location: $0.location, length: 0)) : nil } |
| 616 | revealedLine = line |
| 617 | for range in [old, line].compactMap({ $0 }) { |
| 618 | restyle(range.location..<NSMaxRange(range)) |
| 619 | } |
| 620 | } |
| 621 | |
| 622 | // MARK: - Inline images |
| 623 | |
| 624 | private func toggleImages() { |
| 625 | showsImages.toggle() |
| 626 | restyleAll() |
| 627 | if showsImages { |
| 628 | let ns = textView.textStorage.string as NSString |
| 629 | var count = 0 |
| 630 | var offset = 0 |
| 631 | while offset < ns.length { |
| 632 | let line = ns.lineRange(for: NSRange(location: offset, length: 0)) |
| 633 | if let image = InlineImages.image(atLine: line.location, in: ns), loadImage(image.path) != nil { count += 1 } |
| 634 | offset = NSMaxRange(line) |
| 635 | } |
| 636 | onMessage?("\(count) images displayed inline") |
| 637 | } else { |
| 638 | onMessage?("Inline image display turned off") |
| 639 | } |
| 640 | } |
| 641 | |
| 642 | private func loadImage(_ path: String) -> UIImage? { |
| 643 | let expanded = (path as NSString).expandingTildeInPath |
| 644 | let url: URL |
| 645 | if expanded.hasPrefix("/") { |
| 646 | url = URL(fileURLWithPath: expanded) |
| 647 | } else if let baseDirectory { |
| 648 | url = baseDirectory.appendingPathComponent(expanded) |
| 649 | } else { |
| 650 | return nil |
| 651 | } |
| 652 | let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate?.timeIntervalSince1970 ?? 0 |
| 653 | let key = "\(url.path)|\(modified)" as NSString |
| 654 | if let cached = Self.imageCache.object(forKey: key) { return cached } |
| 655 | guard let image = UIImage(contentsOfFile: url.path) else { return nil } |
| 656 | Self.imageCache.setObject(image, forKey: key) |
| 657 | return image |
| 658 | } |
| 659 | |
| 660 | /// Image lines in `range` show the image over the link, which is hidden unless the caret is |
| 661 | /// on the line. |
| 662 | private func applyImages(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange) { |
| 663 | guard showsImages else { return } |
| 664 | let text = storage.string as NSString |
| 665 | let inset = textView.textContainerInset |
| 666 | let maxWidth = max(80, textView.bounds.width - inset.left - inset.right - 40) |
| 667 | var location = range.lowerBound |
| 668 | while location < range.upperBound, location < text.length { |
| 669 | let line = text.lineRange(for: NSRange(location: location, length: 0)) |
| 670 | location = NSMaxRange(line) |
| 671 | guard let image = InlineImages.image(atLine: line.location, in: text), let picture = loadImage(image.path) else { continue } |
| 672 | let width = min(image.width.map(CGFloat.init) ?? picture.size.width, maxWidth) |
| 673 | let height = picture.size.width > 0 ? width * picture.size.height / picture.size.width : 0 |
| 674 | let content = NSRange(location: line.location, length: line.length - (text.substring(with: line).hasSuffix("\n") ? 1 : 0)) |
| 675 | // Invisible, keeping the line's height. |
| 676 | if line.location != revealed.location, content.length > 0 { |
| 677 | storage.addAttributes([.foregroundColor: UIColor.clear, .underlineStyle: 0], range: content) |
| 678 | } |
| 679 | storage.addAttribute(.orgImage, value: InlineImageBox(image: picture, size: CGSize(width: width, height: height)), range: NSRange(location: line.location, length: max(1, content.length))) |
| 680 | let style = (storage.attribute(.paragraphStyle, at: line.location, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() |
| 681 | // The image sits where the line is; the spacing makes room for the rest of it. |
| 682 | style.paragraphSpacing = max(0, height + 4 - theme.font.lineHeight) |
| 683 | storage.addAttribute(.paragraphStyle, value: style, range: line) |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | // MARK: - Truncation |
| 688 | |
| 689 | /// `toggle-truncate-lines`: long lines run off the right edge, scrolling sideways, instead |
| 690 | /// of wrapping. |
| 691 | var truncatesLines: Bool { |
| 692 | get { (textView as? KeyedTextView)?.truncates ?? false } |
| 693 | set { |
| 694 | guard newValue != truncatesLines, let view = textView as? KeyedTextView else { return } |
| 695 | view.truncates = newValue |
| 696 | if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) } |
| 697 | } |
| 698 | } |
| 699 | |
| 422 | 700 | // MARK: - Folding |
| 423 | 701 | |
| 424 | 702 | func setFolds(_ state: FoldState) { |
| @@ -478,6 +756,7 @@ final class MobileEditor: NSObject, DocumentBuffer { |
| 478 | 756 | fileprivate func selectionChanged() { |
| 479 | 757 | let caret = textView.selectedRange.location |
| 480 | 758 | if hidden.containing(caret) != nil { unfold(around: caret) } |
| 759 | revealCaretLine() |
| 481 | 760 | } |
| 482 | 761 | } |
| 483 | 762 | |
| @@ -530,36 +809,124 @@ extension MobileEditor: NSTextContentStorageDelegate { |
| 530 | 809 | } |
| 531 | 810 | |
| 532 | 811 | extension MobileEditor: NSTextLayoutManagerDelegate { |
| 533 | | /// Folded headings, drawers and blocks end with `…`. |
| 812 | /// Folded headings, drawers and blocks end with `…`; blocks are on a band; image lines |
| 813 | /// show their image; pretty entities draw their character. |
| 534 | 814 | nonisolated func textLayoutManager( |
| 535 | 815 | _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement |
| 536 | 816 | ) -> NSTextLayoutFragment { |
| 537 | 817 | let range = textElement.elementRange |
| 538 | | if let range, let manager = textLayoutManager.textContentManager { |
| 539 | | let offset = manager.offset(from: manager.documentRange.location, to: range.location) |
| 540 | | if hidden.isFolded(offset) { return FoldedLineFragment(textElement: textElement, range: range) } |
| 541 | | } |
| 542 | | return NSTextLayoutFragment(textElement: textElement, range: range) |
| 818 | guard let range, let manager = textLayoutManager.textContentManager else { return NSTextLayoutFragment(textElement: textElement, range: range) } |
| 819 | let offset = manager.offset(from: manager.documentRange.location, to: range.location) |
| 820 | let folded = hidden.isFolded(offset) |
| 821 | let band = shaded.containing(offset) != nil ? fragmentStyle.blockBand : nil |
| 822 | let string = (textElement as? NSTextParagraph)?.attributedString |
| 823 | let image = string.flatMap(OrgLineFragment.image) |
| 824 | let entities = string.map(OrgLineFragment.hasEntities) ?? false |
| 825 | guard folded || band != nil || image != nil || entities else { return NSTextLayoutFragment(textElement: textElement, range: range) } |
| 826 | let fragment = OrgLineFragment(textElement: textElement, range: range) |
| 827 | fragment.folded = folded |
| 828 | fragment.band = band |
| 829 | fragment.image = image |
| 830 | return fragment |
| 543 | 831 | } |
| 544 | 832 | } |
| 545 | 833 | |
| 546 | | /// A folded line drawn with an ellipsis after it, like org's folded headings. |
| 547 | | final class FoldedLineFragment: NSTextLayoutFragment, @unchecked Sendable { |
| 834 | extension NSAttributedString.Key { |
| 835 | /// The character an entity shows as, on the entity's last character. |
| 836 | static let orgEntityDisplay = NSAttributedString.Key("OrgEntityDisplay") |
| 837 | /// The color the entity's character is drawn in. |
| 838 | static let orgEntityColor = NSAttributedString.Key("OrgEntityColor") |
| 839 | /// The image an image link's line shows under it. |
| 840 | static let orgImage = NSAttributedString.Key("OrgImage") |
| 841 | } |
| 842 | |
| 843 | final class InlineImageBox: NSObject, @unchecked Sendable { |
| 844 | let image: UIImage |
| 845 | let size: CGSize |
| 846 | init(image: UIImage, size: CGSize) { |
| 847 | self.image = image |
| 848 | self.size = size |
| 849 | } |
| 850 | } |
| 851 | |
| 852 | /// A line drawn with more than its text, as the Mac editor's fragments draw them: a folded |
| 853 | /// line's ellipsis, a block's band across the view, an image under an image link, and pretty |
| 854 | /// entities' characters where the entities' last (invisible) characters sit. |
| 855 | final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable { |
| 856 | var folded = false |
| 857 | var band: (color: UIColor, width: CGFloat)? |
| 858 | var image: InlineImageBox? |
| 859 | |
| 860 | static func image(_ string: NSAttributedString) -> InlineImageBox? { |
| 861 | var found: InlineImageBox? |
| 862 | string.enumerateAttribute(.orgImage, in: NSRange(location: 0, length: string.length)) { value, _, stop in |
| 863 | if let box = value as? InlineImageBox { found = box; stop.pointee = true } |
| 864 | } |
| 865 | return found |
| 866 | } |
| 867 | |
| 868 | static func hasEntities(_ string: NSAttributedString) -> Bool { |
| 869 | var found = false |
| 870 | string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, _, stop in |
| 871 | if value != nil { found = true; stop.pointee = true } |
| 872 | } |
| 873 | return found |
| 874 | } |
| 875 | |
| 876 | private var bandRect: CGRect? { |
| 877 | guard let band else { return nil } |
| 878 | // Not under the empty line TextKit adds after a final newline. |
| 879 | let height = textLineFragments.last { $0.characterRange.length > 0 }?.typographicBounds.maxY ?? layoutFragmentFrame.height |
| 880 | return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: max(band.width, layoutFragmentFrame.maxX), height: height) |
| 881 | } |
| 882 | |
| 883 | private var imageRect: CGRect? { |
| 884 | guard let image, let line = textLineFragments.first else { return nil } |
| 885 | return CGRect(x: line.typographicBounds.minX, y: line.typographicBounds.minY + 2, width: image.size.width, height: image.size.height) |
| 886 | } |
| 887 | |
| 888 | override var renderingSurfaceBounds: CGRect { |
| 889 | var bounds = super.renderingSurfaceBounds |
| 890 | if folded { bounds = bounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0) } |
| 891 | for rect in [bandRect, imageRect].compactMap({ $0 }) { bounds = bounds.union(rect) } |
| 892 | return bounds |
| 893 | } |
| 894 | |
| 548 | 895 | override func draw(at point: CGPoint, in context: CGContext) { |
| 896 | if let band, let rect = bandRect { |
| 897 | context.saveGState() |
| 898 | context.setFillColor(band.color.cgColor) |
| 899 | context.fill(rect.offsetBy(dx: point.x, dy: point.y)) |
| 900 | context.restoreGState() |
| 901 | } |
| 549 | 902 | super.draw(at: point, in: context) |
| 550 | | guard let line = textLineFragments.first(where: { $0.characterRange.length > 0 }) ?? textLineFragments.first else { return } |
| 551 | | let bounds = line.typographicBounds |
| 552 | | let ellipsis = NSAttributedString(string: " …", attributes: [ |
| 553 | | .font: UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular), |
| 554 | | .foregroundColor: UIColor.secondaryLabel, |
| 555 | | ]) |
| 556 | 903 | UIGraphicsPushContext(context) |
| 557 | | ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY)) |
| 558 | | UIGraphicsPopContext() |
| 904 | defer { UIGraphicsPopContext() } |
| 905 | drawEntities(at: point) |
| 906 | if let image, let rect = imageRect { image.image.draw(in: rect.offsetBy(dx: point.x, dy: point.y)) } |
| 907 | if folded, let line = textLineFragments.first(where: { $0.characterRange.length > 0 }) ?? textLineFragments.first { |
| 908 | let bounds = line.typographicBounds |
| 909 | let ellipsis = NSAttributedString(string: " …", attributes: [ |
| 910 | .font: UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular), |
| 911 | .foregroundColor: UIColor.secondaryLabel, |
| 912 | ]) |
| 913 | ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY)) |
| 914 | } |
| 559 | 915 | } |
| 560 | 916 | |
| 561 | | override var renderingSurfaceBounds: CGRect { |
| 562 | | super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0) |
| 917 | private func drawEntities(at point: CGPoint) { |
| 918 | guard let string = (textElement as? NSTextParagraph)?.attributedString else { return } |
| 919 | string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in |
| 920 | guard let display = value as? String, |
| 921 | let line = textLineFragments.first(where: { NSLocationInRange(range.location, $0.characterRange) }) else { return } |
| 922 | var attributes = string.attributes(at: range.location, effectiveRange: nil) |
| 923 | attributes[.foregroundColor] = attributes[.orgEntityColor] ?? UIColor.label |
| 924 | attributes[.orgEntityDisplay] = nil |
| 925 | attributes[.orgEntityColor] = nil |
| 926 | let x = line.locationForCharacter(at: range.location).x |
| 927 | let bounds = line.typographicBounds |
| 928 | NSAttributedString(string: display, attributes: attributes).draw(at: CGPoint(x: point.x + bounds.minX + x, y: point.y + bounds.minY)) |
| 929 | } |
| 563 | 930 | } |
| 564 | 931 | } |
| 565 | 932 | #endif |