krz/orgstar

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

Commit 1cd612d09d

1cd612d09d33e26f6efd028072e1adc98b79c2cb

parent: cfb37bd8ac

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 19:00 UTC

Line numbers

A margin left of the text numbers each line of the file, skipping
folded lines and numbering wrapped lines once; the caret's line is
bright. View ▸ Show Line Numbers (⇧⌘L) or the palette toggles it.

Layout: unified · split

Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -42,6 +42,7 @@ public enum EditorCommands {
4242 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
4343 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
4444 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
45 EditorCommand(id: "app.toggle-line-numbers", title: "Show or Hide Line Numbers", action: .app),
4546 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
4647 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
4748 EditorCommand(id: "app.search", title: "Search Notes", action: .app),
Sources/OrgEditorAppKit/Keys.swift +14
@@ -15,6 +15,20 @@ final class OrgTextView: NSTextView {
1515 /// Returns true when the key was handled.
1616 var keyHandler: ((NSEvent) -> Bool)?
1717 var onMouseDown: (() -> Void)?
18 var showsLineNumbers = false {
19 didSet { if showsLineNumbers != oldValue { updateGutter() } }
20 }
21
22 override func drawBackground(in rect: NSRect) {
23 super.drawBackground(in: rect)
24 drawLineNumbers(in: rect)
25 }
26
27 /// Redraws the line number margin, as when the caret changes line.
28 func redrawLineNumbers() {
29 guard showsLineNumbers else { return }
30 setNeedsDisplay(NSRect(x: 0, y: visibleRect.minY, width: textContainerInset.width, height: visibleRect.height))
31 }
1832 /// Draw the caret as a block over the next character, for normal and visual states.
1933 var blockCursor = false {
2034 didSet {
Sources/OrgEditorAppKit/LineNumbers.swift added +57
@@ -0,0 +1,57 @@
1#if os(macOS)
2import AppKit
3
4/// Line numbers in a margin left of the text, as `display-line-numbers-mode` shows them: one
5/// per line of the file (folded lines skipped, wrapped lines numbered once), the caret's line
6/// bright. Drawn by the text view itself: an `NSRulerView` stops TextKit 2 from drawing text.
7extension OrgTextView {
8 static let lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
9
10 /// Width of the margin for the largest line number, or 0 when numbers are off.
11 func updateGutter() {
12 var width: CGFloat = 0
13 if showsLineNumbers {
14 let lines = (string as NSString).components(separatedBy: "\n").count
15 let digits = max(2, String(lines).count)
16 width = ceil(("0" as NSString).size(withAttributes: [.font: Self.lineNumberFont]).width * CGFloat(digits) + 16)
17 }
18 if textContainerInset.width != width {
19 textContainerInset = NSSize(width: width, height: textContainerInset.height)
20 needsDisplay = true
21 }
22 }
23
24 func drawLineNumbers(in rect: NSRect) {
25 guard showsLineNumbers, let layout = textLayoutManager, let content = layout.textContentManager else { return }
26 let gutter = textContainerInset.width
27 guard rect.minX < gutter else { return }
28 let text = string as NSString
29 let caretLine = text.lineRange(for: NSRange(location: min(selectedRange().location, text.length), length: 0)).location
30 let origin = textContainerOrigin
31 let documentStart = content.documentRange.location
32 guard let start = layout.textLayoutFragment(for: CGPoint(x: 0, y: max(0, rect.minY - origin.y)))?.rangeInElement.location
33 ?? layout.textViewportLayoutController.viewportRange?.location else { return }
34
35 var counted = 0
36 var line = 1
37 layout.enumerateTextLayoutFragments(from: start, options: []) { fragment in
38 let frame = fragment.layoutFragmentFrame
39 if frame.minY + origin.y > rect.maxY { return false }
40 let offset = content.offset(from: documentStart, to: fragment.rangeInElement.location)
41 guard offset <= text.length else { return false }
42 while counted < offset {
43 if text.character(at: counted) == 10 { line += 1 }
44 counted += 1
45 }
46 let label = NSAttributedString(string: "\(line)", attributes: [
47 .font: Self.lineNumberFont,
48 .foregroundColor: offset == caretLine ? NSColor.labelColor : NSColor.tertiaryLabelColor,
49 ])
50 let lineHeight = fragment.textLineFragments.first?.typographicBounds.height ?? frame.height
51 let size = label.size()
52 label.draw(at: NSPoint(x: gutter - size.width - 8, y: frame.minY + origin.y + (lineHeight - size.height) / 2))
53 return true
54 }
55 }
56}
57#endif
Sources/OrgEditorAppKit/OrgEditor.swift +13
@@ -189,6 +189,7 @@ public final class OrgEditor: NSObject {
189189 let scroll = NSScrollView(frame: textView.frame)
190190 scroll.hasVerticalScroller = true
191191 scroll.documentView = textView
192 orgTextView?.showsLineNumbers = showLineNumbers
192193 textView.minSize = NSSize(width: 0, height: scroll.contentSize.height)
193194 textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
194195 return scroll
@@ -243,6 +244,12 @@ public final class OrgEditor: NSObject {
243244 public var showMarkup = true {
244245 didSet { if showMarkup != oldValue { restyleEverything() } }
245246 }
247 /// Line numbers in a ruler beside the text.
248 public var showLineNumbers = true {
249 didSet { orgTextView?.showsLineNumbers = showLineNumbers }
250 }
251 var orgTextView: OrgTextView? { textView as? OrgTextView }
252
246253 /// The line last revealed while markup is hidden, to hide again when the caret leaves.
247254 private var revealedLine: NSRange?
248255 var dispatcher: KeyDispatcher
@@ -419,6 +426,7 @@ public final class OrgEditor: NSObject {
419426 view.bodyFolds = state.bodyFolds
420427 view = view.pruned(to: document.tree)
421428 updateHidden()
429 orgTextView?.redrawLineNumbers()
422430 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
423431 textView.needsDisplay = true
424432 let caret = textView.selectedRange().location
@@ -663,6 +671,10 @@ public final class OrgEditor: NSObject {
663671 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
664672 }
665673 onEditTiming?(clock.now - start)
674 if edit.replacement.contains("\n") || (oldText.substring(with: NSRange(edit.range)) as String).contains("\n") {
675 orgTextView?.updateGutter()
676 }
677 orgTextView?.redrawLineNumbers()
666678 onChange?()
667679 }
668680
@@ -719,6 +731,7 @@ public final class OrgEditor: NSObject {
719731 defer { previousSelection = selection.location }
720732 onSelectionChange?(selection)
721733 revealCaretLine()
734 orgTextView?.redrawLineNumbers()
722735 // A selection reaching into folded text, as a find match does, unfolds it.
723736 if selection.length > 0 {
724737 if hidden.containing(selection.location) != nil || hidden.containing(NSMaxRange(selection) - 1) != nil {
Sources/Orgstar/ContentView.swift +3 −1
@@ -44,6 +44,7 @@ struct ContentView: View {
4444 @State private var previewPath: String?
4545 @AppStorage("outlineShown") private var orgOutlineShown = true
4646 @AppStorage("showMarkup") private var showMarkup = false
47 @AppStorage("showLineNumbers") private var showLineNumbers = true
4748 /// The outline for a file that isn't org, off until asked for.
4849 @State private var plainOutlineShown = false
4950 @State private var query = ""
@@ -66,7 +67,7 @@ struct ContentView: View {
6667 if let previewPath {
6768 FilePreview(path: previewPath)
6869 } else if session.path != nil {
69 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, onAppCommand: perform, onExternal: runExternal)
70 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, onAppCommand: perform, onExternal: runExternal)
7071 .safeAreaInset(edge: .bottom, spacing: 0) {
7172 VStack(spacing: 0) {
7273 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
@@ -384,6 +385,7 @@ struct ContentView: View {
384385 case "app.quit": NSApp.terminate(nil)
385386 case "app.toggle-outline": toggleOutline()
386387 case "app.toggle-markup": showMarkup.toggle()
388 case "app.toggle-line-numbers": showLineNumbers.toggle()
387389 case "app.quick-open": commands.showingQuickOpen = true
388390 case "app.palette": commands.showingPalette = true
389391 case "app.search": commands.focusSearch = true
Sources/Orgstar/EditorView.swift +2
@@ -18,6 +18,7 @@ struct EditorView: NSViewRepresentable {
1818 let optionAsMeta: OptionAsMeta
1919 let options: EditingOptions
2020 let showMarkup: Bool
21 let showLineNumbers: Bool
2122 let onAppCommand: (String) -> Void
2223 let onExternal: (ExternalRequest) -> Void
2324
@@ -69,6 +70,7 @@ struct EditorView: NSViewRepresentable {
6970 editor.optionAsMeta = optionAsMeta
7071 editor.options = options
7172 editor.showMarkup = showMarkup
73 editor.showLineNumbers = showLineNumbers
7274 }
7375 container.window?.isDocumentEdited = session.isDirty
7476 if let request = session.revealRequest, request.id != coordinator.revealID {
Sources/Orgstar/OrgstarApp.swift +4 −1
@@ -268,13 +268,16 @@ struct OrgMenuItems: View {
268268 }
269269}
270270
271/// View ▸ Show Markup: link brackets and targets, and emphasis markers.
271/// View ▸ Show Markup (link brackets and targets, emphasis markers) and Show Line Numbers.
272272struct MarkupToggle: View {
273273 @AppStorage("showMarkup") private var showMarkup = false
274 @AppStorage("showLineNumbers") private var showLineNumbers = true
274275
275276 var body: some View {
276277 Toggle("Show Markup", isOn: $showMarkup)
277278 .keyboardShortcut("m", modifiers: [.command, .shift])
279 Toggle("Show Line Numbers", isOn: $showLineNumbers)
280 .keyboardShortcut("l", modifiers: [.command, .shift])
278281 }
279282}
280283
Tests/OrgEditorAppKitTests/MarkupTests.swift +14
@@ -56,3 +56,17 @@ struct MarkupTests {
5656 #expect(size(h, at: ns.range(of: "[[").location) < 1)
5757 }
5858}
59
60@MainActor
61struct LineNumberTests {
62 @Test func marginFollowsTheSettingAndLineCount() {
63 let h = Harness("* a\nb\n")
64 h.editor.showLineNumbers = true
65 let narrow = h.textView.textContainerInset.width
66 #expect(narrow > 0)
67 h.textView.insertText(String(repeating: "x\n", count: 120), replacementRange: NSRange(location: 6, length: 0))
68 #expect(h.textView.textContainerInset.width > narrow)
69 h.editor.showLineNumbers = false
70 #expect(h.textView.textContainerInset.width == 0)
71 }
72}