WIP(editor): syntax highlighting + code block line numbers

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:44:50 +01:00
co-authored by Claude Sonnet 5
parent 195dc2cc59
commit d44e4dd4ec
4 changed files with 135 additions and 14 deletions
@@ -1,6 +1,7 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
import MarkdownEngine import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit import OutlineKit
/// Read-only for now document/overview editing isn't wired up yet. Uses /// Read-only for now document/overview editing isn't wired up yet. Uses
@@ -18,7 +19,10 @@ struct CollectionOverviewContent: View {
ScrollView { ScrollView {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $markdown, text: $markdown,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false isEditable: false
) )
.padding() .padding()
@@ -1,6 +1,7 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
import MarkdownEngine import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit import OutlineKit
/// Distraction-free reading view no toolbar/sidebar chrome, larger type. /// Distraction-free reading view no toolbar/sidebar chrome, larger type.
@@ -44,7 +45,10 @@ struct DocumentPresentSheet: View {
.padding(.bottom, 8) .padding(.bottom, 8)
NativeTextViewWrapper( NativeTextViewWrapper(
text: $text, text: $text,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false isEditable: false
) )
.font(.system(size: 18)) .font(.system(size: 18))
@@ -3,6 +3,7 @@ import AppKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import MarkdownEngine import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit import OutlineKit
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions /// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
@@ -17,6 +18,21 @@ struct DocumentReaderView: View {
/// Outline see `SettingsView.editorDetail`. /// Outline see `SettingsView.editorDetail`.
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false @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 @State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let document: OutlineDocument let document: OutlineDocument
@@ -44,6 +60,14 @@ struct DocumentReaderView: View {
@State private var isShowingShareSheet = false @State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false @State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String? @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( init(
apiClient: OutlineAPIClient, apiClient: OutlineAPIClient,
@@ -307,12 +331,24 @@ struct DocumentReaderView: View {
} }
} }
} else { } else {
NativeTextViewWrapper( ZStack(alignment: .topLeading) {
text: $viewModel.text, NativeTextViewWrapper(
configuration: .init(heightBehavior: .fitsContent), text: $viewModel.text,
documentId: viewModel.documentId, configuration: .init(
isEditable: viewModel.isEffectivelyEditable 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() .padding()
@@ -360,12 +396,24 @@ struct DocumentReaderView: View {
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView { ScrollView {
NativeTextViewWrapper( ZStack(alignment: .topLeading) {
text: $viewModel.text, NativeTextViewWrapper(
configuration: .init(heightBehavior: .fitsContent), text: $viewModel.text,
documentId: viewModel.documentId, configuration: .init(
isEditable: false 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) .padding(8)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
} }
@@ -610,4 +658,58 @@ struct DocumentReaderView: View {
operation.run() 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..<contentLineCount, id: \.self) { line in
Text("\(line + 1)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: gutterWidth - 6, alignment: .trailing)
.position(
x: selection.rect.minX + (gutterWidth - 6) / 2,
// +1.5 rows: skip the invisible open-fence row, then
// center within this content row.
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
)
}
.allowsHitTesting(false)
}
}
#endif #endif
@@ -0,0 +1,11 @@
#if os(macOS)
import MarkdownEngineCodeBlocks
/// One `HighlighterSwiftBridge` for the whole app. It owns a JavaScriptCore
/// context (expensive to spin up) plus its own highlight cache, so every
/// `NativeTextViewWrapper` should share this instance rather than each
/// constructing its own.
enum CodeSyntaxHighlighting {
static let shared = HighlighterSwiftBridge()
}
#endif