diff --git a/lib/flutter_code_editor.dart b/lib/flutter_code_editor.dart index cc07e5d6..a9f777ae 100644 --- a/lib/flutter_code_editor.dart +++ b/lib/flutter_code_editor.dart @@ -4,6 +4,7 @@ export 'src/analyzer/default_analyzer.dart'; export 'src/analyzer/models/analysis_result.dart'; export 'src/analyzer/models/issue.dart'; export 'src/analyzer/models/issue_type.dart'; +export 'src/autocomplete/autocompleter.dart'; export 'src/code/code.dart'; export 'src/code/code_line.dart'; diff --git a/lib/src/autocomplete/autocompleter.dart b/lib/src/autocomplete/autocompleter.dart index 236910f0..2ddff884 100644 --- a/lib/src/autocomplete/autocompleter.dart +++ b/lib/src/autocomplete/autocompleter.dart @@ -1,133 +1,29 @@ -import 'package:autotrie/autotrie.dart'; -import 'package:highlight/highlight_core.dart'; +import 'package:flutter/material.dart'; +import 'package:highlight/highlight.dart'; -import '../code/reg_exp.dart'; +abstract class Autocompleter { + Mode? mode; + List blacklist = []; -/// Accumulates textual data and suggests autocompletion based on it. -class Autocompleter { - Mode? _mode; - final _customAutocomplete = AutoComplete(engine: SortEngine.entriesOnly()); - final _keywordsAutocomplete = AutoComplete(engine: SortEngine.entriesOnly()); - final _textAutocompletes = {}; - final _lastTexts = {}; - Set _blacklistSet = const {}; + Autocompleter(); - static final _whitespacesRe = RegExp(r'\s+'); + void setText(Object key, String? text); - /// The language to automatically extract keywords from. - Mode? get mode => _mode; + Future> getSuggestionItems(TextEditingValue value); - set mode(Mode? value) { - _mode = value; - _parseKeywords(); - } - - void _parseKeywords() { - _keywordsAutocomplete.clearEntries(); - - final keywords = mode?.keywords; - if (keywords == null) { - return; - } - - if (keywords is String) { - _parseStringKeywords(keywords); - } else if (keywords is Map) { - _parseStringStringKeywords(keywords); - } else if (keywords is Map) { - _parseStringDynamicKeywords(keywords); - } else { - throw Exception( - 'Unknown keywords type: ${keywords.runtimeType}, $keywords', - ); - } - } - - void _parseStringKeywords(String keywords) { - _keywordsAutocomplete.enterList( - [...keywords.split(_whitespacesRe).where((k) => k.isNotEmpty)], - ); - } - - void _addKeywords(Iterable keywords) { - _keywordsAutocomplete.enterList( - keywords.where((k) => k.isNotEmpty).toList(growable: false), - ); - } - - void _parseStringStringKeywords(Map map) { - map.values.forEach(_parseStringKeywords); - } - - void _parseStringDynamicKeywords(Map map) { - _addKeywords(map.keys); - } - - /// The words to exclude from suggestions if they are otherwise present. - List get blacklist => _blacklistSet.toList(growable: false); - - set blacklist(List value) { - _blacklistSet = {...value}; - } - - /// Sets the [text] to parse all words from. - /// Multiple texts are supported, each with its own [key]. - /// Use this to set current texts from multiple controllers. - void setText(Object key, String? text) { - if (text == null) { - _textAutocompletes.remove(key); - _lastTexts.remove(key); - return; - } - - if (text == _lastTexts[key]) { - return; - } - - final ac = _getOrCreateTextAutoComplete(key); - _updateText(ac, text); - _lastTexts[key] = text; - } - - AutoComplete _getOrCreateTextAutoComplete(Object key) { - return _textAutocompletes[key] ?? _createTextAutoComplete(key); - } - - AutoComplete _createTextAutoComplete(Object key) { - final result = AutoComplete(engine: SortEngine.entriesOnly()); - _textAutocompletes[key] = result; - return result; - } - - void _updateText(AutoComplete ac, String text) { - ac.clearEntries(); - ac.enterList( - text - // https://github.com/akvelon/flutter-code-editor/issues/61 - //.split(RegExps.wordSplit) - .split(RegExp(RegExps.wordSplit.pattern)) - .where((t) => t.isNotEmpty) - .toList(growable: false), - ); - } - - /// Sets additional words to suggest. - /// Fill this with your library's symbols. - void setCustomWords(List words) { - _customAutocomplete.clearEntries(); - _customAutocomplete.enterList(words); - } + TextEditingValue? replaceText( + TextSelection selection, TextEditingValue value, SuggestionItem item, + ); +} - Future> getSuggestions(String prefix) async { - final result = { - ..._customAutocomplete.suggest(prefix), - ..._keywordsAutocomplete.suggest(prefix), - ..._textAutocompletes.values - .map((ac) => ac.suggest(prefix)) - .expand((e) => e), - }.where((e) => !_blacklistSet.contains(e)).toList(growable: false); +class SuggestionItem { + final String text; + final String displayText; + final dynamic data; - result.sort(); - return result; - } + SuggestionItem({ + required this.text, + required this.displayText, + this.data, + }); } diff --git a/lib/src/autocomplete/default_autocompleter.dart b/lib/src/autocomplete/default_autocompleter.dart new file mode 100644 index 00000000..08e71d1a --- /dev/null +++ b/lib/src/autocomplete/default_autocompleter.dart @@ -0,0 +1,189 @@ +import 'package:autotrie/autotrie.dart'; +import 'package:flutter/material.dart'; +import 'package:highlight/highlight_core.dart'; + +import '../code/reg_exp.dart'; +import '../code_field/text_editing_value.dart'; +import 'autocompleter.dart'; + +/// Accumulates textual data and suggests autocompletion based on it. +class DefaultAutocompleter extends Autocompleter { + Mode? _mode; + final _customAutocomplete = AutoComplete(engine: SortEngine.entriesOnly()); + final _keywordsAutocomplete = AutoComplete(engine: SortEngine.entriesOnly()); + final _textAutocompletes = {}; + final _lastTexts = {}; + Set _blacklistSet = const {}; + String text = ''; + + static final _whitespacesRe = RegExp(r'\s+'); + + DefaultAutocompleter(); + + /// The language to automatically extract keywords from. + @override + Mode? get mode => _mode; + @override + set mode(Mode? value) { + _mode = value; + _parseKeywords(); + } + + void _parseKeywords() { + _keywordsAutocomplete.clearEntries(); + + final keywords = mode?.keywords; + if (keywords == null) { + return; + } + + if (keywords is String) { + _parseStringKeywords(keywords); + } else if (keywords is Map) { + _parseStringStringKeywords(keywords); + } else if (keywords is Map) { + _parseStringDynamicKeywords(keywords); + } else { + throw Exception( + 'Unknown keywords type: ${keywords.runtimeType}, $keywords', + ); + } + } + + void _parseStringKeywords(String keywords) { + _keywordsAutocomplete.enterList( + [...keywords.split(_whitespacesRe).where((k) => k.isNotEmpty)], + ); + } + + void _addKeywords(Iterable keywords) { + _keywordsAutocomplete.enterList( + keywords.where((k) => k.isNotEmpty).toList(growable: false), + ); + } + + void _parseStringStringKeywords(Map map) { + map.values.forEach(_parseStringKeywords); + } + + void _parseStringDynamicKeywords(Map map) { + _addKeywords(map.keys); + } + + /// The words to exclude from suggestions if they are otherwise present. + @override + List get blacklist => _blacklistSet.toList(growable: false); + + @override + set blacklist(List value) { + _blacklistSet = {...value}; + } + + /// Sets the [text] to parse all words from. + /// Multiple texts are supported, each with its own [key]. + /// Use this to set current texts from multiple controllers. + @override + void setText(Object key, String? text) { + this.text = text ?? ''; + if (text == null) { + _textAutocompletes.remove(key); + _lastTexts.remove(key); + return; + } + + if (text == _lastTexts[key]) { + return; + } + + final ac = _getOrCreateTextAutoComplete(key); + _updateText(ac, text); + _lastTexts[key] = text; + } + + AutoComplete _getOrCreateTextAutoComplete(Object key) { + return _textAutocompletes[key] ?? _createTextAutoComplete(key); + } + + AutoComplete _createTextAutoComplete(Object key) { + final result = AutoComplete(engine: SortEngine.entriesOnly()); + _textAutocompletes[key] = result; + return result; + } + + void _updateText(AutoComplete ac, String text) { + ac.clearEntries(); + ac.enterList( + text + // https://github.com/akvelon/flutter-code-editor/issues/61 + //.split(RegExps.wordSplit) + .split(RegExp(RegExps.wordSplit.pattern)) + .where((t) => t.isNotEmpty) + .toList(growable: false), + ); + } + + /// Sets additional words to suggest. + /// Fill this with your library's symbols. + void setCustomWords(List words) { + _customAutocomplete.clearEntries(); + _customAutocomplete.enterList(words); + } + + @override + Future> getSuggestionItems(TextEditingValue value) async { + final prefix = value.wordToCursor; + if (prefix == null) { + return []; + } + + final result = await getSuggestions(prefix); + + return result + .map((e) => SuggestionItem(text: e, displayText: e)) + .toList(); + } + + Future> getSuggestions(String prefix) async { + final result = { + ..._customAutocomplete.suggest(prefix), + ..._keywordsAutocomplete.suggest(prefix), + ..._textAutocompletes.values + .map((ac) => ac.suggest(prefix)) + .expand((e) => e), + }.where((e) => !_blacklistSet.contains(e)).toList(growable: false); + + result.sort(); + + return result; + } + + + @override + TextEditingValue? replaceText( + TextSelection selection, + TextEditingValue value, + SuggestionItem item, + ) { + final previousSelection = selection; + final startPosition = value.wordAtCursorStart; + + if (startPosition != null) { + final replacedText = text.replaceRange( + startPosition, + selection.baseOffset, + item.text, + ); + + final adjustedSelection = previousSelection.copyWith( + baseOffset: startPosition + item.text.length, + extentOffset: startPosition + item.text.length, + ); + + return TextEditingValue( + text: replacedText, + selection: adjustedSelection, + ); + } + return null; + } +} diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart index 5a4a5d92..44a4fa11 100644 --- a/lib/src/code_field/code_controller.dart +++ b/lib/src/code_field/code_controller.dart @@ -9,7 +9,7 @@ import 'package:highlight/highlight_core.dart'; import 'package:meta/meta.dart'; import '../../flutter_code_editor.dart'; -import '../autocomplete/autocompleter.dart'; +import '../autocomplete/default_autocompleter.dart'; import '../code/code_edit_result.dart'; import '../code/key_event.dart'; import '../history/code_history_controller.dart'; @@ -99,7 +99,7 @@ class CodeController extends TextEditingController { final _styleList = []; final _modifierMap = {}; late PopupController popupController; - final autocompleter = Autocompleter(); + late final Autocompleter autocompleter; late final historyController = CodeHistoryController(codeController: this); @internal @@ -148,10 +148,13 @@ class CodeController extends TextEditingController { CloseBlockModifier(), TabModifier(), ], + Autocompleter? autocompleter, }) : _analyzer = analyzer, _readOnlySectionNames = readOnlySectionNames, _code = Code.empty, _isTabReplacementEnabled = modifiers.any((e) => e is TabModifier) { + this.autocompleter = autocompleter ?? DefaultAutocompleter(); + setLanguage(language, analyzer: analyzer); this.visibleSectionNames = visibleSectionNames; _code = _createCode(text ?? ''); @@ -349,28 +352,11 @@ class CodeController extends TextEditingController { /// Inserts the word selected from the list of completions void insertSelectedWord() { - final previousSelection = selection; - final selectedWord = popupController.getSelectedWord(); - final startPosition = value.wordAtCursorStart; - - if (startPosition != null) { - final replacedText = text.replaceRange( - startPosition, - selection.baseOffset, - selectedWord, - ); - - final adjustedSelection = previousSelection.copyWith( - baseOffset: startPosition + selectedWord.length, - extentOffset: startPosition + selectedWord.length, - ); - - value = TextEditingValue( - text: replacedText, - selection: adjustedSelection, - ); + final suggestionItem = popupController.getSelectedItem(); + final result = autocompleter.replaceText(selection, value, suggestionItem); + if (result != null) { + value = result; } - popupController.hide(); } @@ -763,14 +749,8 @@ class CodeController extends TextEditingController { } Future generateSuggestions() async { - final prefix = value.wordToCursor; - if (prefix == null) { - popupController.hide(); - return; - } - final suggestions = - (await autocompleter.getSuggestions(prefix)).toList(growable: false); + (await autocompleter.getSuggestionItems(value)).toList(growable: false); if (suggestions.isNotEmpty) { popupController.show(suggestions); diff --git a/lib/src/code_field/code_field.dart b/lib/src/code_field/code_field.dart index 5ea622d9..e52013c0 100644 --- a/lib/src/code_field/code_field.dart +++ b/lib/src/code_field/code_field.dart @@ -165,6 +165,11 @@ class CodeField extends StatefulWidget { final GutterStyle gutterStyle; + final int caretPadding; + final double autocompletePopupMaxHeight; + final double autocompletePopupMaxWidth; + final Widget Function(BuildContext context)? autocompleteListBuilder; + const CodeField({ super.key, required this.controller, @@ -187,6 +192,10 @@ class CodeField extends StatefulWidget { @Deprecated('Use gutterStyle instead') this.lineNumbers, @Deprecated('Use gutterStyle instead') this.lineNumberStyle = const GutterStyle(), + this.caretPadding = Sizes.caretPadding, + this.autocompletePopupMaxHeight = Sizes.autocompletePopupMaxHeight, + this.autocompletePopupMaxWidth = Sizes.autocompletePopupMaxWidth, + this.autocompleteListBuilder, }) : assert( gutterStyle == null || lineNumbers == null, 'Can not provide gutterStyle and lineNumbers at the same time. ' @@ -241,6 +250,7 @@ class _CodeFieldState extends State { _focusNode!.attach(context, onKeyEvent: _onKeyEvent); widget.controller.searchController.codeFieldFocusNode = _focusNode; + widget.controller.popupController.codeFieldFocusNode = _focusNode; // Workaround for disabling spellchecks in FireFox // https://github.com/akvelon/flutter-code-editor/issues/197 @@ -261,6 +271,7 @@ class _CodeFieldState extends State { @override void dispose() { widget.controller.searchController.codeFieldFocusNode = null; + widget.controller.popupController.codeFieldFocusNode = null; widget.controller.removeListener(_onTextChanged); widget.controller.removeListener(_updatePopupOffset); widget.controller.popupController.removeListener(_onPopupStateChanged); @@ -287,6 +298,7 @@ class _CodeFieldState extends State { ); widget.controller.searchController.codeFieldFocusNode = _focusNode; + widget.controller.popupController.codeFieldFocusNode = _focusNode; widget.controller.addListener(_onTextChanged); widget.controller.addListener(_updatePopupOffset); widget.controller.popupController.addListener(_onPopupStateChanged); @@ -487,7 +499,7 @@ class _CodeFieldState extends State { final leftOffset = _getPopupLeftOffset(textPainter); final normalTopOffset = _getPopupTopOffset(textPainter, caretHeight); final flippedTopOffset = normalTopOffset - - (Sizes.autocompletePopupMaxHeight + caretHeight + Sizes.caretPadding); + (widget.autocompletePopupMaxHeight + caretHeight + widget.caretPadding); setState(() { _normalPopupOffset = Offset(leftOffset, normalTopOffset); @@ -632,6 +644,9 @@ class _CodeFieldState extends State { backgroundColor: _backgroundCol, parentFocusNode: _focusNode!, editorOffset: _editorOffset, + listBuilder: widget.autocompleteListBuilder, + maxHeight: widget.autocompletePopupMaxHeight, + maxWidth: widget.autocompletePopupMaxWidth, ); }, ); diff --git a/lib/src/wip/autocomplete/popup.dart b/lib/src/wip/autocomplete/popup.dart index 90e28945..e6210bfb 100644 --- a/lib/src/wip/autocomplete/popup.dart +++ b/lib/src/wip/autocomplete/popup.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; -import '../../sizes.dart'; import 'popup_controller.dart'; /// Popup window displaying the list of possible completions @@ -27,6 +26,10 @@ class Popup extends StatefulWidget { final TextStyle style; final Color? backgroundColor; + final double maxHeight; + final double maxWidth; + final Widget Function(BuildContext context)? listBuilder; + const Popup({ super.key, required this.controller, @@ -37,6 +40,9 @@ class Popup extends StatefulWidget { required this.parentFocusNode, required this.style, this.backgroundColor, + required this.maxHeight, + required this.maxWidth, + this.listBuilder, }); @override @@ -63,8 +69,7 @@ class PopupState extends State { final bool isHorizontalOverflowed = _isHorizontallyOverflowed(); final double leftOffsetLimit = // TODO(nausharipov): find where 100 comes from - widget.editingWindowSize.width - - Sizes.autocompletePopupMaxWidth + + widget.editingWindowSize.width - widget.maxWidth + (widget.editorOffset?.dx ?? 0) - 100; @@ -79,14 +84,14 @@ class PopupState extends State { alignment: verticalFlipRequired ? Alignment.bottomCenter : Alignment.topCenter, - constraints: const BoxConstraints( - maxHeight: Sizes.autocompletePopupMaxHeight, - maxWidth: Sizes.autocompletePopupMaxWidth, + constraints: BoxConstraints( + maxHeight: widget.maxHeight, + maxWidth: widget.maxWidth, ), // Container is used because the vertical borders // in DecoratedBox are hidden under scroll. // ignore: use_decorated_box - child: Container( + child: widget.listBuilder?.call(context) ?? Container( decoration: BoxDecoration( color: widget.backgroundColor, border: Border.all( @@ -112,9 +117,9 @@ class PopupState extends State { bool _isVerticalFlipRequired() { final isPopupShorterThanWindow = - Sizes.autocompletePopupMaxHeight < widget.editingWindowSize.height; + widget.maxHeight < widget.editingWindowSize.height; final isPopupOverflowingHeight = widget.normalOffset.dy + - Sizes.autocompletePopupMaxHeight - + widget.maxHeight - (widget.editorOffset?.dy ?? 0) > widget.editingWindowSize.height; @@ -124,7 +129,7 @@ class PopupState extends State { bool _isHorizontallyOverflowed() { return widget.normalOffset.dx - (widget.editorOffset?.dx ?? 0) + - Sizes.autocompletePopupMaxWidth > + widget.maxWidth > widget.editingWindowSize.width; } @@ -151,7 +156,7 @@ class PopupState extends State { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( - widget.controller.suggestions[index], + widget.controller.suggestions[index].displayText, overflow: TextOverflow.ellipsis, style: widget.style, ), diff --git a/lib/src/wip/autocomplete/popup_controller.dart b/lib/src/wip/autocomplete/popup_controller.dart index bbbc3886..ac9dcf63 100644 --- a/lib/src/wip/autocomplete/popup_controller.dart +++ b/lib/src/wip/autocomplete/popup_controller.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../autocomplete/autocompleter.dart'; + class PopupController extends ChangeNotifier { - late List suggestions; + late List suggestions; int _selectedIndex = 0; bool shouldShow = false; bool enabled = true; @@ -13,6 +15,7 @@ class PopupController extends ChangeNotifier { /// Should be called when an active list item is selected to be inserted into the text late final void Function() onCompletionSelected; + FocusNode? codeFieldFocusNode; PopupController({required this.onCompletionSelected}) : super(); @@ -23,8 +26,8 @@ class PopupController extends ChangeNotifier { int get selectedIndex => _selectedIndex; - void show(List suggestions) { - if (enabled == false) { + void show(List suggestions) { + if (!enabled) { return; } @@ -76,7 +79,7 @@ class PopupController extends ChangeNotifier { notifyListeners(); } - String getSelectedWord() => suggestions[selectedIndex]; + SuggestionItem getSelectedItem() => suggestions[selectedIndex]; } /// Possible directions of completions list navigation diff --git a/test/src/autocomplete/autocompleter_test.dart b/test/src/autocomplete/autocompleter_test.dart index 447c9392..83551291 100644 --- a/test/src/autocomplete/autocompleter_test.dart +++ b/test/src/autocomplete/autocompleter_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter_code_editor/src/autocomplete/autocompleter.dart'; +import 'package:flutter_code_editor/src/autocomplete/default_autocompleter.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:highlight/languages/dart.dart'; import 'package:highlight/languages/java.dart'; @@ -30,7 +30,7 @@ $dollar1 dollar2$ $dollar3$ void main() { group('Autocompleter', () { test('Mutable mode, keywords from mode, sorted ascending', () async { - final ac = Autocompleter(); + final ac = DefaultAutocompleter(); final initialResults = await ac.getSuggestions('f'); ac.mode = java; @@ -66,7 +66,7 @@ void main() { }); test('Shows words from text', () async { - final ac = Autocompleter(); + final ac = DefaultAutocompleter(); ac.setText(Object, loremIpsum); final singleResults = await ac.getSuggestions('s'); @@ -104,7 +104,7 @@ void main() { }); test('Shows custom words', () async { - final ac = Autocompleter(); + final ac = DefaultAutocompleter(); ac.setCustomWords(['Lorem', 'ipsum', 'word3', 'word4']); final results = await ac.getSuggestions('word'); @@ -113,7 +113,7 @@ void main() { }); test('Applies the blacklist', () async { - final obj = Autocompleter(); + final obj = DefaultAutocompleter(); obj.mode = java; obj.blacklist = ['Finally'];