krz/orgstar

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

Commit 0271e942ff

0271e942ff3d2f72a407b3d9a6463fab8e4b48cf

parent: 07733fc0c0

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 02:34 UTC

Style the first screen first and narrow restyles

Layout: unified · split

Sources/OrgDocument/ViewState.swift +1 −1
@@ -30,7 +30,7 @@ public struct ViewState: Sendable, Equatable {
30/// Descends only through the sections containing `offset`. 30/// Descends only through the sections containing `offset`.
31func isHeadingStart(_ offset: Int, _ root: SyntaxNode) -> Bool { 31func isHeadingStart(_ offset: Int, _ root: SyntaxNode) -> Bool {
32 var node = root 32 var node = root
33 while let child = node.children.first(where: { $0.range.contains(offset) }) { 33 while let child = node.child(containing: offset) {
34 if child.kind == .heading { return child.range.lowerBound == offset } 34 if child.kind == .heading { return child.range.lowerBound == offset }
35 guard child.kind == .section || child.kind == .zerothSection else { return false } 35 guard child.kind == .section || child.kind == .zerothSection else { return false }
36 node = child 36 node = child
Sources/OrgEditorAppKit/OrgEditor.swift +70 −14
@@ -55,6 +55,15 @@ public final class OrgEditor: NSObject {
55 let theme = Theme() 55 let theme = Theme()
56 private var isLoading = false 56 private var isLoading = false
57 private var previousSelection = 0 57 private var previousSelection = 0
58 /// Text before this offset is styled; after it, only base attributes until the background
59 /// passes reach it. Always at a line start.
60 private(set) var styledUpTo = 0
61 private var stylingScheduled = false
62
63 /// Styled before the first render: more than a screenful.
64 static let firstStyleChunk = 30_000
65 /// Styled per run-loop turn afterwards.
66 static let styleChunk = 200_000
58 67
59 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) { 68 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) {
60 self.document = document 69 self.document = document
@@ -77,10 +86,46 @@ public final class OrgEditor: NSObject {
77 textLayoutManager.delegate = self 86 textLayoutManager.delegate = self
78 textContentStorage.delegate = self 87 textContentStorage.delegate = self
79 88
89 load(document.text)
90 }
91
92 /// Replaces the storage's text. Setting the attributed string directly is about 100x
93 /// faster than `NSTextView.string` for large files.
94 private func load(_ text: String) {
80 isLoading = true 95 isLoading = true
81 textView.string = document.text 96 textView.textStorage?.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
82 isLoading = false 97 isLoading = false
83 restyleOutsideEditing(0..<utf16Length) 98 styledUpTo = 0
99 styleNextChunk(Self.firstStyleChunk)
100 scheduleStyling()
101 }
102
103 // MARK: - Initial styling
104
105 /// Styles whole lines from the watermark onward, about `size` units.
106 private func styleNextChunk(_ size: Int) {
107 let text = textView.textStorage!.string as NSString
108 guard styledUpTo < text.length else { return }
109 let target = min(text.length, styledUpTo + size)
110 let end = target == text.length ? target : NSMaxRange(text.lineRange(for: NSRange(location: target, length: 0)))
111 restyleOutsideEditing(styledUpTo..<end)
112 styledUpTo = end
113 }
114
115 private func scheduleStyling() {
116 guard !stylingScheduled, styledUpTo < utf16Length else { return }
117 stylingScheduled = true
118 DispatchQueue.main.async { [weak self] in
119 guard let self else { return }
120 self.stylingScheduled = false
121 self.styleNextChunk(Self.styleChunk)
122 self.scheduleStyling()
123 }
124 }
125
126 /// Styles everything still waiting for a background pass.
127 public func finishStyling() {
128 while styledUpTo < utf16Length { styleNextChunk(Self.styleChunk) }
84 } 129 }
85 130
86 public var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! } 131 public var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! }
@@ -107,10 +152,7 @@ public final class OrgEditor: NSObject {
107 let outcome = try saver.save(&document, to: url) 152 let outcome = try saver.save(&document, to: url)
108 if document.text != before { 153 if document.text != before {
109 let edits = lineEdits(from: before, to: document.text) 154 let edits = lineEdits(from: before, to: document.text)
110 isLoading = true 155 load(document.text)
111 textView.string = document.text
112 isLoading = false
113 restyleOutsideEditing(0..<utf16Length)
114 setFolds(view.mapped(through: edits).folds) 156 setFolds(view.mapped(through: edits).folds)
115 } 157 }
116 return outcome 158 return outcome
@@ -190,23 +232,30 @@ public final class OrgEditor: NSObject {
190 let lineRange = lines.location..<(lines.location + lines.length) 232 let lineRange = lines.location..<(lines.location + lines.length)
191 let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1) 233 let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1)
192 var container = tree.root 234 var container = tree.root
193 while let child = container.children.first(where: { 235 while let child = container.child(containing: lineRange.lowerBound),
194 ($0.kind == .section || $0.kind == .zerothSection) 236 child.kind == .section || child.kind == .zerothSection,
195 && $0.range.lowerBound <= lineRange.lowerBound && lineRange.upperBound <= $0.range.upperBound 237 lineRange.upperBound <= child.range.upperBound {
196 }) {
197 container = child 238 container = child
198 } 239 }
199 var lower = lineRange.lowerBound 240 var lower = lineRange.lowerBound
200 var upper = lineRange.upperBound 241 var upper = lineRange.upperBound
201 let children = container.children 242 // Tables and lists carry no styling of their own, so inside them only the rows or
202 for child in children where child.range.overlaps(around) { 243 // items around the edit change, however large they are.
244 func overlapping(_ nodes: [SyntaxNode]) -> [SyntaxNode] {
245 nodes.filter { $0.range.overlaps(around) }.flatMap { node -> [SyntaxNode] in
246 guard [.table, .plainList, .item].contains(node.kind) else { return [node] }
247 let inner = overlapping(node.children(overlapping: around))
248 return inner.isEmpty ? [node] : inner
249 }
250 }
251 for child in overlapping(container.children(overlapping: around)) {
203 lower = min(lower, child.range.lowerBound) 252 lower = min(lower, child.range.lowerBound)
204 upper = max(upper, child.range.upperBound) 253 upper = max(upper, child.range.upperBound)
205 } 254 }
206 let heading = children.first { $0.kind == .heading } 255 let heading = container.firstChild(.heading)
207 if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true { 256 if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true {
208 lower = min(lower, container.range.lowerBound) 257 lower = min(lower, container.range.lowerBound)
209 upper = max(upper, children.first { $0.kind == .section }?.range.lowerBound ?? container.range.upperBound) 258 upper = max(upper, container.firstChild(.section)?.range.lowerBound ?? container.range.upperBound)
210 } 259 }
211 return lower..<min(upper, text.length) 260 return lower..<min(upper, text.length)
212 } 261 }
@@ -231,6 +280,13 @@ public final class OrgEditor: NSObject {
231 assertionFailure("text view and document disagree: \(error)") 280 assertionFailure("text view and document disagree: \(error)")
232 return 281 return
233 } 282 }
283 // Keep the watermark on unstyled text: shift it past the edit, or pull it back to the
284 // edited line when the edit reaches into unstyled text.
285 if styledUpTo >= edit.range.upperBound {
286 styledUpTo += edit.replacement.utf16.count - edit.range.count
287 } else if styledUpTo > edit.range.lowerBound {
288 styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location
289 }
234 let oldHidden = hidden.all 290 let oldHidden = hidden.all
235 view = view.mapped(through: [edit]).pruned(to: document.tree) 291 view = view.mapped(through: [edit]).pruned(to: document.tree)
236 folded.set(view.folds) 292 folded.set(view.folds)
Sources/OrgPresentation/Presentation.swift +5 −6
@@ -77,7 +77,7 @@ public enum Presentation {
77 default: 77 default:
78 break 78 break
79 } 79 }
80 for child in node.children where child.range.overlaps(range) { 80 for child in node.children(overlapping: range) {
81 visit(child, range, settings, &runs) 81 visit(child, range, settings, &runs)
82 } 82 }
83 } 83 }
@@ -118,12 +118,11 @@ public enum Presentation {
118 } 118 }
119 119
120 private static func collectIndents(_ node: SyntaxNode, _ range: Range<Int>, _ runs: inout [IndentRun]) { 120 private static func collectIndents(_ node: SyntaxNode, _ range: Range<Int>, _ runs: inout [IndentRun]) {
121 for section in node.children where section.kind == .section && section.range.overlaps(range) { 121 for section in node.children(overlapping: range) where section.kind == .section {
122 let children = section.children 122 guard let heading = section.firstChild(.heading) else { continue }
123 guard let heading = children.first(where: { $0.kind == .heading }) else { continue }
124 let level = heading.tokens.first { $0.kind == .stars }?.text.count ?? 1 123 let level = heading.tokens.first { $0.kind == .stars }?.text.count ?? 1
125 runs.append(IndentRun(range: heading.range, firstLine: 0, wrapped: level + 1)) 124 runs.append(IndentRun(range: heading.range, firstLine: 0, wrapped: level + 1))
126 let bodyEnd = children.first { $0.kind == .section }?.range.lowerBound ?? section.range.upperBound 125 let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound
127 if heading.range.upperBound < bodyEnd { 126 if heading.range.upperBound < bodyEnd {
128 runs.append(IndentRun(range: heading.range.upperBound..<bodyEnd, firstLine: level + 1, wrapped: level + 1)) 127 runs.append(IndentRun(range: heading.range.upperBound..<bodyEnd, firstLine: level + 1, wrapped: level + 1))
129 } 128 }
@@ -156,7 +155,7 @@ public enum Presentation {
156 /// Start offset of the heading whose line contains `offset`. 155 /// Start offset of the heading whose line contains `offset`.
157 public static func heading(containing offset: Int, in tree: OrgTree) -> Int? { 156 public static func heading(containing offset: Int, in tree: OrgTree) -> Int? {
158 var node = tree.root 157 var node = tree.root
159 while let child = node.children.first(where: { $0.range.contains(offset) }) { 158 while let child = node.child(containing: offset) {
160 if child.kind == .heading { return child.range.lowerBound } 159 if child.kind == .heading { return child.range.lowerBound }
161 guard child.kind == .section || child.kind == .zerothSection else { return nil } 160 guard child.kind == .section || child.kind == .zerothSection else { return nil }
162 node = child 161 node = child
Tests/OrgEditorAppKitTests/EditorTests.swift +37 −3
@@ -10,13 +10,24 @@ final class Harness {
10 let editor: OrgEditor 10 let editor: OrgEditor
11 let window: NSWindow 11 let window: NSWindow
12 12
13 init(_ text: String) { 13 convenience init(_ text: String) {
14 self.init(DocumentState(bytes: Array(text.utf8)))
15 drainStyling()
16 layout()
17 }
18
19 /// The editor as a window would show it first: background styling not yet run.
20 init(_ document: DocumentState, layout: Bool = true) {
14 _ = NSApplication.shared 21 _ = NSApplication.shared
15 editor = OrgEditor(document: DocumentState(bytes: Array(text.utf8))) 22 editor = OrgEditor(document: document)
16 window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false) 23 window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false)
17 window.contentView = editor.makeScrollView() 24 window.contentView = editor.makeScrollView()
18 window.makeFirstResponder(editor.textView) 25 window.makeFirstResponder(editor.textView)
19 layout() 26 if layout { self.layout() }
27 }
28
29 func drainStyling() {
30 editor.finishStyling()
20 } 31 }
21 32
22 var textView: NSTextView { editor.textView } 33 var textView: NSTextView { editor.textView }
@@ -246,3 +257,26 @@ struct RestyleFuzzTests {
246 h.checkInSync() 257 h.checkInSync()
247 } 258 }
248} 259}
260
261@MainActor
262struct InitialStylingTests {
263 @Test func stylesTheFirstScreenThenTheRest() throws {
264 let line = "* heading *bold*\nbody text\n"
265 let text = String(repeating: line, count: 4_000)
266 let h = Harness(DocumentState(bytes: Array(text.utf8)), layout: false)
267 let length = (text as NSString).length
268 #expect(h.editor.styledUpTo >= OrgEditor.firstStyleChunk && h.editor.styledUpTo < length)
269 h.textView.insertText("x", replacementRange: NSRange(location: length - 3, length: 0))
270 h.drainStyling()
271 #expect(h.editor.styledUpTo == length + 1)
272 #expect(h.textView.textStorage!.isEqual(to: Harness(h.string).textView.textStorage!))
273 }
274
275 @Test func editsBeforeTheWatermarkShiftIt() {
276 let text = String(repeating: "line of text\n", count: 5_000)
277 let h = Harness(DocumentState(bytes: Array(text.utf8)), layout: false)
278 let before = h.editor.styledUpTo
279 h.textView.insertText("abc", replacementRange: NSRange(location: 0, length: 0))
280 #expect(h.editor.styledUpTo == before + 3)
281 }
282}