b3bb7c4a59
- Replace TextEditor with NativeTextEditor (NSViewRepresentable) so plain Enter sends the message and Shift/Cmd+Enter inserts a newline. The old TextEditor passed bare Return directly to NSTextView before SwiftUI's onKeyPress could intercept it, accidentally making Cmd+Enter send instead. - Add More…/Less toggle in ModelInfoView for descriptions longer than 250 characters, with smooth expand/collapse animation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
5.7 KiB
Swift
172 lines
5.7 KiB
Swift
//
|
|
// 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<String>?
|
|
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
|
|
}
|
|
}
|