Add live code formatting in the chat input
Inline single-backtick spans and multi-line fenced ```blocks``` now render with monospace styling as you type, plus real per-language syntax highlighting for fenced blocks (reusing the existing SyntaxHighlighter utility). Only complete, closed spans/fences light up — an unterminated backtick or fence is left as plain text until closed. Pure regex/range logic extracted into testable static functions (inlineCodeRanges, fencedCodeBlocks, fencedCodeBlockRanges) rather than living inline in the NSTextView coordinator.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//
|
||||
// NativeTextEditor.swift
|
||||
// oAI
|
||||
// Confab
|
||||
//
|
||||
// NSViewRepresentable text editor with correct Enter-key semantics:
|
||||
// plain Enter → send, Shift+Enter or Cmd+Enter → newline.
|
||||
@@ -85,6 +85,10 @@ struct NativeTextEditor: NSViewRepresentable {
|
||||
coord.onUpArrow = onUpArrow
|
||||
coord.onDownArrow = onDownArrow
|
||||
coord.onFocusChange = onFocusChange
|
||||
coord.baseFont = font
|
||||
coord.baseTextColor = textColor
|
||||
|
||||
coord.applyInlineCodeStyling()
|
||||
|
||||
if isFocused {
|
||||
DispatchQueue.main.async {
|
||||
@@ -96,6 +100,50 @@ struct NativeTextEditor: NSViewRepresentable {
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
/// Ranges of complete, closed single-backtick spans on one line (e.g. "Hello `code` world" →
|
||||
/// the range covering `` `code` ``, backticks included). An unterminated backtick with no
|
||||
/// closing pair yet is deliberately not matched — it only lights up once closed. Doesn't match
|
||||
/// across a newline, so a fenced block's opening/closing ``` triples never get mistaken for
|
||||
/// this. Pulled out as a pure function so it's testable without a live NSTextView.
|
||||
nonisolated static func inlineCodeRanges(in text: String) -> [NSRange] {
|
||||
guard let regex = try? NSRegularExpression(pattern: "`[^`\\n]+`") else { return [] }
|
||||
let nsText = text as NSString
|
||||
return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { $0.range }
|
||||
}
|
||||
|
||||
/// Complete, closed fenced blocks, each with the full ```…``` range, the language tag (if any,
|
||||
/// from right after the opening fence, e.g. "```python"), and the range of just the code
|
||||
/// content (excluding the fences and the language-tag line). An unterminated fence with no
|
||||
/// closing ``` yet is not matched, same "only when closed" rule as inline spans.
|
||||
nonisolated static func fencedCodeBlocks(in text: String) -> [(fullRange: NSRange, language: String?, codeRange: NSRange)] {
|
||||
guard let regex = try? NSRegularExpression(pattern: "```([A-Za-z0-9_+-]*)[ \\t]*\\n([\\s\\S]*?)```") else { return [] }
|
||||
let nsText = text as NSString
|
||||
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
|
||||
return matches.map { match in
|
||||
let langRange = match.range(at: 1)
|
||||
let language = langRange.length > 0 ? nsText.substring(with: langRange) : nil
|
||||
return (fullRange: match.range, language: language, codeRange: match.range(at: 2))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranges of complete, closed triple-backtick fenced blocks (may span multiple lines,
|
||||
/// including an optional language tag right after the opening fence). An unterminated fence
|
||||
/// with no closing ``` yet is not matched, same "only when closed" rule as inline spans.
|
||||
nonisolated static func fencedCodeBlockRanges(in text: String) -> [NSRange] {
|
||||
fencedCodeBlocks(in: text).map { $0.fullRange }
|
||||
}
|
||||
|
||||
/// Every range that should render as code: fenced ```blocks``` plus inline `spans` — except
|
||||
/// any inline span that falls inside a fenced block (so a stray backtick inside a code block's
|
||||
/// own content never gets double-styled or splits the block's styling).
|
||||
nonisolated static func codeStyledRanges(in text: String) -> [NSRange] {
|
||||
let fenced = fencedCodeBlockRanges(in: text)
|
||||
let inline = inlineCodeRanges(in: text).filter { inlineRange in
|
||||
!fenced.contains { NSIntersectionRange($0, inlineRange).length > 0 }
|
||||
}
|
||||
return (fenced + inline).sorted { $0.location < $1.location }
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
@@ -108,6 +156,8 @@ struct NativeTextEditor: NSViewRepresentable {
|
||||
var onUpArrow: () -> Bool = { false }
|
||||
var onDownArrow: () -> Bool = { false }
|
||||
var onFocusChange: (Bool) -> Void = { _ in }
|
||||
var baseFont: NSFont = .systemFont(ofSize: NSFont.systemFontSize)
|
||||
var baseTextColor: NSColor = .textColor
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -117,6 +167,51 @@ struct NativeTextEditor: NSViewRepresentable {
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let tv = notification.object as? NSTextView else { return }
|
||||
textBinding?.wrappedValue = tv.string
|
||||
applyInlineCodeStyling()
|
||||
}
|
||||
|
||||
/// Re-applies code styling (inline spans and fenced blocks) to the whole text after any
|
||||
/// edit — purely visual (font/color attributes on the existing characters), never touches
|
||||
/// the actual string content, so the backticks/fences stay in the sent message as typed.
|
||||
func applyInlineCodeStyling() {
|
||||
let storage = textView.textStorage!
|
||||
let fullRange = NSRange(location: 0, length: storage.length)
|
||||
let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
|
||||
|
||||
storage.beginEditing()
|
||||
storage.setAttributes([.font: baseFont, .foregroundColor: baseTextColor], range: fullRange)
|
||||
for range in NativeTextEditor.codeStyledRanges(in: storage.string) {
|
||||
storage.addAttributes([
|
||||
.font: monoFont,
|
||||
.backgroundColor: NSColor.textColor.withAlphaComponent(0.08)
|
||||
], range: range)
|
||||
}
|
||||
applySyntaxHighlighting(to: storage)
|
||||
storage.endEditing()
|
||||
}
|
||||
|
||||
/// Colors keywords/strings/comments/numbers inside each fenced block's code content using
|
||||
/// the same per-language `SyntaxHighlighter` already used to render assistant messages —
|
||||
/// only overlays `.foregroundColor` on top of the monospace/background pass above, so it
|
||||
/// never fights that pass's font.
|
||||
private func applySyntaxHighlighting(to storage: NSTextStorage) {
|
||||
let nsText = storage.string as NSString
|
||||
for block in NativeTextEditor.fencedCodeBlocks(in: storage.string) {
|
||||
let codeRange = block.codeRange
|
||||
guard codeRange.location != NSNotFound, codeRange.length > 0 else { continue }
|
||||
let code = nsText.substring(with: codeRange)
|
||||
let highlighted = SyntaxHighlighter.highlight(code: code, language: block.language)
|
||||
// Read runs directly off the AttributedString rather than bridging to
|
||||
// NSAttributedString — that bridge stores SwiftUI's `.foregroundColor` under a
|
||||
// private `SwiftUI.ForegroundColor` key, not the standard Cocoa `.foregroundColor`
|
||||
// key, so it never actually carries the color over (confirmed empirically).
|
||||
for run in highlighted.runs {
|
||||
guard let color = run.foregroundColor else { continue }
|
||||
let runNSRange = NSRange(run.range, in: highlighted)
|
||||
let absoluteRange = NSRange(location: codeRange.location + runNSRange.location, length: runNSRange.length)
|
||||
storage.addAttribute(.foregroundColor, value: NSColor(color), range: absoluteRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//
|
||||
// NativeTextEditorPureLogicTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
@testable import Confab
|
||||
|
||||
@Suite("NativeTextEditor.inlineCodeRanges")
|
||||
struct NativeTextEditorPureLogicTests {
|
||||
|
||||
@Test("No backticks yields no ranges")
|
||||
func noBackticks() {
|
||||
#expect(NativeTextEditor.inlineCodeRanges(in: "hello world").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A single closed backtick pair is matched, backticks included")
|
||||
func singleClosedPair() {
|
||||
let text = "Hello `code` world"
|
||||
let ranges = NativeTextEditor.inlineCodeRanges(in: text)
|
||||
#expect(ranges.count == 1)
|
||||
let matched = (text as NSString).substring(with: ranges[0])
|
||||
#expect(matched == "`code`")
|
||||
}
|
||||
|
||||
@Test("An unterminated single backtick is not matched")
|
||||
func unterminatedBacktick() {
|
||||
let text = "Hello `code world"
|
||||
#expect(NativeTextEditor.inlineCodeRanges(in: text).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Multiple closed pairs are all matched")
|
||||
func multiplePairs() {
|
||||
let text = "`one` and `two` and `three`"
|
||||
let ranges = NativeTextEditor.inlineCodeRanges(in: text)
|
||||
#expect(ranges.count == 3)
|
||||
let nsText = text as NSString
|
||||
#expect(ranges.map { nsText.substring(with: $0) } == ["`one`", "`two`", "`three`"])
|
||||
}
|
||||
|
||||
@Test("Backtick pair cannot span a newline")
|
||||
func doesNotSpanNewline() {
|
||||
let text = "`code\nblock`"
|
||||
#expect(NativeTextEditor.inlineCodeRanges(in: text).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Empty string yields no ranges")
|
||||
func emptyString() {
|
||||
#expect(NativeTextEditor.inlineCodeRanges(in: "").isEmpty)
|
||||
}
|
||||
|
||||
@Test("Adjacent backtick pairs on the same line are each matched separately")
|
||||
func adjacentPairs() {
|
||||
let text = "`a``b`"
|
||||
let ranges = NativeTextEditor.inlineCodeRanges(in: text)
|
||||
let nsText = text as NSString
|
||||
#expect(ranges.map { nsText.substring(with: $0) } == ["`a`", "`b`"])
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("NativeTextEditor.fencedCodeBlockRanges")
|
||||
struct NativeTextEditorFencedBlockTests {
|
||||
|
||||
@Test("No fences yields no ranges")
|
||||
func noFences() {
|
||||
#expect(NativeTextEditor.fencedCodeBlockRanges(in: "hello world").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A closed multi-line fence is matched in full, including the language tag")
|
||||
func closedMultilineFence() {
|
||||
let text = "```python\nprint(\"hi\")\n```"
|
||||
let ranges = NativeTextEditor.fencedCodeBlockRanges(in: text)
|
||||
#expect(ranges.count == 1)
|
||||
#expect((text as NSString).substring(with: ranges[0]) == text)
|
||||
}
|
||||
|
||||
@Test("An unterminated fence (no closing triple) is not matched")
|
||||
func unterminatedFence() {
|
||||
let text = "```python\nprint(\"hi\")"
|
||||
#expect(NativeTextEditor.fencedCodeBlockRanges(in: text).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Text before and after a fence is excluded from the match")
|
||||
func surroundingTextExcluded() {
|
||||
let text = "before\n```\ncode\n```\nafter"
|
||||
let ranges = NativeTextEditor.fencedCodeBlockRanges(in: text)
|
||||
#expect(ranges.count == 1)
|
||||
#expect((text as NSString).substring(with: ranges[0]) == "```\ncode\n```")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("NativeTextEditor.codeStyledRanges")
|
||||
struct NativeTextEditorCodeStyledRangesTests {
|
||||
|
||||
@Test("Combines a fenced block and a separate inline span")
|
||||
func combinesFencedAndInline() {
|
||||
let text = "See `foo` then:\n```\nbar\n```"
|
||||
let ranges = NativeTextEditor.codeStyledRanges(in: text)
|
||||
let nsText = text as NSString
|
||||
#expect(ranges.map { nsText.substring(with: $0) } == ["`foo`", "```\nbar\n```"])
|
||||
}
|
||||
|
||||
@Test("A backtick inside a fenced block's own content is not separately re-matched as inline")
|
||||
func backtickInsideFenceNotDoubleMatched() {
|
||||
let text = "```\nuse `code` here\n```"
|
||||
let ranges = NativeTextEditor.codeStyledRanges(in: text)
|
||||
#expect(ranges.count == 1)
|
||||
#expect((text as NSString).substring(with: ranges[0]) == text)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("NativeTextEditor.fencedCodeBlocks (language + code-content extraction)")
|
||||
struct NativeTextEditorFencedCodeBlocksTests {
|
||||
|
||||
@Test("Extracts the language tag and the code content, excluding fences and the tag line")
|
||||
func extractsLanguageAndCodeContent() {
|
||||
let text = "```python\nprint(\"hi\")\n```"
|
||||
let blocks = NativeTextEditor.fencedCodeBlocks(in: text)
|
||||
#expect(blocks.count == 1)
|
||||
#expect(blocks[0].language == "python")
|
||||
let nsText = text as NSString
|
||||
#expect(nsText.substring(with: blocks[0].codeRange) == "print(\"hi\")\n")
|
||||
}
|
||||
|
||||
@Test("No language tag yields a nil language")
|
||||
func noLanguageTagYieldsNil() {
|
||||
let text = "```\ncode\n```"
|
||||
let blocks = NativeTextEditor.fencedCodeBlocks(in: text)
|
||||
#expect(blocks.count == 1)
|
||||
#expect(blocks[0].language == nil)
|
||||
}
|
||||
|
||||
@Test("Language alias is passed through as typed, unresolved (SyntaxHighlighter resolves aliases itself)")
|
||||
func languageAliasPassthrough() {
|
||||
let text = "```py\nprint(1)\n```"
|
||||
let blocks = NativeTextEditor.fencedCodeBlocks(in: text)
|
||||
#expect(blocks[0].language == "py")
|
||||
}
|
||||
|
||||
@Test("Unterminated fence yields no blocks")
|
||||
func unterminatedFenceYieldsNoBlocks() {
|
||||
#expect(NativeTextEditor.fencedCodeBlocks(in: "```python\nprint(1)").isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("SyntaxHighlighter runs → NSColor")
|
||||
struct SyntaxHighlighterBridgingTests {
|
||||
|
||||
// NB: bridging via `NSAttributedString(highlighted)` does NOT work — that path stores
|
||||
// SwiftUI's `.foregroundColor` under a private `SwiftUI.ForegroundColor` key, not the
|
||||
// standard Cocoa `.foregroundColor` key (confirmed empirically before landing on this
|
||||
// `.runs`-based approach, which is what `NativeTextEditor` actually uses in production).
|
||||
|
||||
@Test("A Python keyword's foreground color is readable via AttributedString.runs and converts to NSColor")
|
||||
func keywordColorReadableViaRuns() {
|
||||
let highlighted = SyntaxHighlighter.highlight(code: "def foo():", language: "python")
|
||||
#expect(!highlighted.runs.isEmpty)
|
||||
|
||||
var foundKeywordColor = false
|
||||
let expected = NSColor(SyntaxHighlighter.keywordColor)
|
||||
for run in highlighted.runs {
|
||||
guard let color = run.foregroundColor else { continue }
|
||||
if NSColor(color).usingColorSpace(.deviceRGB) == expected.usingColorSpace(.deviceRGB) {
|
||||
foundKeywordColor = true
|
||||
}
|
||||
}
|
||||
#expect(foundKeywordColor)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user