mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 20:21:11 +00:00
* 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
164 lines
6.2 KiB
Swift
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))
|
|
}
|
|
}
|
|
}
|