feat: code block language picker

Top-left pill overlay on every code block (opposite corner from the
engine's own copy button) showing the current language; tapping opens
a curated ~20-language menu that rewrites just the fence line,
content untouched.

New engine mechanism to support it: CodeBlockSelection now exposes
fenceRange (the opening ```lang line's range, straight from the
tokenizer's own markerRanges[0] - was already computed, just never
exposed) plus a generic TextRangeReplacementRequest/
pendingTextRangeReplacement, since the existing pendingTextInsertion
only replaces at the current caret/selection, not an arbitrary
already-known range like a fence line the user isn't necessarily
positioned at.

Horizontal scroll for long lines investigated and dropped, per
explicit instruction after weighing it: the only viable path (a real,
selectable/editable independently-scrolling text region) needs an
embedded NSTextAttachmentViewProvider-backed NSTextView per code
block, which would split selection/copy/undo into two contexts
(the code block's own vs. the surrounding document's) instead of one
unified editing surface - a different regression, not a clean win.
Code blocks keep wrapping.
This commit is contained in:
2026-08-20 22:07:48 +01:00
parent 2d0972de1e
commit 283186b28f
7 changed files with 178 additions and 2 deletions
@@ -123,6 +123,9 @@ struct DocumentReaderView: View {
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
/// insert an Image Playground result's Markdown reference at the caret.
@State private var pendingTextInsertion: TextInsertionRequest?
/// Pushed by `CodeBlockLanguagePicker` to rewrite a code block's fence
/// line when the user switches its language from the picker.
@State private var pendingCodeBlockLanguageChange: TextRangeReplacementRequest?
/// Live-updated by `onSelectedTextChange` on the main editable pane;
/// `nil` when the selection is empty (caret only, nothing highlighted).
@State private var currentSelectedText: String?
@@ -547,6 +550,7 @@ struct DocumentReaderView: View {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
@@ -568,6 +572,13 @@ struct DocumentReaderView: View {
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
if viewModel.isEffectivelyEditable {
ForEach(readerCodeBlocks) { selection in
CodeBlockLanguagePicker(selection: selection, documentId: viewModel.documentId) { request in
pendingCodeBlockLanguageChange = request
}
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
@@ -934,6 +945,81 @@ struct DocumentReaderView: View {
/// Thin vertical bar next to an anchored comment's text same visual
/// language as Word/Google Docs' margin comment indicators. Tapping opens
/// the comments sheet focused to that thread.
/// Top-left pill overlay on each code block opposite corner from the
/// engine's own copy button (top-right), same `Color.clear` + `.position()`
/// sizing trick `CodeBlockButton` uses. Tapping opens a curated language
/// list (not the full ~190-language highlight.js catalog same "small
/// curated set, not the whole thing" call as the emoji reaction picker);
/// picking one rewrites just the fence line via
/// `CodeBlockSelection.fenceRange`, leaving the block's content untouched.
private struct CodeBlockLanguagePicker: View {
let selection: CodeBlockSelection
let documentId: String
let onRequest: (TextRangeReplacementRequest) -> Void
private static let languages: [(label: String, tag: String?)] = [
("Plain Text", nil),
("Swift", "swift"),
("Python", "python"),
("JavaScript", "javascript"),
("TypeScript", "typescript"),
("Bash", "bash"),
("JSON", "json"),
("YAML", "yaml"),
("HTML", "html"),
("CSS", "css"),
("SQL", "sql"),
("Java", "java"),
("Kotlin", "kotlin"),
("C", "c"),
("C++", "cpp"),
("C#", "csharp"),
("Go", "go"),
("Rust", "rust"),
("Ruby", "ruby"),
("PHP", "php"),
("Markdown", "markdown"),
]
private var currentLabel: String {
guard let language = selection.language, !language.isEmpty else { return "Plain Text" }
return Self.languages.first { $0.tag == language }?.label ?? language.uppercased()
}
var body: some View {
Color.clear
.frame(width: selection.rect.width, height: selection.rect.height)
.overlay(alignment: .topLeading) {
Menu {
ForEach(Self.languages, id: \.label) { entry in
Button(entry.label) {
onRequest(TextRangeReplacementRequest(
documentId: documentId,
range: selection.fenceRange,
replacement: "```\(entry.tag ?? "")\n"
))
}
}
} label: {
Text(currentLabel.uppercased())
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
}
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.top, 6)
.padding(.leading, 8)
}
.position(
x: selection.rect.midX,
y: selection.rect.midY
)
}
}
private struct CommentAnchorMarker: View {
let rect: CGRect
let onTap: () -> Void
@@ -24,12 +24,19 @@ public struct CodeBlockSelection: Identifiable, Sendable {
/// Plain text content of the block, suitable for putting on the
/// pasteboard.
public let code: String
/// Range of the opening fence line (`` ```lang\n `` or `` ```\n ``) in
/// the text view's DISPLAY-form string `token.markerRanges[0]` from
/// the parser. Lets an embedder safely rewrite just the language tag
/// (e.g. a language-picker menu) without touching the block's content,
/// which `code`/`rect` alone don't give enough to do.
public let fenceRange: NSRange
public init(id: Int, rect: CGRect, language: String?, code: String) {
public init(id: Int, rect: CGRect, language: String?, code: String, fenceRange: NSRange) {
self.id = id
self.rect = rect
self.language = language
self.code = code
self.fenceRange = fenceRange
}
}
@@ -77,6 +77,7 @@ extension NativeTextViewCoordinator {
guard !activeTokenIndices.contains(originalIndex) else { return nil }
if let visibleRange, NSIntersectionRange(token.range, visibleRange).length == 0 { return nil }
guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil }
guard let fenceRange = token.markerRanges.first else { return nil }
boundingRect.origin.x = textView.frame.origin.x + textView.textContainerOrigin.x - scrollOffset.x
boundingRect.size.width = textContainer.containerSize.width
@@ -85,7 +86,8 @@ extension NativeTextViewCoordinator {
id: originalIndex,
rect: boundingRect,
language: MarkdownTokenizer.extractLanguage(from: token, in: textView.string),
code: nsText.substring(with: token.contentRange)
code: nsText.substring(with: token.contentRange),
fenceRange: fenceRange
)
}
@@ -469,6 +469,34 @@ extension NativeTextViewCoordinator {
textView.setSelectedRange(NSRange(location: caretLocation, length: 0))
}
/// Replaces `request.range` verbatim unlike `applyTextInsertion`, does
/// NOT touch the current caret/selection at all, since the range being
/// replaced (e.g. a code block's fence line) usually isn't wherever the
/// caret happens to be. No-ops if `range` no longer fits inside the
/// current document (the doc changed shape since the request was built
/// safer to drop the edit than risk corrupting an unrelated range).
func applyTextRangeReplacement(_ request: TextRangeReplacementRequest, to textView: NSTextView) {
lastAppliedTextRangeReplacementID = request.id
let documentLength = (textView.string as NSString).length
guard request.range.location != NSNotFound,
NSMaxRange(request.range) <= documentLength else { return }
textView.breakUndoCoalescing()
isProgrammaticEdit = true
defer { isProgrammaticEdit = false }
guard textView.shouldChangeText(in: request.range, replacementString: request.replacement) else {
return
}
textView.textStorage?.replaceCharacters(in: request.range, with: request.replacement)
textView.didChangeText()
textView.undoManager?.setActionName("Change Code Block Language")
textView.breakUndoCoalescing()
}
func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) {
lastAppliedInlineReplacementID = request.id
@@ -110,6 +110,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
var wtPostUndoSnapshot: String?
var lastAppliedInlineReplacementID: UUID?
var lastAppliedTextInsertionID: UUID?
var lastAppliedTextRangeReplacementID: UUID?
var activeTokenIndices: Set<Int> = []
var previousActiveTokenIndices: Set<Int> = []
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
@@ -120,3 +120,36 @@ public struct TextInsertionRequest: Sendable {
self.text = text
}
}
/// Request to replace a SPECIFIC, already-known range e.g. a code block's
/// fence line (``CodeBlockSelection/fenceRange``) when switching its
/// language regardless of where the caret currently is. Unlike
/// ``TextInsertionRequest``, this doesn't touch the current selection at
/// all.
///
/// Embedders push one of these into
/// ``NativeTextViewWrapper/pendingTextRangeReplacement`` to commit it. The
/// range is in DISPLAY-form coordinates (the same space `CodeBlockSelection`
/// and every other rect/range the engine hands back use) stale by the
/// time it's applied only if the document changed shape in between, in
/// which case the engine no-ops rather than risk corrupting an unrelated
/// range.
public struct TextRangeReplacementRequest: Sendable {
/// Stable identifier so the engine can detect already-applied requests
/// across SwiftUI re-renders.
public let id: UUID
/// Document the replacement targets. Ignored if it doesn't match the
/// editor's current `documentId` (prevents cross-document writes).
public let documentId: String
/// Range to replace, in the display-form string's coordinates.
public let range: NSRange
/// Text to put in `range`'s place.
public let replacement: String
public init(id: UUID = UUID(), documentId: String, range: NSRange, replacement: String) {
self.id = id
self.documentId = documentId
self.range = range
self.replacement = replacement
}
}
@@ -59,6 +59,11 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
/// value; the engine applies it on the next update and then clears the
/// binding. See ``TextInsertionRequest``.
@Binding public var pendingTextInsertion: TextInsertionRequest?
/// Push a specific-range replacement (e.g. rewriting a code block's
/// fence line to change its language) by setting this to a non-nil
/// value; the engine applies it on the next update and then clears the
/// binding. See ``TextRangeReplacementRequest``.
@Binding public var pendingTextRangeReplacement: TextRangeReplacementRequest?
/// The full editor configuration (theme + services + style toggles). Engine
/// embedders construct this themselves and pass it in; the wrapper does
/// not read UserDefaults or know about app-specific colors/services.
@@ -159,6 +164,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
isWikiLinkActive: Binding<Bool> = .constant(false),
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
pendingTextInsertion: Binding<TextInsertionRequest?> = .constant(nil),
pendingTextRangeReplacement: Binding<TextRangeReplacementRequest?> = .constant(nil),
configuration: MarkdownEditorConfiguration = .default,
fontName: String = "SF Pro",
fontSize: CGFloat = 16,
@@ -188,6 +194,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
self._isWikiLinkActive = isWikiLinkActive
self._pendingInlineReplacement = pendingInlineReplacement
self._pendingTextInsertion = pendingTextInsertion
self._pendingTextRangeReplacement = pendingTextRangeReplacement
self.configuration = configuration
self.fontName = fontName
self.fontSize = fontSize
@@ -636,6 +643,18 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
}
return
}
if let pendingTextRangeReplacement {
if pendingTextRangeReplacement.documentId == documentId,
context.coordinator.lastAppliedTextRangeReplacementID != pendingTextRangeReplacement.id {
context.coordinator.applyTextRangeReplacement(pendingTextRangeReplacement, to: textView)
}
DispatchQueue.main.async {
if self.pendingTextRangeReplacement?.id == pendingTextRangeReplacement.id {
self.pendingTextRangeReplacement = nil
}
}
return
}
if context.coordinator.didInitialFormatting
&& context.coordinator.lastSyncedText == text
&& !fontChanged {