Files
oai-swift/oAI/Views/Main/NativeTextEditor.swift
T
rune 0e4389d272 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.
2026-08-02 15:22:21 +02:00

267 lines
12 KiB
Swift

//
// 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<String>?
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
}
}