diff --git a/oAI/Views/Main/NativeTextEditor.swift b/oAI/Views/Main/NativeTextEditor.swift index d7820ad..511c1d9 100644 --- a/oAI/Views/Main/NativeTextEditor.swift +++ b/oAI/Views/Main/NativeTextEditor.swift @@ -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) + } + } } } } diff --git a/oAITests/NativeTextEditorPureLogicTests.swift b/oAITests/NativeTextEditorPureLogicTests.swift new file mode 100644 index 0000000..212d121 --- /dev/null +++ b/oAITests/NativeTextEditorPureLogicTests.swift @@ -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) + } +}