Merge e45bf2a013 into 6ad56075d6
This commit is contained in:
commit
55ee36c050
3 changed files with 459 additions and 44 deletions
|
|
@ -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(),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
228
flutter/lib/mobile/utils/ios_soft_keyboard_input.dart
Normal file
228
flutter/lib/mobile/utils/ios_soft_keyboard_input.dart
Normal 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;
|
||||
}
|
||||
199
flutter/test/ios_soft_keyboard_input_test.dart
Normal file
199
flutter/test/ios_soft_keyboard_input_test.dart
Normal 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(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue