// // NativeTextEditor.swift // Confab // // NSViewRepresentable text editor with correct Enter-key semantics: // plain Enter → send, Shift+Enter or Cmd+Enter → newline. // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen import SwiftUI import AppKit struct NativeTextEditor: NSViewRepresentable { @Binding var text: String var font: NSFont var textColor: NSColor var isFocused: Bool /// Plain Enter (no modifiers). Return true if the event was consumed. var onReturn: () -> Bool /// Escape key. Return true if consumed. var onEscape: () -> Bool /// Up arrow. Return true if consumed. var onUpArrow: () -> Bool /// Down arrow. Return true if consumed. var onDownArrow: () -> Bool /// Called when the view gains or loses first-responder status. var onFocusChange: (Bool) -> Void // MARK: - NSViewRepresentable func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() scrollView.hasVerticalScroller = false scrollView.hasHorizontalScroller = false scrollView.drawsBackground = false scrollView.borderType = .noBorder let tv = context.coordinator.textView tv.delegate = context.coordinator tv.isEditable = true tv.isRichText = false tv.drawsBackground = false tv.backgroundColor = .clear tv.isAutomaticQuoteSubstitutionEnabled = false tv.isAutomaticDashSubstitutionEnabled = false tv.isAutomaticSpellingCorrectionEnabled = true tv.isContinuousSpellCheckingEnabled = true tv.allowsUndo = true tv.isVerticallyResizable = true tv.isHorizontallyResizable = false tv.autoresizingMask = [.width] tv.textContainer?.widthTracksTextView = true tv.textContainerInset = NSSize(width: 8, height: 6) scrollView.documentView = tv return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { let tv = context.coordinator.textView let coord = context.coordinator // Update text only when it differs (avoids caret-jumping on every keystroke) if tv.string != text { let sel = tv.selectedRanges tv.string = text let len = (tv.string as NSString).length tv.selectedRanges = sel.map { v in let r = v.rangeValue let loc = min(r.location, len) let length = min(r.length, max(0, len - loc)) return NSValue(range: NSRange(location: loc, length: length)) } } if tv.font != font { tv.font = font } if tv.textColor != textColor { tv.textColor = textColor } // Keep coordinator callbacks current with each SwiftUI render coord.textBinding = $text coord.onReturn = onReturn coord.onEscape = onEscape coord.onUpArrow = onUpArrow coord.onDownArrow = onDownArrow coord.onFocusChange = onFocusChange coord.baseFont = font coord.baseTextColor = textColor coord.applyInlineCodeStyling() if isFocused { DispatchQueue.main.async { guard let window = tv.window, window.firstResponder !== tv else { return } window.makeFirstResponder(tv) } } } 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 { let textView = KeyableNSTextView() // Updated on every SwiftUI render via updateNSView var textBinding: Binding? var onReturn: () -> Bool = { false } var onEscape: () -> Bool = { false } 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() textView.coordinator = self } 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) } } } } } // MARK: - KeyableNSTextView /// NSTextView that routes Return / Escape / arrow keys to the SwiftUI /// coordinator before the AppKit default handling runs. final class KeyableNSTextView: NSTextView { weak var coordinator: NativeTextEditor.Coordinator? override func keyDown(with event: NSEvent) { guard let coord = coordinator else { super.keyDown(with: event); return } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let shift = flags.contains(.shift) let cmd = flags.contains(.command) switch event.keyCode { case 36: // Return if shift || cmd { // Shift+Enter or Cmd+Enter → literal newline insertNewlineIgnoringFieldEditor(nil) } else { // Plain Enter → let SwiftUI decide (send or select dropdown item) if !coord.onReturn() { insertNewlineIgnoringFieldEditor(nil) } } case 53: // Escape if !coord.onEscape() { super.keyDown(with: event) } case 126: // Up arrow if !coord.onUpArrow() { super.keyDown(with: event) } case 125: // Down arrow if !coord.onDownArrow() { super.keyDown(with: event) } default: super.keyDown(with: event) } } override func becomeFirstResponder() -> Bool { let ok = super.becomeFirstResponder() if ok { coordinator?.onFocusChange(true) } return ok } override func resignFirstResponder() -> Bool { let ok = super.resignFirstResponder() if ok { coordinator?.onFocusChange(false) } return ok } }