Line numbers !44
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) | ||
| 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 | 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. |
| 272 | struct MarkupToggle: View { | 272 | struct 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 | ||
| 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 | } | ||