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:
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
configuration: .init(
|
||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
codeBlock: editorCodeBlockStyle,
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable
|
||||
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 {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
configuration: .init(
|
||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
codeBlock: editorCodeBlockStyle,
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: false
|
||||
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..<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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user