krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit cfb37bd8ac

cfb37bd8ac1997e58fa785317197bbb0959d1d0b

parent: cf97b142ee

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 18:55 UTC

Shaded band behind blocks

Blocks and fixed-width lines sit on a band the width of the view, as
Emacs extends org-block, instead of a background behind the glyphs.

Layout: unified · split

Sources/OrgEditorAppKit/OrgEditor.swift +27 −1
@@ -52,6 +52,8 @@ public final class OrgEditor: NSObject {
52 52
53 let hidden = HiddenRanges() 53 let hidden = HiddenRanges()
54 let folded = FoldedHeadings() 54 let folded = FoldedHeadings()
55 /// Blocks drawn on a shaded band.
56 let shaded = HiddenRanges()
55 let theme = Theme() 57 let theme = Theme()
56 private var isLoading = false 58 private var isLoading = false
57 private var previousSelection = 0 59 private var previousSelection = 0
@@ -121,6 +123,7 @@ public final class OrgEditor: NSObject {
121 textLayoutManager.delegate = self 123 textLayoutManager.delegate = self
122 textContentStorage.delegate = self 124 textContentStorage.delegate = self
123 125
126 if isOrg { shaded.set(Presentation.shadedBlocks(document.tree)) }
124 load(document.text) 127 load(document.text)
125 if isOrg, let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) } 128 if isOrg, let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
126 } 129 }
@@ -653,7 +656,10 @@ public final class OrgEditor: NSObject {
653 let delta = edit.replacement.utf16.count - edit.range.count 656 let delta = edit.replacement.utf16.count - edit.range.count
654 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd) 657 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
655 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage) 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 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) 663 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
658 } 664 }
659 onEditTiming?(clock.now - start) 665 onEditTiming?(clock.now - start)
@@ -799,11 +805,31 @@ extension OrgEditor: NSTextLayoutManagerDelegate {
799 if let range, let manager = textLayoutManager.textContentManager { 805 if let range, let manager = textLayoutManager.textContentManager {
800 let offset = manager.offset(from: manager.documentRange.location, to: range.location) 806 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
801 if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) } 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 return NSTextLayoutFragment(textElement: textElement, range: range) 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.
816final 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/// A heading line drawn with an ellipsis after it, like org's folded headings. 833/// A heading line drawn with an ellipsis after it, like org's folded headings.
808final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable { 834final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
809 override func draw(at point: CGPoint, in context: CGContext) { 835 override func draw(at point: CGPoint, in context: CGContext) {
Sources/OrgEditorAppKit/Theme.swift +6 −3
@@ -16,6 +16,11 @@ struct Theme {
16 ("0" as NSString).size(withAttributes: [.font: font]).width 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 /// Markup hidden from view: no width, no color. 24 /// Markup hidden from view: no width, no color.
20 var hidden: [NSAttributedString.Key: Any] { 25 var hidden: [NSAttributedString.Key: Any] {
21 [.font: NSFont.monospacedSystemFont(ofSize: 0.01, weight: .regular), .foregroundColor: NSColor.clear] 26 [.font: NSFont.monospacedSystemFont(ofSize: 0.01, weight: .regular), .foregroundColor: NSColor.clear]
@@ -61,9 +66,7 @@ struct Theme {
61 storage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: range) 66 storage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: range)
62 case .footnote, .cookie, .target, .macro, .latex: 67 case .footnote, .cookie, .target, .macro, .latex:
63 storage.addAttribute(.foregroundColor, value: NSColor.systemTeal, range: range) 68 storage.addAttribute(.foregroundColor, value: NSColor.systemTeal, range: range)
64 case .block: 69 case .block, .table, .rule:
65 storage.addAttribute(.backgroundColor, value: NSColor.quaternarySystemFill, range: range)
66 case .table, .rule:
67 break 70 break
68 case .syntax(let category): 71 case .syntax(let category):
69 if let color = Self.color(category) { storage.addAttribute(.foregroundColor, value: color, range: range) } 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 /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its 210 /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its
191 /// section; one in `bodyFolds` hides only its own body, up to its first child heading. 211 /// section; one in `bodyFolds` hides only its own body, up to its first child heading.
192 /// Sorted, without nested duplicates. 212 /// Sorted, without nested duplicates.
Tests/OrgPresentationTests/PresentationTests.swift +10
@@ -1,3 +1,4 @@
1import Foundation
1import OrgCore 2import OrgCore
2import Testing 3import Testing
3@testable import OrgPresentation 4@testable import OrgPresentation
@@ -67,6 +68,15 @@ struct FoldTests {
67 #expect(Presentation.hiddenRanges(OrgParser.parse("* a"), folds: [0]).isEmpty) 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 @Test func headingContaining() { 80 @Test func headingContaining() {
71 let tree = OrgParser.parse(text) 81 let tree = OrgParser.parse(text)
72 #expect(Presentation.heading(containing: 2, in: tree) == 0) 82 #expect(Presentation.heading(containing: 2, in: tree) == 0)