Files
openclaw/apps/macos/Sources/OpenClaw/QuickChatTextView.swift
Peter Steinberger 45f6543c9a feat(macos): Quick Chat power features — voice dictation, paste-to-app, model/reasoning control (#110994)
* feat(macos): add Quick Chat power features

* docs(macos): document Quick Chat power features

* chore(i18n): refresh native locale artifacts for Quick Chat power features

* fix(macos): update DEBUG test helper sendProvider to the reasoning-threaded arity
2026-07-18 17:21:03 -07:00

164 lines
6.2 KiB
Swift

import AppKit
import SwiftUI
struct QuickChatTextView: NSViewRepresentable {
@Binding var text: String
let selectionRange: NSRange?
let onSubmit: (Bool) -> Void
let onEscape: () -> Void
let onUserEdit: () -> Void
let onHeightChange: (CGFloat) -> Void
let onTextViewReady: (NSTextView) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let textView = QuickChatNSTextView()
textView.delegate = context.coordinator
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.font = .systemFont(ofSize: 13.5)
textView.textColor = .labelColor
textView.insertionPointColor = .controlAccentColor
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 6)
textView.minSize = .zero
textView.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
textView.focusRingType = .none
textView.string = self.text
textView.onSubmit = self.onSubmit
textView.onEscape = self.onEscape
let scrollView = NSScrollView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = false
scrollView.autohidesScrollers = true
scrollView.scrollerStyle = .overlay
scrollView.hasHorizontalScroller = false
scrollView.documentView = textView
context.coordinator.scrollView = scrollView
DispatchQueue.main.async {
self.onTextViewReady(textView)
context.coordinator.updateHeight(for: textView)
}
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self
guard let textView = scrollView.documentView as? QuickChatNSTextView else { return }
textView.onSubmit = self.onSubmit
textView.onEscape = self.onEscape
let textChanged = textView.string != self.text && !textView.hasMarkedText()
if textChanged {
context.coordinator.isProgrammaticUpdate = true
textView.string = self.text
}
if !textView.hasMarkedText(), let selectionRange = self.selectionRange {
let selection = NSRange(
location: min(selectionRange.location, textView.string.utf16.count),
length: 0)
if context.coordinator.lastAppliedSelectionRange != selection {
textView.setSelectedRange(selection)
context.coordinator.lastAppliedSelectionRange = selection
}
} else if textChanged {
let selection = NSRange(location: textView.string.utf16.count, length: 0)
textView.setSelectedRange(selection)
context.coordinator.lastAppliedSelectionRange = nil
} else if self.selectionRange == nil {
context.coordinator.lastAppliedSelectionRange = nil
}
if textChanged {
context.coordinator.isProgrammaticUpdate = false
}
DispatchQueue.main.async {
context.coordinator.updateHeight(for: textView)
}
}
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: QuickChatTextView
weak var scrollView: NSScrollView?
var isProgrammaticUpdate = false
var lastAppliedSelectionRange: NSRange?
private var lastHeight: CGFloat = 0
init(_ parent: QuickChatTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? QuickChatNSTextView else { return }
if !self.isProgrammaticUpdate {
self.parent.onUserEdit()
self.parent.text = textView.string
}
self.updateHeight(for: textView)
}
func updateHeight(for textView: NSTextView) {
guard let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer else { return }
layoutManager.ensureLayout(for: textContainer)
let font = textView.font ?? .systemFont(ofSize: 13.5)
let lineHeight = ceil(layoutManager.defaultLineHeight(for: font))
let naturalHeight = ceil(layoutManager.usedRect(for: textContainer).height + 12)
let minHeight = lineHeight + 12
let maxHeight = (lineHeight * 5) + 12
let height = min(max(naturalHeight, minHeight), maxHeight)
self.scrollView?.hasVerticalScroller = naturalHeight > maxHeight
guard abs(height - self.lastHeight) > 0.5 else { return }
self.lastHeight = height
self.parent.onHeightChange(height)
}
}
}
private final class QuickChatNSTextView: NSTextView {
var onSubmit: ((Bool) -> Void)?
var onEscape: (() -> Void)?
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 {
guard !self.hasMarkedText() else {
super.keyDown(with: event)
return
}
self.onEscape?()
return
}
guard event.keyCode == 36 || event.keyCode == 76 else {
super.keyDown(with: event)
return
}
guard !self.hasMarkedText() else {
super.keyDown(with: event)
return
}
let modifiers = event.modifierFlags.intersection([.command, .shift])
if modifiers.contains(.shift), !modifiers.contains(.command) {
self.insertNewline(nil)
} else {
self.onSubmit?(modifiers.contains(.command))
}
}
}