Commit cfb37bd8ac
Verified · cmc
Layout: unified · split
Sources/OrgEditorAppKit/OrgEditor.swift +27 −1
| @@ -52,6 +52,8 @@ public final class OrgEditor: NSObject { | ||
| 52 | 52 | |
| 53 | 53 | let hidden = HiddenRanges() |
| 54 | 54 | let folded = FoldedHeadings() |
| 55 | /// Blocks drawn on a shaded band. | |
| 56 | let shaded = HiddenRanges() | |
| 55 | 57 | let theme = Theme() |
| 56 | 58 | private var isLoading = false |
| 57 | 59 | private var previousSelection = 0 |
| @@ -121,6 +123,7 @@ public final class OrgEditor: NSObject { | ||
| 121 | 123 | textLayoutManager.delegate = self |
| 122 | 124 | textContentStorage.delegate = self |
| 123 | 125 | |
| 126 | if isOrg { shaded.set(Presentation.shadedBlocks(document.tree)) } | |
| 124 | 127 | load(document.text) |
| 125 | 128 | if isOrg, let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) } |
| 126 | 129 | } |
| @@ -653,7 +656,10 @@ public final class OrgEditor: NSObject { | ||
| 653 | 656 | let delta = edit.replacement.utf16.count - edit.range.count |
| 654 | 657 | let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd) |
| 655 | 658 | restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage) |
| 656 | if hidden.all != oldHidden.map({ mapRange($0, through: edit) }) { | |
| 659 | let oldShaded = shaded.all | |
| 660 | if isOrg { shaded.set(Presentation.shadedBlocks(document.tree)) } | |
| 661 | if hidden.all != oldHidden.map({ mapRange($0, through: edit) }) | |
| 662 | || shaded.all != oldShaded.map({ mapRange($0, through: edit) }) { | |
| 657 | 663 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) |
| 658 | 664 | } |
| 659 | 665 | onEditTiming?(clock.now - start) |
| @@ -799,11 +805,31 @@ extension OrgEditor: NSTextLayoutManagerDelegate { | ||
| 799 | 805 | if let range, let manager = textLayoutManager.textContentManager { |
| 800 | 806 | let offset = manager.offset(from: manager.documentRange.location, to: range.location) |
| 801 | 807 | if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) } |
| 808 | if shaded.containing(offset) != nil { return ShadedFragment(textElement: textElement, range: range) } | |
| 802 | 809 | } |
| 803 | 810 | return NSTextLayoutFragment(textElement: textElement, range: range) |
| 804 | 811 | } |
| 805 | 812 | } |
| 806 | 813 | |
| 814 | /// A line of a block, on a band across the whole width of the view, as Emacs extends the | |
| 815 | /// `org-block` face. | |
| 816 | final class ShadedFragment: NSTextLayoutFragment, @unchecked Sendable { | |
| 817 | private var band: CGRect { | |
| 818 | let width = textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width | |
| 819 | return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: width, height: layoutFragmentFrame.height) | |
| 820 | } | |
| 821 | ||
| 822 | override var renderingSurfaceBounds: CGRect { super.renderingSurfaceBounds.union(band) } | |
| 823 | ||
| 824 | override func draw(at point: CGPoint, in context: CGContext) { | |
| 825 | context.saveGState() | |
| 826 | context.setFillColor(Theme.blockBackground.cgColor) | |
| 827 | context.fill(band.offsetBy(dx: point.x, dy: point.y)) | |
| 828 | context.restoreGState() | |
| 829 | super.draw(at: point, in: context) | |
| 830 | } | |
| 831 | } | |
| 832 | ||
| 807 | 833 | /// A heading line drawn with an ellipsis after it, like org's folded headings. |
| 808 | 834 | final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable { |
| 809 | 835 | override func draw(at point: CGPoint, in context: CGContext) { |
Sources/OrgEditorAppKit/Theme.swift +6 −3
| @@ -16,6 +16,11 @@ struct Theme { | ||
| 16 | 16 | ("0" as NSString).size(withAttributes: [.font: font]).width |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | /// The band behind blocks. | |
| 20 | nonisolated(unsafe) static let blockBackground = NSColor(name: nil) { appearance in | |
| 21 | appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(white: 1, alpha: 0.06) : NSColor(white: 0, alpha: 0.045) | |
| 22 | } | |
| 23 | ||
| 19 | 24 | /// Markup hidden from view: no width, no color. |
| 20 | 25 | var hidden: [NSAttributedString.Key: Any] { |
| 21 | 26 | [.font: NSFont.monospacedSystemFont(ofSize: 0.01, weight: .regular), .foregroundColor: NSColor.clear] |
| @@ -61,9 +66,7 @@ struct Theme { | ||
| 61 | 66 | storage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: range) |
| 62 | 67 | case .footnote, .cookie, .target, .macro, .latex: |
| 63 | 68 | storage.addAttribute(.foregroundColor, value: NSColor.systemTeal, range: range) |
| 64 | case .block: | |
| 65 | storage.addAttribute(.backgroundColor, value: NSColor.quaternarySystemFill, range: range) | |
| 66 | case .table, .rule: | |
| 69 | case .block, .table, .rule: | |
| 67 | 70 | break |
| 68 | 71 | case .syntax(let category): |
| 69 | 72 | if let color = Self.color(category) { storage.addAttribute(.foregroundColor, value: color, range: range) } |
Sources/OrgPresentation/Presentation.swift +20
| @@ -187,6 +187,26 @@ public enum Presentation { | ||
| 187 | 187 | } |
| 188 | 188 | } |
| 189 | 189 | |
| 190 | /// Blocks and fixed-width areas, drawn on a shaded band the width of the view. Sorted. | |
| 191 | public static func shadedBlocks(_ tree: OrgTree) -> [Range<Int>] { | |
| 192 | var ranges: [Range<Int>] = [] | |
| 193 | func walk(_ node: SyntaxNode) { | |
| 194 | for child in node.children { | |
| 195 | switch child.kind { | |
| 196 | case .block, .dynamicBlock, .fixedWidth: | |
| 197 | // Without the blank lines org counts as part of the element. | |
| 198 | let text = child.text | |
| 199 | let trimmed = text.utf16.count - String(text.reversed().prefix { $0.isWhitespace }).utf16.count | |
| 200 | ranges.append(child.range.lowerBound..<(child.range.lowerBound + trimmed)) | |
| 201 | case .paragraph, .table, .heading, .planning, .propertyDrawer, .drawer, .keyword, .comment: continue | |
| 202 | default: walk(child) | |
| 203 | } | |
| 204 | } | |
| 205 | } | |
| 206 | walk(tree.root) | |
| 207 | return ranges | |
| 208 | } | |
| 209 | ||
| 190 | 210 | /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its |
| 191 | 211 | /// section; one in `bodyFolds` hides only its own body, up to its first child heading. |
| 192 | 212 | /// Sorted, without nested duplicates. |
Tests/OrgPresentationTests/PresentationTests.swift +10
| @@ -1,3 +1,4 @@ | ||
| 1 | import Foundation | |
| 1 | 2 | import OrgCore |
| 2 | 3 | import Testing |
| 3 | 4 | @testable import OrgPresentation |
| @@ -67,6 +68,15 @@ struct FoldTests { | ||
| 67 | 68 | #expect(Presentation.hiddenRanges(OrgParser.parse("* a"), folds: [0]).isEmpty) |
| 68 | 69 | } |
| 69 | 70 | |
| 71 | @Test func shadedBlocks() { | |
| 72 | let text = "* a\n#+begin_src sh\nls\n#+end_src\n\n- item\n #+begin_quote\n q\n #+end_quote\n: fixed\n" | |
| 73 | let ns = text as NSString | |
| 74 | let ranges = Presentation.shadedBlocks(OrgParser.parse(text)) | |
| 75 | #expect(ranges.map { ns.substring(with: NSRange($0)) } == [ | |
| 76 | "#+begin_src sh\nls\n#+end_src", " #+begin_quote\n q\n #+end_quote", ": fixed", | |
| 77 | ]) | |
| 78 | } | |
| 79 | ||
| 70 | 80 | @Test func headingContaining() { |
| 71 | 81 | let tree = OrgParser.parse(text) |
| 72 | 82 | #expect(Presentation.heading(containing: 2, in: tree) == 0) |