krz/orgstar

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

Commit decaf5db8c

decaf5db8c55edebdb66cae18bbb466a4989ecc8

parent: 064f21f012

Verified · cmc

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

Share paragraph styles across lines

Layout: unified · split

Sources/OrgEditorAppKit/OrgEditor.swift +17 −2
@@ -59,6 +59,13 @@ public final class OrgEditor: NSObject {
59 /// passes reach it. Always at a line start. 59 /// passes reach it. Always at a line start.
60 private(set) var styledUpTo = 0 60 private(set) var styledUpTo = 0
61 private var stylingScheduled = false 61 private var stylingScheduled = false
62 /// One paragraph style object per indent pair, shared by every line that uses it.
63 private var paragraphStyles: [IndentKey: NSParagraphStyle] = [:]
64
65 private struct IndentKey: Hashable {
66 let firstLine: Int
67 let wrapped: Int
68 }
62 69
63 /// Styled before the first render: more than a screenful. 70 /// Styled before the first render: more than a screenful.
64 static let firstStyleChunk = 30_000 71 static let firstStyleChunk = 30_000
@@ -253,7 +260,7 @@ public final class OrgEditor: NSObject {
253 for run in Presentation.indents(document.tree, in: range) { 260 for run in Presentation.indents(document.tree, in: range) {
254 guard let clipped = clip(run.range, to: range) else { continue } 261 guard let clipped = clip(run.range, to: range) else { continue }
255 guard run.firstLine == run.wrapped else { 262 guard run.firstLine == run.wrapped else {
256 storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped) 263 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped)
257 continue 264 continue
258 } 265 }
259 // Body lines wrap under their own text: past leading blanks, and past a list bullet 266 // Body lines wrap under their own text: past leading blanks, and past a list bullet
@@ -263,7 +270,7 @@ public final class OrgEditor: NSObject {
263 let line = text.lineRange(for: NSRange(location: location, length: 0)) 270 let line = text.lineRange(for: NSRange(location: location, length: 0))
264 let hanging = Self.hangingColumns(text.substring(with: line)) 271 let hanging = Self.hangingColumns(text.substring(with: line))
265 let lineRange = NSIntersectionRange(line, clipped) 272 let lineRange = NSIntersectionRange(line, clipped)
266 storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped + hanging), range: lineRange) 273 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange)
267 location = NSMaxRange(line) 274 location = NSMaxRange(line)
268 } 275 }
269 } 276 }
@@ -273,6 +280,14 @@ public final class OrgEditor: NSObject {
273 } 280 }
274 } 281 }
275 282
283 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
284 let key = IndentKey(firstLine: firstLine, wrapped: wrapped)
285 if let style = paragraphStyles[key] { return style }
286 let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
287 paragraphStyles[key] = style
288 return style
289 }
290
276 private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? { 291 private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
277 let clipped = run.clamped(to: range) 292 let clipped = run.clamped(to: range)
278 return clipped.isEmpty ? nil : NSRange(clipped) 293 return clipped.isEmpty ? nil : NSRange(clipped)