diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift index 9985223..1c7fe42 100644 --- a/oAI/Views/Main/InputBar.swift +++ b/oAI/Views/Main/InputBar.swift @@ -44,7 +44,7 @@ struct InputBar: View { @State private var showCommandDropdown = false @State private var selectedSuggestionIndex: Int = 0 - @FocusState private var isInputFocused: Bool + @State private var isInputFocused: Bool = false private static let minInputHeight: CGFloat = 56 private static let maxInputHeight: CGFloat = 320 @@ -95,55 +95,50 @@ struct InputBar: View { } // Editor — fills the fixed-height box, bottom area reserved for globe - TextEditor(text: $text) - .font(.system(size: settings.inputTextSize)) - .foregroundColor(.oaiPrimary) - .scrollContentBackground(.hidden) - .padding(.horizontal, 8) - .padding(.top, 6) - .padding(.bottom, 30) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .focused($isInputFocused) - .onChange(of: text) { - showCommandDropdown = text.hasPrefix("/") - selectedSuggestionIndex = 0 - } - #if os(macOS) - .onKeyPress(.upArrow) { - if showCommandDropdown && selectedSuggestionIndex > 0 { - selectedSuggestionIndex -= 1 - return .handled - } - return .ignored - } - .onKeyPress(.downArrow) { - if showCommandDropdown { - let count = CommandSuggestionsView.filteredCommands(for: text).count - if selectedSuggestionIndex < count - 1 { - selectedSuggestionIndex += 1 - return .handled - } - } - return .ignored - } - .onKeyPress(.escape) { - if showCommandDropdown { showCommandDropdown = false; return .handled } - if isGenerating { onCancel(); return .handled } - return .ignored - } - .onKeyPress(.return, phases: .down) { press in - if press.modifiers.contains(.shift) { return .ignored } + NativeTextEditor( + text: $text, + font: .systemFont(ofSize: settings.inputTextSize), + textColor: NSColor(Color.oaiPrimary), + isFocused: isInputFocused, + onReturn: { if showCommandDropdown { let suggestions = CommandSuggestionsView.filteredCommands(for: text) if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { selectCommand(suggestions[selectedSuggestionIndex].command) - return .handled + return true } } - if !text.isEmpty { onSend(); return .handled } - return .handled - } - #endif + if !text.isEmpty { onSend(); return true } + return true + }, + onEscape: { + if showCommandDropdown { showCommandDropdown = false; return true } + if isGenerating { onCancel(); return true } + return false + }, + onUpArrow: { + if showCommandDropdown && selectedSuggestionIndex > 0 { + selectedSuggestionIndex -= 1; return true + } + return false + }, + onDownArrow: { + if showCommandDropdown { + let count = CommandSuggestionsView.filteredCommands(for: text).count + if selectedSuggestionIndex < count - 1 { + selectedSuggestionIndex += 1; return true + } + } + return false + }, + onFocusChange: { focused in isInputFocused = focused } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: text) { + showCommandDropdown = text.hasPrefix("/") + selectedSuggestionIndex = 0 + } + .padding(.bottom, 30) // Online / offline toggle — bottom-left of the text box VStack { diff --git a/oAI/Views/Main/NativeTextEditor.swift b/oAI/Views/Main/NativeTextEditor.swift new file mode 100644 index 0000000..9902925 --- /dev/null +++ b/oAI/Views/Main/NativeTextEditor.swift @@ -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? + 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 + } +} diff --git a/oAI/Views/Screens/ModelInfoView.swift b/oAI/Views/Screens/ModelInfoView.swift index e785c76..aabce4a 100644 --- a/oAI/Views/Screens/ModelInfoView.swift +++ b/oAI/Views/Screens/ModelInfoView.swift @@ -30,6 +30,7 @@ struct ModelInfoView: View { @Environment(\.dismiss) var dismiss @Bindable private var settings = SettingsService.shared + @State private var isDescriptionExpanded = false var body: some View { VStack(spacing: 0) { @@ -78,8 +79,18 @@ struct ModelInfoView: View { Text(desc) .font(.body) .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(isDescriptionExpanded ? nil : 4) .textSelection(.enabled) + if desc.count > 250 { + Button(isDescriptionExpanded ? "Less" : "More…") { + withAnimation(.easeInOut(duration: 0.2)) { + isDescriptionExpanded.toggle() + } + } + .font(.callout) + .foregroundStyle(.blue) + .buttonStyle(.plain) + } } .padding(.leading, 4) }