Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposerTextViewIOS.swift
metaforismo 102a5915ef fix(ios): restore Magic Keyboard return in chat composer (#116042)
* 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>
2026-07-31 16:21:41 -07:00

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