diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 262cc8a8a7..9ae30968b2 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -137,6 +137,7 @@ private enum TerminalInputSequence { private final class TerminalInputField: UITextField { var onDeleteBackward: (() -> Void)? var onInsert: ((String) -> Void)? + private let deletionRouter = TerminalDeletionRouter() private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands( action: #selector(handleHardwareKeyCommand(_:)) @@ -147,8 +148,14 @@ private final class TerminalInputField: UITextField { } override func deleteBackward() { - onDeleteBackward?() - super.deleteBackward() + deletionRouter.deleteBackward( + emit: { [weak self] in self?.onDeleteBackward?() }, + performSystemDelete: { super.deleteBackward() } + ) + } + + func reportEmptyReplacement() { + deletionRouter.emptyReplacement { [weak self] in self?.onDeleteBackward?() } } @objc @@ -410,6 +417,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { return false } + // Some software keyboards report deletion here without calling deleteBackward. + // The field suppresses a duplicate if UIKit also calls this delegate from super.deleteBackward. + if textField === inputField { + inputField.reportEmptyReplacement() + } return false } diff --git a/apps/mobile/modules/t3-terminal/ios/TerminalDeletionRouter.swift b/apps/mobile/modules/t3-terminal/ios/TerminalDeletionRouter.swift new file mode 100644 index 0000000000..5b766faa4b --- /dev/null +++ b/apps/mobile/modules/t3-terminal/ios/TerminalDeletionRouter.swift @@ -0,0 +1,17 @@ +/// Sends one terminal DEL for either UITextField deletion callback path. +/// `super.deleteBackward()` may synchronously call the editing delegate. +final class TerminalDeletionRouter { + private var insideDeleteBackward = false + + func deleteBackward(emit: () -> Void, performSystemDelete: () -> Void) { + insideDeleteBackward = true + defer { insideDeleteBackward = false } + emit() + performSystemDelete() + } + + func emptyReplacement(emit: () -> Void) { + guard !insideDeleteBackward else { return } + emit() + } +} diff --git a/apps/mobile/modules/t3-terminal/scripts/test-terminal-deletion.swift b/apps/mobile/modules/t3-terminal/scripts/test-terminal-deletion.swift new file mode 100644 index 0000000000..d5769906c9 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/scripts/test-terminal-deletion.swift @@ -0,0 +1,28 @@ +import Foundation + +@main +struct TerminalDeletionTest { + static func main() { + let router = TerminalDeletionRouter() + var sent = 0 + let emit = { sent += 1 } + + // Software keyboards may only send an empty replacement to the delegate. + router.emptyReplacement(emit: emit) + precondition(sent == 1) + + // UIKit may also invoke that delegate from the subclass's super call. + router.deleteBackward(emit: emit, performSystemDelete: { + router.emptyReplacement(emit: emit) + }) + precondition(sent == 2) + + // A hardware-key callback can arrive without the editing delegate. + router.deleteBackward(emit: emit, performSystemDelete: {}) + precondition(sent == 3) + + // Ownership resets after the call, so later software-keyboard deletes work. + router.emptyReplacement(emit: emit) + precondition(sent == 4) + } +}