diff --git a/lib/src/code/code.dart b/lib/src/code/code.dart index 4241e707..1692327a 100644 --- a/lib/src/code/code.dart +++ b/lib/src/code/code.dart @@ -326,10 +326,21 @@ class Code { TextSelection oldSelection, TextEditingValue visibleAfter, ) { - final visibleRangeAfter = visibleAfter.getChangedRange( - TextEditingValue(text: visibleText, selection: oldSelection), + final clampedOldSelection = _clampSelection( + oldSelection, + visibleText.length, + ); + final clampedVisibleAfter = visibleAfter.copyWith( + selection: _clampSelection( + visibleAfter.selection, + visibleAfter.text.length, + ), + ); + + final visibleRangeAfter = clampedVisibleAfter.getChangedRange( + TextEditingValue(text: visibleText, selection: clampedOldSelection), ) ?? - visibleAfter.text.getChangedRange( + clampedVisibleAfter.text.getChangedRange( visibleText, attributeChangeTo: TextAffinity.upstream, ); @@ -395,7 +406,7 @@ class Code { } final fullTextAfter = rangeBefore.textBefore(text) + - visibleRangeAfter.textInside(visibleAfter.text) + + visibleRangeAfter.textInside(clampedVisibleAfter.text) + rangeBefore.textAfter(text); // The line at [start] has changed for sure. @@ -529,4 +540,21 @@ class Code { visibleSectionNames: visibleSectionNames, ); } + + static TextSelection _clampSelection(TextSelection selection, int textLength) { + int clampOffset(int offset) { + if (offset < 0) { + return 0; + } + if (offset > textLength) { + return textLength; + } + return offset; + } + + return selection.copyWith( + baseOffset: clampOffset(selection.baseOffset), + extentOffset: clampOffset(selection.extentOffset), + ); + } } diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart index adbe8b2d..e40778b7 100644 --- a/lib/src/code_field/code_controller.dart +++ b/lib/src/code_field/code_controller.dart @@ -64,7 +64,7 @@ class CodeController extends TextEditingController { } AnalysisResult analysisResult; - String _lastAnalyzedText = ''; + var _lastAnalyzedText = ''; Timer? _debounce; final AbstractNamedSectionParser? namedSectionParser; @@ -85,7 +85,7 @@ class CodeController extends TextEditingController { final bool _isTabReplacementEnabled; /* Computed members */ - String _languageId = ''; + var _languageId = ''; ///Contains names of named sections, those will be visible for user. ///If it is not empty, all another code except specified will be hidden. @@ -125,7 +125,7 @@ class CodeController extends TextEditingController { @visibleForTesting TextSpan? lastTextSpan; - bool _disposed = false; + var _disposed = false; late final actions = >{ CommentUncommentIntent: CommentUncommentAction(controller: this), @@ -173,6 +173,8 @@ class CodeController extends TextEditingController { _code = _createCode(text ?? ''); fullText = text ?? ''; + debugPrint('CodeController initialized with text:\n$text'); + addListener(_scheduleAnalysis); addListener(_updateSearchResult); _searchSettingsController.addListener(_updateSearchResult); @@ -317,6 +319,10 @@ class CodeController extends TextEditingController { } KeyEventResult onKey(KeyEvent event) { + if (hasActiveComposition) { + return KeyEventResult.ignored; + } + if (event is KeyDownEvent || event is KeyRepeatEvent) { return _onKeyDownRepeat(event); } @@ -324,6 +330,11 @@ class CodeController extends TextEditingController { return KeyEventResult.ignored; // The framework will handle. } + bool get hasActiveComposition { + final composing = value.composing; + return composing.isValid && !composing.isCollapsed; + } + KeyEventResult _onKeyDownRepeat(KeyEvent event) { if (event.isCtrlF(HardwareKeyboard.instance.logicalKeysPressed)) { showSearch(); @@ -345,6 +356,10 @@ class CodeController extends TextEditingController { } void onEnterKeyAction() { + if (hasActiveComposition) { + return; + } + if (popupController.shouldShow) { insertSelectedWord(); return; @@ -368,6 +383,10 @@ class CodeController extends TextEditingController { } void onTabKeyAction() { + if (hasActiveComposition) { + return; + } + if (popupController.shouldShow) { insertSelectedWord(); return; @@ -443,10 +462,29 @@ class CodeController extends TextEditingController { @override set value(TextEditingValue newValue) { + final hadActiveCompositionInOldValue = hasActiveComposition; final hasTextChanged = newValue.text != super.value.text; final hasSelectionChanged = newValue.selection != super.value.selection; + final hasComposingChanged = newValue.composing != super.value.composing; + final hasActiveComposingInNewValue = + newValue.composing.isValid && !newValue.composing.isCollapsed; - if (!hasTextChanged && !hasSelectionChanged) { + if (!hasTextChanged && !hasSelectionChanged && !hasComposingChanged) { + return; + } + + if (hasActiveComposingInNewValue || hadActiveCompositionInOldValue) { + if (readOnly && hasTextChanged) { + return; + } + + // During IME composition, preserve platform-provided editing state + // and avoid applying editor transforms that may break composition commit. + // Keep internal code state in sync so highlighted rendering doesn't drift. + if (hasTextChanged) { + _updateCodeIfChanged(newValue.text); + } + super.value = newValue; return; } @@ -917,6 +955,17 @@ class CodeController extends TextEditingController { TextStyle? style, bool? withComposing, }) { + // IME composition (e.g. pinyin) depends on composing-aware rendering. + // When composing is active, delegate to Flutter's default implementation + // so the composing range is preserved and rendered correctly. + if (hasActiveComposition) { + return super.buildTextSpan( + context: context, + style: style, + withComposing: withComposing ?? true, + ); + } + final spanBeforeSearch = _createTextSpan( context: context, style: style, diff --git a/lib/src/code_field/code_field.dart b/lib/src/code_field/code_field.dart index 8e40fd85..e8438822 100644 --- a/lib/src/code_field/code_field.dart +++ b/lib/src/code_field/code_field.dart @@ -102,20 +102,6 @@ final _shortcuts = { meta: true, ): const SearchIntent(), - // Dismiss - LogicalKeySet( - LogicalKeyboardKey.escape, - ): const DismissIntent(), - - // EnterKey - LogicalKeySet( - LogicalKeyboardKey.enter, - ): const EnterKeyIntent(), - - // TabKey - LogicalKeySet( - LogicalKeyboardKey.tab, - ): const TabKeyIntent(), }; class CodeField extends StatefulWidget { @@ -422,6 +408,22 @@ class _CodeFieldState extends State { textStyle = defaultTextStyle.merge(widget.textStyle); + final isComposingText = widget.controller.hasActiveComposition; + final shortcuts = { + ..._shortcuts, + if (!isComposingText) ...{ + LogicalKeySet( + LogicalKeyboardKey.escape, + ): const DismissIntent(), + LogicalKeySet( + LogicalKeyboardKey.enter, + ): const EnterKeyIntent(), + LogicalKeySet( + LogicalKeyboardKey.tab, + ): const TabKeyIntent(), + }, + }; + final codeField = TextField( focusNode: _focusNode, scrollPadding: widget.padding, @@ -463,7 +465,7 @@ class _CodeFieldState extends State { return FocusableActionDetector( actions: widget.controller.actions, - shortcuts: _shortcuts, + shortcuts: shortcuts, child: Container( decoration: widget.decoration, color: _backgroundCol, diff --git a/test/src/code_field/code_controller_set_value_test.dart b/test/src/code_field/code_controller_set_value_test.dart index 2af965ce..e4720809 100644 --- a/test/src/code_field/code_controller_set_value_test.dart +++ b/test/src/code_field/code_controller_set_value_test.dart @@ -4,6 +4,45 @@ import 'package:flutter_test/flutter_test.dart'; import '../common/create_app.dart'; void main() { + test('Composing-only updates are applied', () { + final controller = createController(''); + controller.value = const TextEditingValue( + text: 'ni', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: 0, end: 1), + ); + + controller.value = const TextEditingValue( + text: 'ni', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: 0, end: 2), + ); + + expect(controller.value.composing, const TextRange(start: 0, end: 2)); + controller.dispose(); + }); + + test('Composing text is committed correctly after IME selection', () { + final controller = createController('}'); + controller.selection = const TextSelection.collapsed(offset: 1); + + controller.value = const TextEditingValue( + text: '} ni', + selection: TextSelection.collapsed(offset: 4), + composing: TextRange(start: 2, end: 4), + ); + + controller.value = const TextEditingValue( + text: '} 你', + selection: TextSelection.collapsed(offset: 3), + composing: TextRange.empty, + ); + + expect(controller.text, '} 你'); + expect(controller.fullText, '} 你'); + controller.dispose(); + }); + testWidgets( 'Backspace or delete at a folded block collapse point ' '=> Do nothing.', (wt) async { diff --git a/test/src/code_field/code_controller_shortcut_test.dart b/test/src/code_field/code_controller_shortcut_test.dart index 01303fac..4d4d9972 100644 --- a/test/src/code_field/code_controller_shortcut_test.dart +++ b/test/src/code_field/code_controller_shortcut_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import '../common/create_app.dart'; @@ -111,6 +112,28 @@ void main() { expect(controller.text, example.visibleTextAfter, reason: example.name); } }); + + test('IME composition bypasses popup arrow key handling', () { + final controller = createController(''); + controller.popupController.show(['one', 'two']); + controller.value = const TextEditingValue( + text: 'ni', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: 0, end: 2), + ); + + final result = controller.onKey( + const KeyDownEvent( + timeStamp: Duration.zero, + physicalKey: PhysicalKeyboardKey.arrowDown, + logicalKey: LogicalKeyboardKey.arrowDown, + ), + ); + + expect(result, KeyEventResult.ignored); + expect(controller.popupController.selectedIndex, 0); + controller.dispose(); + }); }); }