mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 20:51:40 +00:00
* fix(ios): restore hardware return in chat composer * test(ios): cover hardware return composer flow * fix(ios): route history keys through native editor * fix(ios): satisfy composer lint gates * test(ios): fix snapshot app lookup * test(ios): keep snapshot helper nonthrowing --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
163 lines
5.7 KiB
Swift
163 lines
5.7 KiB
Swift
#if os(iOS)
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
@MainActor
|
|
struct ChatComposerTextViewIOS: UIViewRepresentable {
|
|
@Binding var text: String
|
|
var shouldFocus: Bool
|
|
var isEnabled: Bool
|
|
var minHeight: CGFloat
|
|
var maxHeight: CGFloat
|
|
var onFocusChange: (Bool) -> Void
|
|
var onHistoryUp: (Bool) -> Bool
|
|
var onHistoryDown: () -> Bool
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(self)
|
|
}
|
|
|
|
func makeUIView(context: Context) -> ChatComposerUITextView {
|
|
let textView = ChatComposerTextViewIOSFactory.makeConfiguredTextView()
|
|
textView.delegate = context.coordinator
|
|
textView.text = self.text
|
|
self.configureHistoryHandlers(textView)
|
|
return textView
|
|
}
|
|
|
|
func updateUIView(_ textView: ChatComposerUITextView, context: Context) {
|
|
context.coordinator.parent = self
|
|
textView.isEditable = self.isEnabled
|
|
textView.isSelectable = self.isEnabled
|
|
self.configureHistoryHandlers(textView)
|
|
|
|
if self.shouldFocus, self.isEnabled, !textView.isFirstResponder {
|
|
textView.becomeFirstResponder()
|
|
} else if !self.shouldFocus || !self.isEnabled, textView.isFirstResponder {
|
|
textView.resignFirstResponder()
|
|
}
|
|
|
|
let isEcho = context.coordinator.lastReportedText == self.text
|
|
if textView.isFirstResponder, isEcho {
|
|
return
|
|
}
|
|
|
|
if textView.text != self.text {
|
|
context.coordinator.isProgrammaticUpdate = true
|
|
defer { context.coordinator.isProgrammaticUpdate = false }
|
|
textView.text = self.text
|
|
if textView.isFirstResponder {
|
|
textView.selectedRange = NSRange(location: (self.text as NSString).length, length: 0)
|
|
}
|
|
textView.invalidateIntrinsicContentSize()
|
|
}
|
|
context.coordinator.lastReportedText = self.text
|
|
}
|
|
|
|
private func configureHistoryHandlers(_ textView: ChatComposerUITextView) {
|
|
textView.onHistoryUp = self.onHistoryUp
|
|
textView.onHistoryDown = self.onHistoryDown
|
|
}
|
|
|
|
func sizeThatFits(
|
|
_ proposal: ProposedViewSize,
|
|
uiView: ChatComposerUITextView,
|
|
context _: Context) -> CGSize?
|
|
{
|
|
guard let width = proposal.width else { return nil }
|
|
let fitting = uiView.sizeThatFits(
|
|
CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
|
|
return CGSize(
|
|
width: width,
|
|
height: min(max(fitting.height, self.minHeight), self.maxHeight))
|
|
}
|
|
|
|
@MainActor
|
|
final class Coordinator: NSObject, UITextViewDelegate {
|
|
var parent: ChatComposerTextViewIOS
|
|
var isProgrammaticUpdate = false
|
|
var lastReportedText: String?
|
|
|
|
init(_ parent: ChatComposerTextViewIOS) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
self.parent.onFocusChange(true)
|
|
}
|
|
|
|
func textViewDidEndEditing(_ textView: UITextView) {
|
|
self.parent.onFocusChange(false)
|
|
}
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
guard !self.isProgrammaticUpdate, textView.isFirstResponder else { return }
|
|
self.lastReportedText = textView.text
|
|
self.parent.text = textView.text
|
|
textView.invalidateIntrinsicContentSize()
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ChatComposerUITextView: UITextView {
|
|
var onHistoryUp: ((Bool) -> Bool)?
|
|
var onHistoryDown: (() -> Bool)?
|
|
|
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
|
var unhandledPresses = presses
|
|
for press in presses {
|
|
guard let key = press.key else { continue }
|
|
if self.handleHardwareKey(key.keyCode, modifierFlags: key.modifierFlags) {
|
|
unhandledPresses.remove(press)
|
|
}
|
|
}
|
|
guard !unhandledPresses.isEmpty else { return }
|
|
super.pressesBegan(unhandledPresses, with: event)
|
|
}
|
|
|
|
/// Internal for focused responder-level keyboard routing coverage.
|
|
func handleHardwareKey(
|
|
_ keyCode: UIKeyboardHIDUsage,
|
|
modifierFlags: UIKeyModifierFlags) -> Bool
|
|
{
|
|
let commandModifiers: UIKeyModifierFlags = [.shift, .control, .alternate, .command]
|
|
guard modifierFlags.isDisjoint(with: commandModifiers) else { return false }
|
|
switch keyCode {
|
|
case .keyboardUpArrow:
|
|
return self.onHistoryUp?(self.caretOnFirstLine) == true
|
|
case .keyboardDownArrow:
|
|
return self.onHistoryDown?() == true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var caretOnFirstLine: Bool {
|
|
let location = min(max(self.selectedRange.location, 0), (self.text as NSString).length)
|
|
let prefix = (self.text as NSString).substring(to: location)
|
|
return !prefix.contains("\n") && !prefix.contains("\r")
|
|
}
|
|
}
|
|
|
|
enum ChatComposerTextViewIOSFactory {
|
|
/// Internal for @testable import coverage of native multiline input defaults.
|
|
@MainActor
|
|
static func makeConfiguredTextView() -> ChatComposerUITextView {
|
|
let textView = ChatComposerUITextView()
|
|
textView.backgroundColor = .clear
|
|
textView.font = OpenClawChatTypography.bodyUIFont
|
|
textView.adjustsFontForContentSizeCategory = true
|
|
textView.allowsEditingTextAttributes = false
|
|
textView.isScrollEnabled = true
|
|
textView.showsVerticalScrollIndicator = false
|
|
textView.textContainerInset = .zero
|
|
textView.textContainer.lineFragmentPadding = 0
|
|
textView.returnKeyType = .default
|
|
textView.accessibilityIdentifier = "chat-message-input"
|
|
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
return textView
|
|
}
|
|
}
|
|
#endif
|