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