Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions lib/src/code/code.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
);
}
}
57 changes: 53 additions & 4 deletions lib/src/code_field/code_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class CodeController extends TextEditingController {
}

AnalysisResult analysisResult;
String _lastAnalyzedText = '';
var _lastAnalyzedText = '';
Timer? _debounce;

final AbstractNamedSectionParser? namedSectionParser;
Expand All @@ -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.
Expand Down Expand Up @@ -125,7 +125,7 @@ class CodeController extends TextEditingController {
@visibleForTesting
TextSpan? lastTextSpan;

bool _disposed = false;
var _disposed = false;

late final actions = <Type, Action<Intent>>{
CommentUncommentIntent: CommentUncommentAction(controller: this),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -317,13 +319,22 @@ class CodeController extends TextEditingController {
}

KeyEventResult onKey(KeyEvent event) {
if (hasActiveComposition) {
return KeyEventResult.ignored;
}

if (event is KeyDownEvent || event is KeyRepeatEvent) {
return _onKeyDownRepeat(event);
}

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();
Expand All @@ -345,6 +356,10 @@ class CodeController extends TextEditingController {
}

void onEnterKeyAction() {
if (hasActiveComposition) {
return;
}

if (popupController.shouldShow) {
insertSelectedWord();
return;
Expand All @@ -368,6 +383,10 @@ class CodeController extends TextEditingController {
}

void onTabKeyAction() {
if (hasActiveComposition) {
return;
}

if (popupController.shouldShow) {
insertSelectedWord();
return;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down
32 changes: 17 additions & 15 deletions lib/src/code_field/code_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,6 @@ final _shortcuts = <ShortcutActivator, Intent>{
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 {
Expand Down Expand Up @@ -422,6 +408,22 @@ class _CodeFieldState extends State<CodeField> {

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,
Expand Down Expand Up @@ -463,7 +465,7 @@ class _CodeFieldState extends State<CodeField> {

return FocusableActionDetector(
actions: widget.controller.actions,
shortcuts: _shortcuts,
shortcuts: shortcuts,
child: Container(
decoration: widget.decoration,
color: _backgroundCol,
Expand Down
39 changes: 39 additions & 0 deletions test/src/code_field/code_controller_set_value_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions test/src/code_field/code_controller_shortcut_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});
}

Expand Down