Line numbers !44

merged merged by cmc on 2026-10-05 19:00 UTC · krz/orgstar:line-numbers into main

8 files changed, +108 −2

Layout: unified · split

Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -42,6 +42,7 @@ public enum EditorCommands {
42 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), 42 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
43 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), 43 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
44 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), 44 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),
45 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), 46 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
46 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), 47 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
47 EditorCommand(id: "app.search", title: "Search Notes", action: .app), 48 EditorCommand(id: "app.search", title: "Search Notes", action: .app),
Sources/OrgEditorAppKit/Keys.swift +14
@@ -15,6 +15,20 @@ final class OrgTextView: NSTextView {
15 /// Returns true when the key was handled. 15 /// Returns true when the key was handled.
16 var keyHandler: ((NSEvent) -> Bool)? 16 var keyHandler: ((NSEvent) -> Bool)?
17 var onMouseDown: (() -> Void)? 17 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 }
18 /// Draw the caret as a block over the next character, for normal and visual states. 32 /// Draw the caret as a block over the next character, for normal and visual states.
19 var blockCursor = false { 33 var blockCursor = false {
20 didSet { 34 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 {
189 let scroll = NSScrollView(frame: textView.frame) 189 let scroll = NSScrollView(frame: textView.frame)
190 scroll.hasVerticalScroller = true 190 scroll.hasVerticalScroller = true
191 scroll.documentView = textView 191 scroll.documentView = textView
192 orgTextView?.showsLineNumbers = showLineNumbers
192 textView.minSize = NSSize(width: 0, height: scroll.contentSize.height) 193 textView.minSize = NSSize(width: 0, height: scroll.contentSize.height)
193 textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) 194 textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
194 return scroll 195 return scroll
@@ -243,6 +244,12 @@ public final class OrgEditor: NSObject {
243 public var showMarkup = true { 244 public var showMarkup = true {
244 didSet { if showMarkup != oldValue { restyleEverything() } } 245 didSet { if showMarkup != oldValue { restyleEverything() } }
245 } 246 }
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
246 /// The line last revealed while markup is hidden, to hide again when the caret leaves. 253 /// The line last revealed while markup is hidden, to hide again when the caret leaves.
247 private var revealedLine: NSRange? 254 private var revealedLine: NSRange?
248 var dispatcher: KeyDispatcher 255 var dispatcher: KeyDispatcher
@@ -419,6 +426,7 @@ public final class OrgEditor: NSObject {
419 view.bodyFolds = state.bodyFolds 426 view.bodyFolds = state.bodyFolds
420 view = view.pruned(to: document.tree) 427 view = view.pruned(to: document.tree)
421 updateHidden() 428 updateHidden()
429 orgTextView?.redrawLineNumbers()
422 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) 430 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
423 textView.needsDisplay = true 431 textView.needsDisplay = true
424 let caret = textView.selectedRange().location 432 let caret = textView.selectedRange().location
@@ -663,6 +671,10 @@ public final class OrgEditor: NSObject {
663 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) 671 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
664 } 672 }
665 onEditTiming?(clock.now - start) 673 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()
666 onChange?() 678 onChange?()
667 } 679 }
668 680
@@ -719,6 +731,7 @@ public final class OrgEditor: NSObject {
719 defer { previousSelection = selection.location } 731 defer { previousSelection = selection.location }
720 onSelectionChange?(selection) 732 onSelectionChange?(selection)
721 revealCaretLine() 733 revealCaretLine()
734 orgTextView?.redrawLineNumbers()
722 // A selection reaching into folded text, as a find match does, unfolds it. 735 // A selection reaching into folded text, as a find match does, unfolds it.
723 if selection.length > 0 { 736 if selection.length > 0 {
724 if hidden.containing(selection.location) != nil || hidden.containing(NSMaxRange(selection) - 1) != nil { 737 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 {
44 @State private var previewPath: String? 44 @State private var previewPath: String?
45 @AppStorage("outlineShown") private var orgOutlineShown = true 45 @AppStorage("outlineShown") private var orgOutlineShown = true
46 @AppStorage("showMarkup") private var showMarkup = false 46 @AppStorage("showMarkup") private var showMarkup = false
47 @AppStorage("showLineNumbers") private var showLineNumbers = true
47 /// The outline for a file that isn't org, off until asked for. 48 /// The outline for a file that isn't org, off until asked for.
48 @State private var plainOutlineShown = false 49 @State private var plainOutlineShown = false
49 @State private var query = "" 50 @State private var query = ""
@@ -66,7 +67,7 @@ struct ContentView: View {
66 if let previewPath { 67 if let previewPath {
67 FilePreview(path: previewPath) 68 FilePreview(path: previewPath)
68 } else if session.path != nil { 69 } 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)
70 .safeAreaInset(edge: .bottom, spacing: 0) { 71 .safeAreaInset(edge: .bottom, spacing: 0) {
71 VStack(spacing: 0) { 72 VStack(spacing: 0) {
72 ModeLine(session: session, commands: commands, clock: clock, perform: perform) 73 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
@@ -384,6 +385,7 @@ struct ContentView: View {
384 case "app.quit": NSApp.terminate(nil) 385 case "app.quit": NSApp.terminate(nil)
385 case "app.toggle-outline": toggleOutline() 386 case "app.toggle-outline": toggleOutline()
386 case "app.toggle-markup": showMarkup.toggle() 387 case "app.toggle-markup": showMarkup.toggle()
388 case "app.toggle-line-numbers": showLineNumbers.toggle()
387 case "app.quick-open": commands.showingQuickOpen = true 389 case "app.quick-open": commands.showingQuickOpen = true
388 case "app.palette": commands.showingPalette = true 390 case "app.palette": commands.showingPalette = true
389 case "app.search": commands.focusSearch = true 391 case "app.search": commands.focusSearch = true
Sources/Orgstar/EditorView.swift +2
@@ -18,6 +18,7 @@ struct EditorView: NSViewRepresentable {
18 let optionAsMeta: OptionAsMeta 18 let optionAsMeta: OptionAsMeta
19 let options: EditingOptions 19 let options: EditingOptions
20 let showMarkup: Bool 20 let showMarkup: Bool
21 let showLineNumbers: Bool
21 let onAppCommand: (String) -> Void 22 let onAppCommand: (String) -> Void
22 let onExternal: (ExternalRequest) -> Void 23 let onExternal: (ExternalRequest) -> Void
23 24
@@ -69,6 +70,7 @@ struct EditorView: NSViewRepresentable {
69 editor.optionAsMeta = optionAsMeta 70 editor.optionAsMeta = optionAsMeta
70 editor.options = options 71 editor.options = options
71 editor.showMarkup = showMarkup 72 editor.showMarkup = showMarkup
73 editor.showLineNumbers = showLineNumbers
72 } 74 }
73 container.window?.isDocumentEdited = session.isDirty 75 container.window?.isDocumentEdited = session.isDirty
74 if let request = session.revealRequest, request.id != coordinator.revealID { 76 if let request = session.revealRequest, request.id != coordinator.revealID {
Sources/Orgstar/OrgstarApp.swift +4 −1
@@ -268,13 +268,16 @@ struct OrgMenuItems: View {
268 } 268 }
269} 269}
270 270
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.
272struct MarkupToggle: View { 272struct MarkupToggle: View {
273 @AppStorage("showMarkup") private var showMarkup = false 273 @AppStorage("showMarkup") private var showMarkup = false
274 @AppStorage("showLineNumbers") private var showLineNumbers = true
274 275
275 var body: some View { 276 var body: some View {
276 Toggle("Show Markup", isOn: $showMarkup) 277 Toggle("Show Markup", isOn: $showMarkup)
277 .keyboardShortcut("m", modifiers: [.command, .shift]) 278 .keyboardShortcut("m", modifiers: [.command, .shift])
279 Toggle("Show Line Numbers", isOn: $showLineNumbers)
280 .keyboardShortcut("l", modifiers: [.command, .shift])
278 } 281 }
279} 282}
280 283
Tests/OrgEditorAppKitTests/MarkupTests.swift +14
@@ -56,3 +56,17 @@ struct MarkupTests {
56 #expect(size(h, at: ns.range(of: "[[").location) < 1) 56 #expect(size(h, at: ns.range(of: "[[").location) < 1)
57 } 57 }
58} 58}
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}