krz/orgstar

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

Commit c857dec17c

c857dec17c1cf7a45952149fa2e48b69fad2ae25

parent: 3c38fb3fdc

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 00:31 UTC

Line index, and cheaper block bands while typing

Line numbers counted newlines from the top of the file on every draw,
and the margin split the whole text on every new line; a LineIndex of
line starts, updated by each edit, answers both. Finding blocks walked
the whole tree on every keystroke, which doubled typing p95 on a 1 MB
file (6.7 to 12.5 ms); edits away from block delimiters, fixed-width
lines and headings now move the known ranges, and a full search runs
once typing pauses. Gates: 1 MB open 91 ms, typing p95 5.5 ms.

Layout: unified · split

Sources/OrgEditorAppKit/Keys.swift +2
@@ -21,6 +21,8 @@ final class OrgTextView: NSTextView {
2121 var lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
2222 var lineNumberColor = NSColor.tertiaryLabelColor
2323 var currentLineNumberColor = NSColor.labelColor
24 /// Line starts, kept by the editor, for the line numbers.
25 var lineIndex: LineIndex?
2426 var showsLineNumbers = false {
2527 didSet { if showsLineNumbers != oldValue { updateGutter() } }
2628 }
Sources/OrgEditorAppKit/LineIndex.swift added +72
@@ -0,0 +1,72 @@
1#if os(macOS)
2import Foundation
3
4/// Where each line of the text starts (UTF-16), kept up to date edit by edit, so line numbers
5/// come from a search instead of counting newlines from the top.
6final class LineIndex {
7 /// Sorted; the first line starts at 0.
8 private(set) var starts: [Int] = [0]
9
10 init(_ text: NSString = "") { reset(text) }
11
12 func reset(_ text: NSString) {
13 starts = [0]
14 appendStarts(in: text, from: 0, to: text.length)
15 }
16
17 private func appendStarts(in text: NSString, from: Int, to end: Int) {
18 var location = from
19 while location < end {
20 let found = text.range(of: "\n", options: .literal, range: NSRange(location: location, length: end - location))
21 guard found.location != NSNotFound else { break }
22 starts.append(found.location + 1)
23 location = found.location + 1
24 }
25 }
26
27 var lineCount: Int { starts.count }
28
29 /// The line (from 1) holding `offset`.
30 func line(of offset: Int) -> Int {
31 var low = 0
32 var high = starts.count
33 while low < high {
34 let mid = (low + high) / 2
35 if starts[mid] <= offset { low = mid + 1 } else { high = mid }
36 }
37 return max(1, low)
38 }
39
40 /// `old` (in the text before) became `newLength` characters of `text` (the text after).
41 func replace(_ old: Range<Int>, newLength: Int, in text: NSString) {
42 let delta = newLength - old.count
43 // Starts after a newline inside the old range go; starts after it move.
44 let first = firstIndex(after: old.lowerBound)
45 let last = firstIndex(after: old.upperBound)
46 var inserted: [Int] = []
47 var location = old.lowerBound
48 let end = old.lowerBound + newLength
49 while location < end {
50 let found = text.range(of: "\n", options: .literal, range: NSRange(location: location, length: end - location))
51 guard found.location != NSNotFound else { break }
52 inserted.append(found.location + 1)
53 location = found.location + 1
54 }
55 if delta != 0 {
56 for i in last..<starts.count { starts[i] += delta }
57 }
58 starts.replaceSubrange(first..<last, with: inserted)
59 }
60
61 /// The index of the first start greater than `offset`.
62 private func firstIndex(after offset: Int) -> Int {
63 var low = 0
64 var high = starts.count
65 while low < high {
66 let mid = (low + high) / 2
67 if starts[mid] <= offset { low = mid + 1 } else { high = mid }
68 }
69 return low
70 }
71}
72#endif
Sources/OrgEditorAppKit/LineNumbers.swift +2 −7
@@ -9,7 +9,7 @@ extension OrgTextView {
99 func updateGutter() {
1010 var width: CGFloat = 0
1111 if showsLineNumbers {
12 let lines = (string as NSString).components(separatedBy: "\n").count
12 let lines = lineIndex?.lineCount ?? 1
1313 let digits = max(2, String(lines).count)
1414 width = ceil(("0" as NSString).size(withAttributes: [.font: lineNumberFont]).width * CGFloat(digits) + 16)
1515 }
@@ -30,17 +30,12 @@ extension OrgTextView {
3030 guard let start = layout.textLayoutFragment(for: CGPoint(x: 0, y: max(0, rect.minY - origin.y)))?.rangeInElement.location
3131 ?? layout.textViewportLayoutController.viewportRange?.location else { return }
3232
33 var counted = 0
34 var line = 1
3533 layout.enumerateTextLayoutFragments(from: start, options: []) { fragment in
3634 let frame = fragment.layoutFragmentFrame
3735 if frame.minY + origin.y > rect.maxY { return false }
3836 let offset = content.offset(from: documentStart, to: fragment.rangeInElement.location)
3937 guard offset <= text.length else { return false }
40 while counted < offset {
41 if text.character(at: counted) == 10 { line += 1 }
42 counted += 1
43 }
38 let line = lineIndex?.line(of: offset) ?? 1
4439 let label = NSAttributedString(string: "\(line)", attributes: [
4540 .font: lineNumberFont,
4641 .foregroundColor: offset == caretLine ? currentLineNumberColor : lineNumberColor,
Sources/OrgEditorAppKit/OrgEditor.swift +48 −4
@@ -52,6 +52,8 @@ public final class OrgEditor: NSObject {
5252
5353 let hidden = HiddenRanges()
5454 let folded = FoldedHeadings()
55 /// Where lines start, for line numbers.
56 let lines = LineIndex()
5557 /// Blocks drawn on a shaded band.
5658 let shaded = HiddenRanges()
5759 private(set) var theme = Theme()
@@ -143,6 +145,9 @@ public final class OrgEditor: NSObject {
143145 isLoading = true
144146 textView.textStorage?.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
145147 isLoading = false
148 lines.reset(text as NSString)
149 orgTextView?.lineIndex = lines
150 orgTextView?.updateGutter()
146151 styledUpTo = 0
147152 styleNextChunk(Self.firstStyleChunk)
148153 scheduleStyling()
@@ -699,6 +704,36 @@ public final class OrgEditor: NSObject {
699704
700705 // MARK: - Editing
701706
707 private var blockCheck: DispatchWorkItem?
708
709 /// Looks for blocks over the whole file once typing pauses, catching what moving the
710 /// ranges can't see (a block's end found far away).
711 private func scheduleBlockCheck() {
712 blockCheck?.cancel()
713 let work = DispatchWorkItem { [weak self] in self?.settleBlocks() }
714 blockCheck = work
715 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
716 }
717
718 func settleBlocks() {
719 blockCheck?.cancel()
720 guard isOrg else { return }
721 let blocks = Presentation.shadedBlocks(document.tree)
722 guard blocks != shaded.all else { return }
723 shaded.set(blocks)
724 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
725 }
726
727 /// Whether the lines around `range`, and the line after it (a deleted newline joins it to
728 /// them), hold a block delimiter or a fixed-width line.
729 static func mayChangeBlocks(_ text: NSString, _ range: Range<Int>) -> Bool {
730 let start = min(range.lowerBound, text.length)
731 let end = min(range.upperBound + 1, text.length)
732 let lines = text.lineRange(for: NSRange(location: start, length: end - start))
733 let content = text.substring(with: lines)
734 return content.contains("#+") || content.range(of: "(^|\\n)([ \\t]*:|\\*)", options: .regularExpression) != nil
735 }
736
702737 fileprivate func storageEdited(range: NSRange, delta: Int) {
703738 guard !isLoading, let storage = textView.textStorage else { return }
704739 markActive = false
@@ -733,15 +768,24 @@ public final class OrgEditor: NSObject {
733768 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
734769 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
735770 let oldShaded = shaded.all
736 if isOrg { shaded.set(Presentation.shadedBlocks(document.tree)) }
771 if isOrg {
772 // Walking the tree for blocks costs more than typing; only an edit on a line that
773 // could start or end one (`#+…`, `: …`) needs it, others move the ranges.
774 if Self.mayChangeBlocks(oldText, edit.range) || Self.mayChangeBlocks(newText, edit.range.lowerBound..<newEnd) {
775 shaded.set(Presentation.shadedBlocks(document.tree))
776 } else {
777 shaded.set(oldShaded.map { mapRange($0, through: edit) })
778 }
779 scheduleBlockCheck()
780 }
737781 if hidden.all != oldHidden.map({ mapRange($0, through: edit) })
738782 || shaded.all != oldShaded.map({ mapRange($0, through: edit) }) {
739783 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
740784 }
741785 onEditTiming?(clock.now - start)
742 if edit.replacement.contains("\n") || (oldText.substring(with: NSRange(edit.range)) as String).contains("\n") {
743 orgTextView?.updateGutter()
744 }
786 let lineCount = lines.lineCount
787 lines.replace(edit.range, newLength: edit.replacement.utf16.count, in: newText)
788 if lines.lineCount != lineCount { orgTextView?.updateGutter() }
745789 orgTextView?.redrawLineNumbers()
746790 onChange?()
747791 }
Tests/OrgEditorAppKitTests/LineIndexTests.swift added +52
@@ -0,0 +1,52 @@
1import Foundation
2import Testing
3import OrgPresentation
4@testable import OrgEditorAppKit
5
6struct LineIndexTests {
7 func naive(_ text: String) -> [Int] {
8 var starts = [0]
9 for (i, u) in text.utf16.enumerated() where u == 10 { starts.append(i + 1) }
10 return starts
11 }
12
13 @Test func followsRandomEdits() {
14 var generator = SystemRandomNumberGenerator()
15 let pieces = ["a", "bc\n", "\n", "\n\n", "xyz", "é\n", "😀", ""]
16 var text = "first\nsecond\n\nthird"
17 let index = LineIndex(text as NSString)
18 for _ in 0..<2000 {
19 let ns = text as NSString
20 let start = Int.random(in: 0...ns.length, using: &generator)
21 let end = min(ns.length, start + Int.random(in: 0...6, using: &generator))
22 let replacement = pieces.randomElement(using: &generator)!
23 text = ns.replacingCharacters(in: NSRange(location: start, length: end - start), with: replacement)
24 index.replace(start..<end, newLength: (replacement as NSString).length, in: text as NSString)
25 #expect(index.starts == naive(text))
26 if index.starts != naive(text) { return }
27 }
28 #expect(index.line(of: 0) == 1)
29 #expect(index.line(of: (text as NSString).length) == naive(text).count)
30 }
31}
32
33@MainActor
34struct ShadedBlockTests {
35 /// Bands after edits match a full search once typing settles.
36 @Test func incrementalMatchesFull() {
37 var generator = SystemRandomNumberGenerator()
38 let pieces = ["x", "word ", "\n", "#+begin_src sh\n", "#+end_src\n", ": fixed\n", "* H\n", "#+begin_quote\n", "#+end_quote\n", ""]
39 let h = Harness("* A\n#+begin_src sh\nls\n#+end_src\ntext\n: one\n* B\nbody\n")
40 for _ in 0..<400 {
41 let length = h.editor.document.text.utf16.count
42 let start = Int.random(in: 0...length, using: &generator)
43 let end = min(length, start + Int.random(in: 0...5, using: &generator))
44 let piece = pieces.randomElement(using: &generator)!
45 h.textView.insertText(piece, replacementRange: NSRange(location: start, length: end - start))
46 h.editor.settleBlocks()
47 let full = Presentation.shadedBlocks(h.editor.document.tree)
48 #expect(h.editor.shaded.all == full, "\(h.editor.document.text)")
49 if h.editor.shaded.all != full { return }
50 }
51 }
52}