// // NativeTextEditor.swift // oAI // // NSViewRepresentable text editor with correct Enter-key semantics: // plain Enter → send, Shift+Enter or Cmd+Enter → newline. // // SPDX-License-Identifier: AGPL-3.0-or-later // 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 if isFocused { DispatchQueue.main.async { guard let window = tv.window, window.firstResponder !== tv else { return } window.makeFirstResponder(tv) } } } func makeCoordinator() -> Coordinator { Coordinator() } // 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 } override init() { super.init() textView.coordinator = self } func textDidChange(_ notification: Notification) { guard let tv = notification.object as? NSTextView else { return } textBinding?.wrappedValue = tv.string } } } // 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 } }