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 {
3030/// Descends only through the sections containing `offset`.
3131func isHeadingStart(_ offset: Int, _ root: SyntaxNode) -> Bool {
3232 var node = root
33 while let child = node.children.first(where: { $0.range.contains(offset) }) {
33 while let child = node.child(containing: offset) {
3434 if child.kind == .heading { return child.range.lowerBound == offset }
3535 guard child.kind == .section || child.kind == .zerothSection else { return false }
3636 node = child
Sources/OrgEditorAppKit/OrgEditor.swift +70 −14
@@ -55,6 +55,15 @@ public final class OrgEditor: NSObject {
5555 let theme = Theme()
5656 private var isLoading = false
5757 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
5867
5968 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) {
6069 self.document = document
@@ -77,10 +86,46 @@ public final class OrgEditor: NSObject {
7786 textLayoutManager.delegate = self
7887 textContentStorage.delegate = self
7988
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) {
8095 isLoading = true
81 textView.string = document.text
96 textView.textStorage?.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
8297 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) }
84129 }
85130
86131 public var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! }
@@ -107,10 +152,7 @@ public final class OrgEditor: NSObject {
107152 let outcome = try saver.save(&document, to: url)
108153 if document.text != before {
109154 let edits = lineEdits(from: before, to: document.text)
110 isLoading = true
111 textView.string = document.text
112 isLoading = false
113 restyleOutsideEditing(0..<utf16Length)
155 load(document.text)
114156 setFolds(view.mapped(through: edits).folds)
115157 }
116158 return outcome
@@ -190,23 +232,30 @@ public final class OrgEditor: NSObject {
190232 let lineRange = lines.location..<(lines.location + lines.length)
191233 let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1)
192234 var container = tree.root
193 while let child = container.children.first(where: {
194 ($0.kind == .section || $0.kind == .zerothSection)
195 && $0.range.lowerBound <= lineRange.lowerBound && lineRange.upperBound <= $0.range.upperBound
196 }) {
235 while let child = container.child(containing: lineRange.lowerBound),
236 child.kind == .section || child.kind == .zerothSection,
237 lineRange.upperBound <= child.range.upperBound {
197238 container = child
198239 }
199240 var lower = lineRange.lowerBound
200241 var upper = lineRange.upperBound
201 let children = container.children
202 for child in children where child.range.overlaps(around) {
242 // Tables and lists carry no styling of their own, so inside them only the rows or
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)) {
203252 lower = min(lower, child.range.lowerBound)
204253 upper = max(upper, child.range.upperBound)
205254 }
206 let heading = children.first { $0.kind == .heading }
255 let heading = container.firstChild(.heading)
207256 if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true {
208257 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)
210259 }
211260 return lower..<min(upper, text.length)
212261 }
@@ -231,6 +280,13 @@ public final class OrgEditor: NSObject {
231280 assertionFailure("text view and document disagree: \(error)")
232281 return
233282 }
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 }
234290 let oldHidden = hidden.all
235291 view = view.mapped(through: [edit]).pruned(to: document.tree)
236292 folded.set(view.folds)
Sources/OrgPresentation/Presentation.swift +5 −6
@@ -77,7 +77,7 @@ public enum Presentation {
7777 default:
7878 break
7979 }
80 for child in node.children where child.range.overlaps(range) {
80 for child in node.children(overlapping: range) {
8181 visit(child, range, settings, &runs)
8282 }
8383 }
@@ -118,12 +118,11 @@ public enum Presentation {
118118 }
119119
120120 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) {
122 let children = section.children
123 guard let heading = children.first(where: { $0.kind == .heading }) else { continue }
121 for section in node.children(overlapping: range) where section.kind == .section {
122 guard let heading = section.firstChild(.heading) else { continue }
124123 let level = heading.tokens.first { $0.kind == .stars }?.text.count ?? 1
125124 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
127126 if heading.range.upperBound < bodyEnd {
128127 runs.append(IndentRun(range: heading.range.upperBound..<bodyEnd, firstLine: level + 1, wrapped: level + 1))
129128 }
@@ -156,7 +155,7 @@ public enum Presentation {
156155 /// Start offset of the heading whose line contains `offset`.
157156 public static func heading(containing offset: Int, in tree: OrgTree) -> Int? {
158157 var node = tree.root
159 while let child = node.children.first(where: { $0.range.contains(offset) }) {
158 while let child = node.child(containing: offset) {
160159 if child.kind == .heading { return child.range.lowerBound }
161160 guard child.kind == .section || child.kind == .zerothSection else { return nil }
162161 node = child
Tests/OrgEditorAppKitTests/EditorTests.swift +37 −3
@@ -10,13 +10,24 @@ final class Harness {
1010 let editor: OrgEditor
1111 let window: NSWindow
1212
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) {
1421 _ = NSApplication.shared
15 editor = OrgEditor(document: DocumentState(bytes: Array(text.utf8)))
22 editor = OrgEditor(document: document)
1623 window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false)
1724 window.contentView = editor.makeScrollView()
1825 window.makeFirstResponder(editor.textView)
19 layout()
26 if layout { self.layout() }
27 }
28
29 func drainStyling() {
30 editor.finishStyling()
2031 }
2132
2233 var textView: NSTextView { editor.textView }
@@ -246,3 +257,26 @@ struct RestyleFuzzTests {
246257 h.checkInSync()
247258 }
248259}
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}