Fix Enter key semantics and add expandable model descriptions
- 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>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user