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 {
5252
5353 let hidden = HiddenRanges()
5454 let folded = FoldedHeadings()
55 /// Blocks drawn on a shaded band.
56 let shaded = HiddenRanges()
5557 let theme = Theme()
5658 private var isLoading = false
5759 private var previousSelection = 0
@@ -121,6 +123,7 @@ public final class OrgEditor: NSObject {
121123 textLayoutManager.delegate = self
122124 textContentStorage.delegate = self
123125
126 if isOrg { shaded.set(Presentation.shadedBlocks(document.tree)) }
124127 load(document.text)
125128 if isOrg, let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
126129 }
@@ -653,7 +656,10 @@ public final class OrgEditor: NSObject {
653656 let delta = edit.replacement.utf16.count - edit.range.count
654657 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
655658 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) }) {
657663 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
658664 }
659665 onEditTiming?(clock.now - start)
@@ -799,11 +805,31 @@ extension OrgEditor: NSTextLayoutManagerDelegate {
799805 if let range, let manager = textLayoutManager.textContentManager {
800806 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
801807 if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) }
808 if shaded.containing(offset) != nil { return ShadedFragment(textElement: textElement, range: range) }
802809 }
803810 return NSTextLayoutFragment(textElement: textElement, range: range)
804811 }
805812}
806813
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
807833/// A heading line drawn with an ellipsis after it, like org's folded headings.
808834final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
809835 override func draw(at point: CGPoint, in context: CGContext) {
Sources/OrgEditorAppKit/Theme.swift +6 −3
@@ -16,6 +16,11 @@ struct Theme {
1616 ("0" as NSString).size(withAttributes: [.font: font]).width
1717 }
1818
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
1924 /// Markup hidden from view: no width, no color.
2025 var hidden: [NSAttributedString.Key: Any] {
2126 [.font: NSFont.monospacedSystemFont(ofSize: 0.01, weight: .regular), .foregroundColor: NSColor.clear]
@@ -61,9 +66,7 @@ struct Theme {
6166 storage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: range)
6267 case .footnote, .cookie, .target, .macro, .latex:
6368 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:
6770 break
6871 case .syntax(let category):
6972 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 {
187187 }
188188 }
189189
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
190210 /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its
191211 /// section; one in `bodyFolds` hides only its own body, up to its first child heading.
192212 /// Sorted, without nested duplicates.
Tests/OrgPresentationTests/PresentationTests.swift +10
@@ -1,3 +1,4 @@
1import Foundation
12import OrgCore
23import Testing
34@testable import OrgPresentation
@@ -67,6 +68,15 @@ struct FoldTests {
6768 #expect(Presentation.hiddenRanges(OrgParser.parse("* a"), folds: [0]).isEmpty)
6869 }
6970
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
7080 @Test func headingContaining() {
7181 let tree = OrgParser.parse(text)
7282 #expect(Presentation.heading(containing: 2, in: tree) == 0)