From d44e4dd4ec3b613d72eae2a643268170fdca17ce Mon Sep 17 00:00:00 2001 From: psmattas Date: Wed, 19 Aug 2026 23:44:50 +0100 Subject: [PATCH] WIP(editor): syntax highlighting + code block line numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Syntax highlighting: wires the already-pinned HighlighterSwiftBridge (MarkdownEngineCodeBlocks) into services.syntaxHighlighter on every NativeTextViewWrapper, shared via one CodeSyntaxHighlighting.shared instance (JSContext init is expensive, don't build one per text view). Line numbers: app-side CodeBlockLineNumberGutter overlay positioned via onCodeBlockSelectionChange's rect, widens codeBlock.horizontalIndent to make room. Marked WIP: line-number overlays (and the engine's own built-in copy-button overlay) stay empty on cold document load until the user's first click/keystroke — a real gap in swift-markdown-engine's rebuild path (cachedCodeBlockTokens is only ever seeded by AppKit text- delegate callbacks, not the programmatic text-binding rebuild), not fixable from the app side without patching the package. See TODO.local.md for the full trace and options. Co-Authored-By: Claude Sonnet 5 --- .../CollectionOverviewContent.swift | 6 +- .../Collections/DocumentPresentSheet.swift | 6 +- .../Collections/DocumentReaderView.swift | 126 ++++++++++++++++-- Outpost/Support/CodeSyntaxHighlighting.swift | 11 ++ 4 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 Outpost/Support/CodeSyntaxHighlighting.swift diff --git a/Outpost/Features/Collections/CollectionOverviewContent.swift b/Outpost/Features/Collections/CollectionOverviewContent.swift index ad2fcbf..8c8e1f8 100644 --- a/Outpost/Features/Collections/CollectionOverviewContent.swift +++ b/Outpost/Features/Collections/CollectionOverviewContent.swift @@ -1,6 +1,7 @@ #if os(macOS) import SwiftUI import MarkdownEngine +import MarkdownEngineCodeBlocks import OutlineKit /// Read-only for now — document/overview editing isn't wired up yet. Uses @@ -18,7 +19,10 @@ struct CollectionOverviewContent: View { ScrollView { NativeTextViewWrapper( text: $markdown, - configuration: .init(heightBehavior: .fitsContent), + configuration: .init( + services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared), + heightBehavior: .fitsContent + ), isEditable: false ) .padding() diff --git a/Outpost/Features/Collections/DocumentPresentSheet.swift b/Outpost/Features/Collections/DocumentPresentSheet.swift index 5c1fc5a..980b86d 100644 --- a/Outpost/Features/Collections/DocumentPresentSheet.swift +++ b/Outpost/Features/Collections/DocumentPresentSheet.swift @@ -1,6 +1,7 @@ #if os(macOS) import SwiftUI import MarkdownEngine +import MarkdownEngineCodeBlocks import OutlineKit /// Distraction-free reading view — no toolbar/sidebar chrome, larger type. @@ -44,7 +45,10 @@ struct DocumentPresentSheet: View { .padding(.bottom, 8) NativeTextViewWrapper( text: $text, - configuration: .init(heightBehavior: .fitsContent), + configuration: .init( + services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared), + heightBehavior: .fitsContent + ), isEditable: false ) .font(.system(size: 18)) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index a674997..7d4f36d 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -3,6 +3,7 @@ import AppKit import SwiftUI import UniformTypeIdentifiers import MarkdownEngine +import MarkdownEngineCodeBlocks import OutlineKit /// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions @@ -17,6 +18,21 @@ struct DocumentReaderView: View { /// Outline — see `SettingsView.editorDetail`. @AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false + /// Outline's own "Show line numbers" preference (synced, read via + /// `session.userPreferences`, not `@AppStorage` — this one's the + /// server's, not a local-only Outpost setting). No `@Environment`-in-`init` + /// problem here since this is read directly in the view, not the + /// view model. + private var showCodeBlockLineNumbers: Bool { + session.userPreferences?.codeBlockLineNumbers ?? false + } + /// Widened left indent reserved for the number gutter when line numbers + /// are on (default is 12pt, just enough margin, no room for digits). + private static let lineNumberGutterWidth: CGFloat = 32 + private var editorCodeBlockStyle: CodeBlockStyle { + showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default + } + @State private var viewModel: DocumentReaderViewModel let apiClient: OutlineAPIClient let document: OutlineDocument @@ -44,6 +60,14 @@ struct DocumentReaderView: View { @State private var isShowingShareSheet = false @State private var isShowingNewDocumentSheet = false @State private var actionErrorMessage: String? + /// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange` — + /// one array per instance (main pane, split-view preview pane), since + /// each lays the same text out at a different width and gets different + /// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its + /// doc comment for why the gutter needs `codeBlock.horizontalIndent` + /// widened, which is gated on the same flag). + @State private var readerCodeBlocks: [CodeBlockSelection] = [] + @State private var previewCodeBlocks: [CodeBlockSelection] = [] init( apiClient: OutlineAPIClient, @@ -307,12 +331,24 @@ struct DocumentReaderView: View { } } } else { - NativeTextViewWrapper( - text: $viewModel.text, - configuration: .init(heightBehavior: .fitsContent), - documentId: viewModel.documentId, - isEditable: viewModel.isEffectivelyEditable - ) + ZStack(alignment: .topLeading) { + NativeTextViewWrapper( + text: $viewModel.text, + configuration: .init( + services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared), + codeBlock: editorCodeBlockStyle, + heightBehavior: .fitsContent + ), + documentId: viewModel.documentId, + isEditable: viewModel.isEffectivelyEditable, + onCodeBlockSelectionChange: { readerCodeBlocks = $0 } + ) + if showCodeBlockLineNumbers { + ForEach(readerCodeBlocks) { selection in + CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth) + } + } + } } } .padding() @@ -360,12 +396,24 @@ struct DocumentReaderView: View { .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) ScrollView { - NativeTextViewWrapper( - text: $viewModel.text, - configuration: .init(heightBehavior: .fitsContent), - documentId: viewModel.documentId, - isEditable: false - ) + ZStack(alignment: .topLeading) { + NativeTextViewWrapper( + text: $viewModel.text, + configuration: .init( + services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared), + codeBlock: editorCodeBlockStyle, + heightBehavior: .fitsContent + ), + documentId: viewModel.documentId, + isEditable: false, + onCodeBlockSelectionChange: { previewCodeBlocks = $0 } + ) + if showCodeBlockLineNumbers { + ForEach(previewCodeBlocks) { selection in + CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth) + } + } + } .padding(8) .frame(maxWidth: .infinity, alignment: .topLeading) } @@ -610,4 +658,58 @@ struct DocumentReaderView: View { operation.run() } } + +/// One code block's number gutter, positioned absolutely over a +/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` — same overlay +/// pattern MarkdownEngine's own `CodeBlockButton` uses. +/// +/// `selection.rect` spans the WHOLE fenced block (open fence line + content +/// + close fence line), matching what the engine actually lays out — the +/// fence lines render with invisible (`.clear`) text once the caret leaves +/// the block, but they don't collapse to zero height, so the block is +/// always exactly `content line count + 2` rows tall. `selection.code` is +/// content only, so the row height and number positions below both account +/// for that phantom top/bottom row explicitly instead of dividing by the +/// content line count alone (which would drift the numbers upward, more so +/// per line, the taller the block). +/// +/// Known limitation, accepted rather than fixable app-side: a content line +/// that soft-wraps onto a second visual row (MarkdownEngine always +/// char-wraps code blocks, no way to opt out without forking the package) +/// throws this off — every row below it reads one line low. Documented in +/// TODO.local.md alongside the same package's other gaps. +private struct CodeBlockLineNumberGutter: View { + let selection: CodeBlockSelection + let gutterWidth: CGFloat + + /// `selection.code` (`token.contentRange`) always ends with exactly one + /// trailing `\n` per content line — the range runs right up to the + /// start of the closing fence's own line, so the newline that ends the + /// last content line is included, but there's never an unterminated + /// final line to add one more for. Counting `\n` characters directly + /// (not `.components(separatedBy:).count`, which is one too many + /// whenever the string ends in the separator) is what makes a + /// single-line block read "1", not "2". + private var contentLineCount: Int { + max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } }) + } + + var body: some View { + let totalRows = CGFloat(contentLineCount + 2) + let rowHeight = selection.rect.height / totalRows + ForEach(0..