From e45bf2a01327115ba92336a85d88a83c86c6c3b6 Mon Sep 17 00:00:00 2001 From: CodeEagle Date: Tue, 19 May 2026 11:59:01 +0800 Subject: [PATCH] Fix iOS CJK soft keyboard input --- flutter/lib/mobile/pages/remote_page.dart | 76 +++--- .../mobile/utils/ios_soft_keyboard_input.dart | 228 ++++++++++++++++++ .../test/ios_soft_keyboard_input_test.dart | 199 +++++++++++++++ 3 files changed, 459 insertions(+), 44 deletions(-) create mode 100644 flutter/lib/mobile/utils/ios_soft_keyboard_input.dart create mode 100644 flutter/test/ios_soft_keyboard_input_test.dart diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 74a5af45c..4293d45e6 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -22,6 +22,7 @@ import '../../models/input_model.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../../utils/image.dart'; +import '../utils/ios_soft_keyboard_input.dart'; import '../widgets/dialog.dart'; import '../widgets/custom_scale_widget.dart'; @@ -66,6 +67,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { Orientation? _currentOrientation; final _uniqueKey = UniqueKey(); Timer? _iosKeyboardWorkaroundTimer; + String? _iosComposingValue; final _blockableOverlayState = BlockableOverlayState(); @@ -108,6 +110,9 @@ class _RemotePageState extends State with WidgetsBindingObserver { gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId); keyboardSubscription = keyboardVisibilityController.onChange.listen(onSoftKeyboardChanged); + if (isIOS) { + _textController.addListener(_handleIOSTextControllerChanged); + } gFFI.chatModel .changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID)); _blockableOverlayState.applyFfi(gFFI); @@ -126,6 +131,9 @@ class _RemotePageState extends State with WidgetsBindingObserver { @override Future dispose() async { WidgetsBinding.instance.removeObserver(this); + if (isIOS) { + _textController.removeListener(_handleIOSTextControllerChanged); + } // https://github.com/flutter/flutter/issues/64935 super.dispose(); gFFI.dialogManager.hideMobileActionsOverlay(store: false); @@ -214,53 +222,32 @@ class _RemotePageState extends State with WidgetsBindingObserver { } void _handleIOSSoftKeyboardInput(String newValue) { - var oldValue = _value; - _value = newValue; - var i = newValue.length - 1; - for (; i >= 0 && newValue[i] != '1'; --i) {} - var j = oldValue.length - 1; - for (; j >= 0 && oldValue[j] != '1'; --j) {} - if (i < j) j = i; - var subNewValue = newValue.substring(j + 1); - var subOldValue = oldValue.substring(j + 1); + final result = diffIOSSoftKeyboardInput( + previousValue: _value, + currentValue: newValue, + composingRange: _textController.value.composing, + previousComposingValue: _iosComposingValue, + ); + _value = result.nextValue; + _iosComposingValue = result.nextComposingValue; - // get common prefix of subNewValue and subOldValue - var common = 0; - for (; - common < subOldValue.length && - common < subNewValue.length && - subNewValue[common] == subOldValue[common]; - ++common) {} - - // get newStr from subNewValue - var newStr = ""; - if (subNewValue.length > common) { - newStr = subNewValue.substring(common); - } - - // Set the value to the old value and early return if is still composing. (1 && 2) - // 1. The composing range is valid - // 2. The new string is shorter than the composing range. - if (_textController.value.isComposingRangeValid) { - final composingLength = _textController.value.composing.end - - _textController.value.composing.start; - if (composingLength > newStr.length) { - _value = oldValue; - return; + for (final action in result.actions) { + switch (action.type) { + case IOSSoftKeyboardInputActionType.backspace: + inputModel.inputKey('VK_BACK'); + break; + case IOSSoftKeyboardInputActionType.inputKey: + inputChar(action.value); + break; + case IOSSoftKeyboardInputActionType.inputText: + bind.sessionInputString(sessionId: sessionId, value: action.value); + break; } } + } - // Delete the different part in the old value. - for (i = 0; i < subOldValue.length - common; ++i) { - inputModel.inputKey('VK_BACK'); - } - - // Input the new string. - if (newStr.length > 1) { - bind.sessionInputString(sessionId: sessionId, value: newStr); - } else { - inputChar(newStr); - } + void _handleIOSTextControllerChanged() { + _handleIOSSoftKeyboardInput(_textController.value.text); } void _handleNonIOSSoftKeyboardInput(String newValue) { @@ -326,6 +313,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { gFFI.invokeMethod("enable_soft_keyboard", true); // destroy first, so that our _value trick can work _value = initText; + _iosComposingValue = null; _textController.text = _value; setState(() => _showEdit = false); _timer?.cancel(); @@ -603,7 +591,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { // 2. The button will trigger `onKeyEvent` if the text field is empty. // ko/zh/ja input method: the button will trigger `onKeyEvent` // and the event will not popup if `KeyEventResult.handled` is returned. - onChanged: handleSoftKeyboardInput, + onChanged: isIOS ? null : handleSoftKeyboardInput, ).workaroundFreezeLinuxMint(), ), ]; diff --git a/flutter/lib/mobile/utils/ios_soft_keyboard_input.dart b/flutter/lib/mobile/utils/ios_soft_keyboard_input.dart new file mode 100644 index 000000000..fee4c9a1c --- /dev/null +++ b/flutter/lib/mobile/utils/ios_soft_keyboard_input.dart @@ -0,0 +1,228 @@ +import 'package:flutter/services.dart'; + +enum IOSSoftKeyboardInputActionType { + backspace, + inputKey, + inputText, +} + +class IOSSoftKeyboardInputAction { + final IOSSoftKeyboardInputActionType type; + final String value; + + const IOSSoftKeyboardInputAction.backspace() + : type = IOSSoftKeyboardInputActionType.backspace, + value = ''; + + const IOSSoftKeyboardInputAction.inputKey(this.value) + : type = IOSSoftKeyboardInputActionType.inputKey; + + const IOSSoftKeyboardInputAction.inputText(this.value) + : type = IOSSoftKeyboardInputActionType.inputText; + + @override + bool operator ==(Object other) { + return other is IOSSoftKeyboardInputAction && + other.type == type && + other.value == value; + } + + @override + int get hashCode => Object.hash(type, value); + + @override + String toString() => 'IOSSoftKeyboardInputAction($type, $value)'; +} + +class IOSSoftKeyboardInputResult { + final String nextValue; + final String? nextComposingValue; + final List actions; + + const IOSSoftKeyboardInputResult({ + required this.nextValue, + required this.nextComposingValue, + required this.actions, + }); +} + +IOSSoftKeyboardInputResult diffIOSSoftKeyboardInput({ + required String previousValue, + required String currentValue, + required TextRange composingRange, + String? previousComposingValue, + String sentinel = '1', +}) { + if (_shouldHoldComposingText( + currentValue, + composingRange, + previousComposingValue, + )) { + return IOSSoftKeyboardInputResult( + nextValue: previousValue, + nextComposingValue: currentValue.substring( + composingRange.start, + composingRange.end, + ), + actions: const [], + ); + } + + var currentSentinelIndex = _lastSentinelIndex(currentValue, sentinel); + var previousSentinelIndex = _lastSentinelIndex(previousValue, sentinel); + if (currentSentinelIndex < previousSentinelIndex) { + previousSentinelIndex = currentSentinelIndex; + } + + final currentTail = currentValue.substring(currentSentinelIndex + 1); + final previousTail = previousValue.substring(previousSentinelIndex + 1); + final commonPrefixLength = _commonPrefixLength(currentTail, previousTail); + final actions = []; + + final deleteCount = previousTail.length - commonPrefixLength; + for (var i = 0; i < deleteCount; i++) { + actions.add(const IOSSoftKeyboardInputAction.backspace()); + } + + final insertedText = currentTail.substring(commonPrefixLength); + if (insertedText.isNotEmpty) { + if (_shouldInputAsText(insertedText)) { + actions.add(IOSSoftKeyboardInputAction.inputText(insertedText)); + } else { + actions.add(IOSSoftKeyboardInputAction.inputKey(insertedText)); + } + } + + return IOSSoftKeyboardInputResult( + nextValue: currentValue, + nextComposingValue: null, + actions: actions, + ); +} + +bool _isValidComposingRange(String value, TextRange range) { + return range.isValid && + !range.isCollapsed && + range.isNormalized && + range.end <= value.length; +} + +bool _shouldHoldComposingText( + String value, + TextRange range, + String? previousComposingValue, +) { + if (!_isValidComposingRange(value, range)) return false; + + final composingValue = value.substring(range.start, range.end); + final kind = _compositionKind(composingValue); + if (kind == _CompositionKind.committedText) return false; + + if (previousComposingValue == null || previousComposingValue.isEmpty) { + return true; + } + + return _isComposingTransition(_compositionKind(previousComposingValue), kind); +} + +int _lastSentinelIndex(String value, String sentinel) { + if (sentinel.isEmpty) return -1; + return value.lastIndexOf(sentinel); +} + +int _commonPrefixLength(String a, String b) { + var common = 0; + while (common < a.length && common < b.length && a[common] == b[common]) { + common++; + } + return common; +} + +bool _shouldInputAsText(String value) { + if (value.length != 1) return true; + return value.runes.any((rune) => rune > 0x7F); +} + +enum _CompositionKind { + ascii, + stroke, + bopomofo, + japaneseKana, + koreanJamo, + koreanHangul, + committedText, +} + +_CompositionKind _compositionKind(String value) { + if (value.runes.every((rune) => rune <= 0x7F)) { + return _CompositionKind.ascii; + } + if (value.runes.every(_isChineseStrokeRune)) { + return _CompositionKind.stroke; + } + if (value.runes.every(_isBopomofoRune)) { + return _CompositionKind.bopomofo; + } + if (value.runes.every(_isJapaneseKanaRune)) { + return _CompositionKind.japaneseKana; + } + if (value.runes.every(_isKoreanJamoRune)) { + return _CompositionKind.koreanJamo; + } + if (value.runes.every(_isKoreanHangulRune)) { + return _CompositionKind.koreanHangul; + } + return _CompositionKind.committedText; +} + +bool _isComposingTransition( + _CompositionKind previous, + _CompositionKind current, +) { + if (previous == current) return true; + if (previous == _CompositionKind.ascii && + (current == _CompositionKind.japaneseKana || + current == _CompositionKind.koreanJamo || + current == _CompositionKind.koreanHangul)) { + return true; + } + if ((previous == _CompositionKind.koreanJamo || + previous == _CompositionKind.koreanHangul) && + (current == _CompositionKind.koreanJamo || + current == _CompositionKind.koreanHangul)) { + return true; + } + return false; +} + +bool _isChineseStrokeRune(int rune) { + return rune == 0x4E00 || + rune == 0x4E28 || + rune == 0x4E3F || + rune == 0x4E36 || + rune == 0x4E59 || + (rune >= 0x31C0 && rune <= 0x31EF); +} + +bool _isBopomofoRune(int rune) { + return (rune >= 0x3100 && rune <= 0x312F) || + (rune >= 0x31A0 && rune <= 0x31BF); +} + +bool _isJapaneseKanaRune(int rune) { + return (rune >= 0x3040 && rune <= 0x309F) || + (rune >= 0x30A0 && rune <= 0x30FF) || + (rune >= 0x31F0 && rune <= 0x31FF) || + (rune >= 0xFF66 && rune <= 0xFF9F); +} + +bool _isKoreanJamoRune(int rune) { + return (rune >= 0x1100 && rune <= 0x11FF) || + (rune >= 0x3130 && rune <= 0x318F) || + (rune >= 0xA960 && rune <= 0xA97F) || + (rune >= 0xD7B0 && rune <= 0xD7FF); +} + +bool _isKoreanHangulRune(int rune) { + return rune >= 0xAC00 && rune <= 0xD7AF; +} diff --git a/flutter/test/ios_soft_keyboard_input_test.dart b/flutter/test/ios_soft_keyboard_input_test.dart new file mode 100644 index 000000000..8ed5011d9 --- /dev/null +++ b/flutter/test/ios_soft_keyboard_input_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_hbb/mobile/utils/ios_soft_keyboard_input.dart'; + +void main() { + group('diffIOSSoftKeyboardInput', () { + test('does not send composing pinyin before commit', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111ni', + composingRange: const TextRange(start: 3, end: 5), + ); + + expect(result.nextValue, '111'); + expect(result.nextComposingValue, 'ni'); + expect(result.actions, isEmpty); + }); + + test('does not send a single composing stroke before commit', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111一', + composingRange: const TextRange(start: 3, end: 4), + ); + + expect(result.nextValue, '111'); + expect(result.nextComposingValue, '一'); + expect(result.actions, isEmpty); + }); + + test('does not send composing stroke input before commit', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111一丨', + composingRange: const TextRange(start: 3, end: 5), + previousComposingValue: '一', + ); + + expect(result.nextValue, '111'); + expect(result.nextComposingValue, '一丨'); + expect(result.actions, isEmpty); + }); + + test('sends committed Chinese text even if iOS keeps composing active', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111你', + composingRange: const TextRange(start: 3, end: 4), + previousComposingValue: 'ni', + ); + + expect(result.nextValue, '111你'); + expect(result.nextComposingValue, isNull); + expect(result.actions, [ + const IOSSoftKeyboardInputAction.inputText('你'), + ]); + }); + + test('sends committed Chinese text after stroke input replacement', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111你', + composingRange: const TextRange(start: 3, end: 4), + previousComposingValue: '一丨', + ); + + expect(result.nextValue, '111你'); + expect(result.nextComposingValue, isNull); + expect(result.actions, [ + const IOSSoftKeyboardInputAction.inputText('你'), + ]); + }); + + test('sends committed Chinese text as direct text input', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111你', + composingRange: TextRange.empty, + ); + + expect(result.nextValue, '111你'); + expect(result.nextComposingValue, isNull); + expect(result.actions, [ + const IOSSoftKeyboardInputAction.inputText('你'), + ]); + }); + + test('does not send Japanese kana converted from romaji before commit', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111にほん', + composingRange: const TextRange(start: 3, end: 6), + previousComposingValue: 'nihon', + ); + + expect(result.nextValue, '111'); + expect(result.nextComposingValue, 'にほん'); + expect(result.actions, isEmpty); + }); + + test('sends committed Japanese kanji after kana composition', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111日本', + composingRange: const TextRange(start: 3, end: 5), + previousComposingValue: 'にほん', + ); + + expect(result.nextValue, '111日本'); + expect(result.nextComposingValue, isNull); + expect(result.actions, [ + const IOSSoftKeyboardInputAction.inputText('日本'), + ]); + }); + + test('sends committed Japanese kana when composing collapses', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111にほん', + composingRange: TextRange.empty, + previousComposingValue: 'にほん', + ); + + expect(result.nextValue, '111にほん'); + expect(result.nextComposingValue, isNull); + expect(result.actions, [ + const IOSSoftKeyboardInputAction.inputText('にほん'), + ]); + }); + + test('does not send Korean jamo composition before commit', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111ㅎㅏ', + composingRange: const TextRange(start: 3, end: 5), + ); + + expect(result.nextValue, '111'); + expect(result.nextComposingValue, 'ㅎㅏ'); + expect(result.actions, isEmpty); + }); + + test('does not send composing Korean hangul syllable before commit', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111한', + composingRange: const TextRange(start: 3, end: 4), + previousComposingValue: 'ㅎㅏ', + ); + + expect(result.nextValue, '111'); + expect(result.nextComposingValue, '한'); + expect(result.actions, isEmpty); + }); + + test('sends committed Korean hangul when composing collapses', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111한', + composingRange: TextRange.empty, + previousComposingValue: '한', + ); + + expect(result.nextValue, '111한'); + expect(result.nextComposingValue, isNull); + expect(result.actions, [ + const IOSSoftKeyboardInputAction.inputText('한'), + ]); + }); + + test('keeps ascii single character input as a key stroke', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111', + currentValue: '111a', + composingRange: TextRange.empty, + ); + + expect(result.nextValue, '111a'); + expect(result.nextComposingValue, isNull); + expect(result.actions, [ + const IOSSoftKeyboardInputAction.inputKey('a'), + ]); + }); + + test('sends backspace when committed text is deleted', () { + final result = diffIOSSoftKeyboardInput( + previousValue: '111你', + currentValue: '111', + composingRange: TextRange.empty, + ); + + expect(result.nextValue, '111'); + expect(result.nextComposingValue, isNull); + expect(result.actions, [ + const IOSSoftKeyboardInputAction.backspace(), + ]); + }); + }); +}