This commit is contained in:
CodeEagle 2026-05-24 19:09:46 +08:00 committed by GitHub
commit 55ee36c050
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 459 additions and 44 deletions

View file

@ -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<RemotePage> with WidgetsBindingObserver {
Orientation? _currentOrientation;
final _uniqueKey = UniqueKey();
Timer? _iosKeyboardWorkaroundTimer;
String? _iosComposingValue;
final _blockableOverlayState = BlockableOverlayState();
@ -108,6 +110,9 @@ class _RemotePageState extends State<RemotePage> 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<RemotePage> with WidgetsBindingObserver {
@override
Future<void> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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(),
),
];

View file

@ -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<IOSSoftKeyboardInputAction> 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 = <IOSSoftKeyboardInputAction>[];
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;
}

View file

@ -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(),
]);
});
});
}