From ec3385144f532b5da0a6c1eaf59c371cc9699267 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:58:06 +0300 Subject: [PATCH 1/7] Introduce Suggestion value type for custom completion sources --- lib/src/autocomplete/suggestion.dart | 86 ++++++++++++++++++++++ test/src/autocomplete/suggestion_test.dart | 73 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 lib/src/autocomplete/suggestion.dart create mode 100644 test/src/autocomplete/suggestion_test.dart diff --git a/lib/src/autocomplete/suggestion.dart b/lib/src/autocomplete/suggestion.dart new file mode 100644 index 00000000..c9161b4e --- /dev/null +++ b/lib/src/autocomplete/suggestion.dart @@ -0,0 +1,86 @@ +import 'package:flutter/foundation.dart'; + +/// A single completion candidate returned by a [SuggestionProvider]. +/// +/// The class decouples the user-facing label from the text that is actually +/// inserted into the editor, and carries additional metadata that UI layers +/// can use to render icons, group items, show documentation on hover, or +/// order candidates by priority. +@immutable +class Suggestion { + /// Text displayed to the user in the completion popup. + final String label; + + /// Text that will be inserted into the editor when the suggestion is + /// accepted. Defaults to [label] if not provided. + final String insertText; + + /// Short secondary text shown next to [label], typically a type or origin + /// hint (e.g. `String`, `enum`, `keyword`). + final String? detail; + + /// Extended description of the suggestion. Intended for markdown rendering + /// in a side panel or hover tooltip. + final String? documentation; + + /// High-level classification used for grouping and iconography. + final SuggestionType type; + + /// Higher values are surfaced earlier in the list. Ties are broken by the + /// order in which the provider returned the suggestions. + final int priority; + + const Suggestion({ + required this.label, + String? insertText, + this.detail, + this.documentation, + this.type = SuggestionType.text, + this.priority = 0, + }) : insertText = insertText ?? label; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Suggestion && + other.label == label && + other.insertText == insertText && + other.detail == detail && + other.documentation == documentation && + other.type == type && + other.priority == priority; + } + + @override + int get hashCode => + Object.hash(label, insertText, detail, documentation, type, priority); + + @override + String toString() => + 'Suggestion(label: $label, type: $type, priority: $priority)'; +} + +/// High-level category of a [Suggestion]. +/// +/// Implementers are free to ignore the type or assign a custom meaning to +/// [SuggestionType.custom]; UI layers use it as a hint for grouping and +/// default icons. +enum SuggestionType { + /// A plain word pulled from the buffer or an otherwise unclassified source. + text, + + /// A keyword of the current language mode. + keyword, + + /// A field of a class, an object key, or a configuration property. + field, + + /// A value of an enumeration. + enumValue, + + /// A predefined code fragment, typically with tabstops. + snippet, + + /// A client-specific category without built-in semantics. + custom, +} diff --git a/test/src/autocomplete/suggestion_test.dart b/test/src/autocomplete/suggestion_test.dart new file mode 100644 index 00000000..0d4032aa --- /dev/null +++ b/test/src/autocomplete/suggestion_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_code_editor/src/autocomplete/suggestion.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Suggestion', () { + test('defaults: insertText == label, type text, priority 0', () { + const suggestion = Suggestion(label: 'foo'); + + expect(suggestion.label, 'foo'); + expect(suggestion.insertText, 'foo'); + expect(suggestion.detail, isNull); + expect(suggestion.documentation, isNull); + expect(suggestion.type, SuggestionType.text); + expect(suggestion.priority, 0); + }); + + test('insertText overrides label for the inserted value', () { + const suggestion = Suggestion(label: '=>', insertText: ' => '); + + expect(suggestion.label, '=>'); + expect(suggestion.insertText, ' => '); + }); + + test('carries optional detail and documentation verbatim', () { + const suggestion = Suggestion( + label: 'title', + detail: 'String', + documentation: 'Headline shown to the user.', + type: SuggestionType.field, + priority: 10, + ); + + expect(suggestion.detail, 'String'); + expect(suggestion.documentation, 'Headline shown to the user.'); + expect(suggestion.type, SuggestionType.field); + expect(suggestion.priority, 10); + }); + + test('equality considers all fields', () { + const a = Suggestion(label: 'foo'); + const b = Suggestion(label: 'foo'); + const c = Suggestion(label: 'foo', detail: 'bar'); + + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + }); + + test('equality distinguishes different types and priorities', () { + const base = Suggestion(label: 'foo'); + const withType = Suggestion(label: 'foo', type: SuggestionType.keyword); + const withPriority = Suggestion(label: 'foo', priority: 1); + + expect(base, isNot(equals(withType))); + expect(base, isNot(equals(withPriority))); + expect(withType, isNot(equals(withPriority))); + }); + + test('SuggestionType exposes expected variants', () { + expect( + SuggestionType.values, + containsAll([ + SuggestionType.text, + SuggestionType.keyword, + SuggestionType.field, + SuggestionType.enumValue, + SuggestionType.snippet, + SuggestionType.custom, + ]), + ); + }); + }); +} From 0219277035cf33366e6c0d1fbc90bff2058ecb5b Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:58:48 +0300 Subject: [PATCH 2/7] Introduce SuggestionRequest to describe editor state for completion --- lib/src/autocomplete/suggestion_request.dart | 46 +++++++++++++++++++ .../autocomplete/suggestion_request_test.dart | 41 +++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 lib/src/autocomplete/suggestion_request.dart create mode 100644 test/src/autocomplete/suggestion_request_test.dart diff --git a/lib/src/autocomplete/suggestion_request.dart b/lib/src/autocomplete/suggestion_request.dart new file mode 100644 index 00000000..6ef751fb --- /dev/null +++ b/lib/src/autocomplete/suggestion_request.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; +import 'package:highlight/highlight_core.dart'; + +/// A snapshot of the editor state used to ask a [SuggestionProvider] +/// for completion candidates. +/// +/// Instances are cheap to create: they carry only references to strings +/// owned by the caller and a numeric offset. Providers should treat a +/// request as immutable. +@immutable +class SuggestionRequest { + /// The full text of the document at the moment the request was made. + final String text; + + /// Caret offset inside [text], measured in UTF-16 code units. + final int offset; + + /// The word fragment immediately before the caret. + /// + /// This is what the user has typed so far and what a provider is expected + /// to match against when filtering candidates. + final String prefix; + + /// The language [Mode] configured for the editor, if any. + final Mode? language; + + const SuggestionRequest({ + required this.text, + required this.offset, + required this.prefix, + this.language, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SuggestionRequest && + other.text == text && + other.offset == offset && + other.prefix == prefix && + other.language == language; + } + + @override + int get hashCode => Object.hash(text, offset, prefix, language); +} diff --git a/test/src/autocomplete/suggestion_request_test.dart b/test/src/autocomplete/suggestion_request_test.dart new file mode 100644 index 00000000..6487535d --- /dev/null +++ b/test/src/autocomplete/suggestion_request_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_code_editor/src/autocomplete/suggestion_request.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:highlight/languages/dart.dart'; + +void main() { + group('SuggestionRequest', () { + test('stores text, offset, prefix, language', () { + final request = SuggestionRequest( + text: 'int foo = 0;', + offset: 5, + prefix: 'f', + language: dart, + ); + + expect(request.text, 'int foo = 0;'); + expect(request.offset, 5); + expect(request.prefix, 'f'); + expect(request.language, dart); + }); + + test('language defaults to null', () { + const request = SuggestionRequest( + text: 'abc', + offset: 1, + prefix: 'a', + ); + + expect(request.language, isNull); + }); + + test('equality is by value', () { + const a = SuggestionRequest(text: 'x', offset: 1, prefix: 'x'); + const b = SuggestionRequest(text: 'x', offset: 1, prefix: 'x'); + const c = SuggestionRequest(text: 'x', offset: 2, prefix: 'x'); + + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + }); + }); +} From 238461aa56b87da87c4bc492ea788b494efb2169 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:00:23 +0300 Subject: [PATCH 3/7] Add SuggestionProvider API with default Autocompleter-backed implementation SuggestionProvider decouples the source of completion candidates from the CodeController. DefaultSuggestionProvider wraps the existing Autocompleter so the out-of-the-box behavior stays identical: language keywords, buffer words, and words from setCustomWords are exposed as text-typed Suggestion items. Callers can now inject their own implementation (e.g. schema-driven or LSP-backed) without subclassing or forking Autocompleter. --- .../default_suggestion_provider.dart | 26 +++++++ lib/src/autocomplete/suggestion_provider.dart | 20 ++++++ .../default_suggestion_provider_test.dart | 47 +++++++++++++ .../suggestion_provider_test.dart | 70 +++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 lib/src/autocomplete/default_suggestion_provider.dart create mode 100644 lib/src/autocomplete/suggestion_provider.dart create mode 100644 test/src/autocomplete/default_suggestion_provider_test.dart create mode 100644 test/src/autocomplete/suggestion_provider_test.dart diff --git a/lib/src/autocomplete/default_suggestion_provider.dart b/lib/src/autocomplete/default_suggestion_provider.dart new file mode 100644 index 00000000..12e54593 --- /dev/null +++ b/lib/src/autocomplete/default_suggestion_provider.dart @@ -0,0 +1,26 @@ +import 'autocompleter.dart'; +import 'suggestion.dart'; +import 'suggestion_provider.dart'; +import 'suggestion_request.dart'; + +/// [SuggestionProvider] backed by the existing [Autocompleter] implementation. +/// +/// Preserves the out-of-the-box behavior: keywords from the language mode, +/// words extracted from the buffer, and custom words set via +/// [Autocompleter.setCustomWords] are merged into a single flat list of +/// [SuggestionType.text] items. Callers that need richer, context-aware +/// completion should implement [SuggestionProvider] directly. +class DefaultSuggestionProvider implements SuggestionProvider { + /// The autocompleter whose output is being wrapped. + final Autocompleter autocompleter; + + DefaultSuggestionProvider(this.autocompleter); + + @override + Future> suggestionsFor(SuggestionRequest request) async { + final words = await autocompleter.getSuggestions(request.prefix); + return words + .map((w) => Suggestion(label: w, type: SuggestionType.text)) + .toList(growable: false); + } +} diff --git a/lib/src/autocomplete/suggestion_provider.dart b/lib/src/autocomplete/suggestion_provider.dart new file mode 100644 index 00000000..f68d81ca --- /dev/null +++ b/lib/src/autocomplete/suggestion_provider.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'suggestion.dart'; +import 'suggestion_request.dart'; + +/// Source of completion candidates for a [CodeController]. +/// +/// Implementations are free to combine in-buffer words, language keywords, +/// LSP responses, schema-driven field catalogs, or any other origin. The +/// controller queries the provider on every autocompletion cycle and passes +/// the resulting [Suggestion]s to the popup without re-sorting - the +/// provider is the source of truth for ordering. +abstract class SuggestionProvider { + /// Returns suggestions matching the current editor state. + /// + /// Both synchronous and asynchronous returns are supported via + /// [FutureOr]; synchronous providers should avoid allocating a [Future] + /// so that fast paths stay off the microtask queue. + FutureOr> suggestionsFor(SuggestionRequest request); +} diff --git a/test/src/autocomplete/default_suggestion_provider_test.dart b/test/src/autocomplete/default_suggestion_provider_test.dart new file mode 100644 index 00000000..cab2fb88 --- /dev/null +++ b/test/src/autocomplete/default_suggestion_provider_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_code_editor/src/autocomplete/autocompleter.dart'; +import 'package:flutter_code_editor/src/autocomplete/default_suggestion_provider.dart'; +import 'package:flutter_code_editor/src/autocomplete/suggestion.dart'; +import 'package:flutter_code_editor/src/autocomplete/suggestion_request.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DefaultSuggestionProvider', () { + test('wraps Autocompleter.getSuggestions as text-typed Suggestions', + () async { + final ac = Autocompleter()..setCustomWords(['bar', 'baz', 'foo']); + final provider = DefaultSuggestionProvider(ac); + + const request = + SuggestionRequest(text: 'b', offset: 1, prefix: 'b'); + final result = await provider.suggestionsFor(request); + + expect(result.map((e) => e.label).toList(), ['bar', 'baz']); + expect( + result.every((e) => e.type == SuggestionType.text), + isTrue, + ); + expect( + result.every((e) => e.insertText == e.label), + isTrue, + ); + }); + + test('empty results for a prefix with no matches', () async { + final ac = Autocompleter()..setCustomWords(['bar']); + final provider = DefaultSuggestionProvider(ac); + + const request = + SuggestionRequest(text: 'z', offset: 1, prefix: 'z'); + final result = await provider.suggestionsFor(request); + + expect(result, isEmpty); + }); + + test('exposes the wrapped autocompleter', () { + final ac = Autocompleter(); + final provider = DefaultSuggestionProvider(ac); + + expect(provider.autocompleter, same(ac)); + }); + }); +} diff --git a/test/src/autocomplete/suggestion_provider_test.dart b/test/src/autocomplete/suggestion_provider_test.dart new file mode 100644 index 00000000..62f37717 --- /dev/null +++ b/test/src/autocomplete/suggestion_provider_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_code_editor/src/autocomplete/suggestion.dart'; +import 'package:flutter_code_editor/src/autocomplete/suggestion_provider.dart'; +import 'package:flutter_code_editor/src/autocomplete/suggestion_request.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Minimal fixed provider used to lock down the contract. +class _FixedProvider implements SuggestionProvider { + final List _items; + _FixedProvider(this._items); + + @override + List suggestionsFor(SuggestionRequest request) => _items; +} + +class _AsyncProvider implements SuggestionProvider { + final List _items; + _AsyncProvider(this._items); + + @override + Future> suggestionsFor(SuggestionRequest request) async => + _items; +} + +void main() { + group('SuggestionProvider contract', () { + const request = SuggestionRequest(text: '', offset: 0, prefix: ''); + + test('sync provider returns the same list it was built with', () async { + const items = [ + Suggestion(label: 'a', type: SuggestionType.field, priority: 2), + Suggestion(label: 'b', type: SuggestionType.enumValue), + ]; + final provider = _FixedProvider(items); + + final result = await provider.suggestionsFor(request); + + expect(result, items); + }); + + test('async provider is awaited transparently', () async { + const items = [ + Suggestion(label: 'only'), + ]; + final provider = _AsyncProvider(items); + + final result = await provider.suggestionsFor(request); + + expect(result, items); + }); + + test( + 'provider is the source of truth for ordering ' + '(caller is not expected to re-sort)', () async { + const unsorted = [ + Suggestion(label: 'beta'), + Suggestion(label: 'alpha'), + Suggestion(label: 'gamma'), + ]; + final provider = _FixedProvider(unsorted); + + final result = await provider.suggestionsFor(request); + + expect(result.map((e) => e.label).toList(), [ + 'beta', + 'alpha', + 'gamma', + ]); + }); + }); +} From 91ea1344bc94d97abbfc253eaa7d9daca3bf5166 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:19:42 +0300 Subject: [PATCH 4/7] Extend PopupController with rich Suggestion items - `showItems(List)` is the new primary entry point; the legacy `show(List)` now wraps each string in a text-typed Suggestion and delegates to `showItems`. - `items` exposes the rich suggestions; `suggestions` becomes a view returning just the labels for backward compatibility with popup widgets that were built against the original `List` shape. - `getSelectedItem()` returns the full Suggestion for callers that need `insertText`/`detail`/`documentation`; `getSelectedWord()` still returns the label. - `scrollByArrow` guards against an empty items list. --- .../wip/autocomplete/popup_controller.dart | 51 +++++++-- .../autocomplete/popup_controller_test.dart | 108 ++++++++++++++++++ 2 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 test/src/wip/autocomplete/popup_controller_test.dart diff --git a/lib/src/wip/autocomplete/popup_controller.dart b/lib/src/wip/autocomplete/popup_controller.dart index de081b4d..ad3751d3 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/suggestion.dart'; + class PopupController extends ChangeNotifier { - late List suggestions; + List _items = const []; int _selectedIndex = 0; bool shouldShow = false; bool enabled = true; @@ -16,6 +18,18 @@ class PopupController extends ChangeNotifier { PopupController({required this.onCompletionSelected}) : super(); + /// Currently shown rich suggestions. + List get items => _items; + + /// Convenience view returning just the labels of [items]. + /// + /// Retained for backward compatibility with popup widgets that were built + /// against the original `List` contract. New code should render + /// directly from [items] to have access to detail, documentation, and + /// type metadata. + List get suggestions => + _items.map((e) => e.label).toList(growable: false); + set selectedIndex(int value) { _selectedIndex = value; notifyListeners(); @@ -23,12 +37,15 @@ class PopupController extends ChangeNotifier { int get selectedIndex => _selectedIndex; - void show(List suggestions) { + /// Displays rich [Suggestion]s in the popup. + /// + /// Resets the selected index and scrolls the list to the top. + void showItems(List items) { if (!enabled) { return; } - this.suggestions = suggestions; + _items = items; _selectedIndex = 0; shouldShow = true; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -39,6 +56,16 @@ class PopupController extends ChangeNotifier { notifyListeners(); } + /// Displays plain string suggestions. Each string becomes a text-typed + /// [Suggestion]. Preserved for backward compatibility; prefer [showItems]. + void show(List suggestions) { + showItems( + suggestions + .map((w) => Suggestion(label: w, type: SuggestionType.text)) + .toList(growable: false), + ); + } + void hide() { shouldShow = false; notifyListeners(); @@ -46,12 +73,14 @@ class PopupController extends ChangeNotifier { /// Changes the selected item and scrolls through the list of completions on keyboard arrows pressed void scrollByArrow(ScrollDirection direction) { + if (_items.isEmpty) { + return; + } final previousSelectedIndex = selectedIndex; if (direction == ScrollDirection.up) { - selectedIndex = - (selectedIndex - 1 + suggestions.length) % suggestions.length; + selectedIndex = (selectedIndex - 1 + _items.length) % _items.length; } else { - selectedIndex = (selectedIndex + 1) % suggestions.length; + selectedIndex = (selectedIndex + 1) % _items.length; } final visiblePositions = itemPositionsListener.itemPositions.value .where((item) { @@ -67,7 +96,7 @@ class PopupController extends ChangeNotifier { // If previously selected item was at the bottom of the visible part of the list, // on 'down' arrow the new one will appear at the bottom as well final isStepDown = selectedIndex - previousSelectedIndex == 1; - if (isStepDown && selectedIndex < suggestions.length - 1) { + if (isStepDown && selectedIndex < _items.length - 1) { itemScrollController.jumpTo(index: selectedIndex + 1, alignment: 1); } else { itemScrollController.jumpTo(index: selectedIndex); @@ -76,7 +105,13 @@ class PopupController extends ChangeNotifier { notifyListeners(); } - String getSelectedWord() => suggestions[selectedIndex]; + /// Label of the currently selected suggestion. Retained for callers that + /// worked with the original string-only API. + String getSelectedWord() => _items[_selectedIndex].label; + + /// The currently selected rich suggestion. Callers can use [Suggestion.insertText] + /// to honor the intended insertion payload (which may differ from the label). + Suggestion getSelectedItem() => _items[_selectedIndex]; } /// Possible directions of completions list navigation diff --git a/test/src/wip/autocomplete/popup_controller_test.dart b/test/src/wip/autocomplete/popup_controller_test.dart new file mode 100644 index 00000000..57ac2697 --- /dev/null +++ b/test/src/wip/autocomplete/popup_controller_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_code_editor/src/autocomplete/suggestion.dart'; +import 'package:flutter_code_editor/src/wip/autocomplete/popup_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('PopupController', () { + late PopupController controller; + + setUp(() { + controller = PopupController(onCompletionSelected: () {}); + }); + + group('showItems (rich API)', () { + test('populates items and turns shouldShow on', () { + const items = [ + Suggestion(label: 'apple', type: SuggestionType.field), + Suggestion(label: 'banana', type: SuggestionType.enumValue), + ]; + + controller.showItems(items); + + expect(controller.items, items); + expect(controller.shouldShow, isTrue); + expect(controller.selectedIndex, 0); + }); + + test('derives suggestions getter from items labels', () { + controller.showItems(const [ + Suggestion(label: 'apple'), + Suggestion(label: 'banana'), + ]); + + expect(controller.suggestions, ['apple', 'banana']); + }); + + test('getSelectedItem returns the Suggestion at selectedIndex', () { + const items = [ + Suggestion(label: 'a'), + Suggestion(label: 'b', detail: 'extra'), + ]; + controller.showItems(items); + controller.selectedIndex = 1; + + expect(controller.getSelectedItem(), items[1]); + expect(controller.getSelectedWord(), 'b'); + }); + + test('is a no-op while disabled', () { + controller.enabled = false; + + controller.showItems(const [Suggestion(label: 'a')]); + + expect(controller.shouldShow, isFalse); + }); + }); + + group('show(List) backward compatibility', () { + test( + 'wraps plain strings into text-typed Suggestion items', + () { + controller.show(['foo', 'bar']); + + expect(controller.items.length, 2); + expect(controller.items.first, const Suggestion(label: 'foo')); + expect( + controller.items.every((e) => e.type == SuggestionType.text), + isTrue, + ); + expect(controller.suggestions, ['foo', 'bar']); + expect(controller.shouldShow, isTrue); + }, + ); + + test('empty list is accepted (controller still shows)', () { + controller.show([]); + + expect(controller.items, isEmpty); + expect(controller.shouldShow, isTrue); + }); + }); + + group('hide', () { + test('turns shouldShow off', () { + controller.showItems(const [Suggestion(label: 'x')]); + controller.hide(); + + expect(controller.shouldShow, isFalse); + }); + }); + + // Note: scrollByArrow exercises ItemScrollController.jumpTo which relies + // on an attached ScrollablePositionedList. Covering it meaningfully + // requires a widget test with a real popup rendered; out of scope here. + // The guard added below (items.isEmpty early return) is still worth + // a minimal check. + group('scrollByArrow', () { + test('is a no-op on an empty popup', () { + controller.scrollByArrow(ScrollDirection.up); + controller.scrollByArrow(ScrollDirection.down); + + expect(controller.items, isEmpty); + expect(controller.selectedIndex, 0); + }); + }); + }); +} From 6607fc6438950f5a623683ca36feed6f7560b78a Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:50:44 +0300 Subject: [PATCH 5/7] Inject SuggestionProvider into CodeController Adds an optional `suggestionProvider` constructor parameter and a matching getter/setter. When no provider is supplied the controller falls back to `DefaultSuggestionProvider(autocompleter)`, preserving the pre-existing completion behavior. `generateSuggestions` now builds a `SuggestionRequest` from the current editor state (text, caret offset, prefix, language) and delegates to the provider. Results flow through `PopupController.showItems`, so rich metadata (type, priority, detail, documentation) is retained for UI layers that want to render it. No changes in behavior for existing callers. --- lib/src/code_field/code_controller.dart | 33 ++++- .../code_controller_suggestions_test.dart | 119 ++++++++++++++++++ 2 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 test/src/code_field/code_controller_suggestions_test.dart diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart index adbe8b2d..26cad484 100644 --- a/lib/src/code_field/code_controller.dart +++ b/lib/src/code_field/code_controller.dart @@ -10,6 +10,9 @@ import 'package:meta/meta.dart'; import '../../flutter_code_editor.dart'; import '../autocomplete/autocompleter.dart'; +import '../autocomplete/default_suggestion_provider.dart'; +import '../autocomplete/suggestion_provider.dart'; +import '../autocomplete/suggestion_request.dart'; import '../code/code_edit_result.dart'; import '../code/key_event.dart'; import '../code_modifiers/insertion.dart'; @@ -104,6 +107,22 @@ class CodeController extends TextEditingController { final _modifierMap = {}; late PopupController popupController; final autocompleter = Autocompleter(); + + late SuggestionProvider _suggestionProvider; + + /// Source of completion candidates passed to the popup. + /// + /// Defaults to a [DefaultSuggestionProvider] wrapping the built-in + /// [autocompleter], which preserves the out-of-the-box behavior. + /// Assign a custom [SuggestionProvider] to plug in schema-driven, + /// LSP-backed, or any other completion source. + SuggestionProvider get suggestionProvider => _suggestionProvider; + + set suggestionProvider(SuggestionProvider value) { + if (identical(_suggestionProvider, value)) return; + _suggestionProvider = value; + notifyListeners(); + } late final historyController = CodeHistoryController(codeController: this); @internal @@ -164,10 +183,13 @@ class CodeController extends TextEditingController { this.readOnly = false, this.params = const EditorParams(), this.modifiers = defaultCodeModifiers, + SuggestionProvider? suggestionProvider, }) : _analyzer = analyzer, _readOnlySectionNames = readOnlySectionNames, _code = Code.empty, _isTabReplacementEnabled = modifiers.any((e) => e is TabModifier) { + _suggestionProvider = + suggestionProvider ?? DefaultSuggestionProvider(autocompleter); setLanguage(language, analyzer: analyzer); this.visibleSectionNames = visibleSectionNames; _code = _createCode(text ?? ''); @@ -819,11 +841,16 @@ class CodeController extends TextEditingController { return; } - final suggestions = - (await autocompleter.getSuggestions(prefix)).toList(growable: false); + final request = SuggestionRequest( + text: text, + offset: selection.baseOffset, + prefix: prefix, + language: _language, + ); + final suggestions = await _suggestionProvider.suggestionsFor(request); if (suggestions.isNotEmpty) { - popupController.show(suggestions); + popupController.showItems(suggestions); } else { popupController.hide(); } diff --git a/test/src/code_field/code_controller_suggestions_test.dart b/test/src/code_field/code_controller_suggestions_test.dart new file mode 100644 index 00000000..70f24622 --- /dev/null +++ b/test/src/code_field/code_controller_suggestions_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_code_editor/src/autocomplete/suggestion.dart'; +import 'package:flutter_code_editor/src/autocomplete/suggestion_provider.dart'; +import 'package:flutter_code_editor/src/autocomplete/suggestion_request.dart'; +import 'package:flutter_code_editor/src/code_field/code_controller.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Test double that records every request it received and returns a fixed +/// list of suggestions for inspection. +class _RecordingProvider implements SuggestionProvider { + final List toReturn; + final List received = []; + + _RecordingProvider(this.toReturn); + + @override + Future> suggestionsFor(SuggestionRequest request) async { + received.add(request); + return toReturn; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('CodeController.suggestionProvider', () { + test( + 'defaults to wrapping the built-in Autocompleter - popup shows ' + 'custom words added via setCustomWords', + () async { + final controller = CodeController(text: ''); + controller.autocompleter.setCustomWords(['apple', 'apricot']); + + // Simulate user typing a prefix and invoking suggestions. + controller.value = TextEditingValue( + text: 'a', + selection: const TextSelection.collapsed(offset: 1), + ); + await controller.generateSuggestions(); + + expect(controller.popupController.suggestions, + containsAll(['apple', 'apricot'])); + }, + ); + + test('custom provider replaces the default source', () async { + const custom = [ + Suggestion( + label: 'title', + detail: 'String', + type: SuggestionType.field, + ), + Suggestion( + label: 'platform_is', + type: SuggestionType.field, + priority: 5, + ), + ]; + final provider = _RecordingProvider(custom); + final controller = CodeController( + text: '', + suggestionProvider: provider, + ); + + controller.value = TextEditingValue( + text: 'p', + selection: const TextSelection.collapsed(offset: 1), + ); + await controller.generateSuggestions(); + + expect(controller.popupController.items, custom); + expect(controller.popupController.suggestions, + ['title', 'platform_is']); + // The controller may also fire generateSuggestions via its change + // listener, so we assert the request was made at least once with the + // expected editor state rather than pinning down a specific count. + expect(provider.received, isNotEmpty); + final last = provider.received.last; + expect(last.prefix, 'p'); + expect(last.offset, 1); + expect(last.text, 'p'); + }); + + test('provider is replaceable via the public setter', () async { + final controller = CodeController(text: ''); + final replacement = _RecordingProvider(const [ + Suggestion(label: 'zzz', type: SuggestionType.custom), + ]); + + controller.suggestionProvider = replacement; + controller.value = TextEditingValue( + text: 'z', + selection: const TextSelection.collapsed(offset: 1), + ); + await controller.generateSuggestions(); + + expect(controller.popupController.items.single.label, 'zzz'); + expect(controller.popupController.items.single.type, + SuggestionType.custom); + expect(replacement.received, isNotEmpty); + }); + + test('empty suggestion list hides the popup', () async { + final provider = _RecordingProvider(const []); + final controller = CodeController( + text: '', + suggestionProvider: provider, + ); + + controller.value = TextEditingValue( + text: 'q', + selection: const TextSelection.collapsed(offset: 1), + ); + await controller.generateSuggestions(); + + expect(controller.popupController.shouldShow, isFalse); + }); + }); +} From 9c45698fb82958b5410e7aa69c3ef261528d1eb1 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:57:25 +0300 Subject: [PATCH 6/7] Export SuggestionProvider API from the public library - Re-export Suggestion, SuggestionType, SuggestionRequest, SuggestionProvider and DefaultSuggestionProvider from package:flutter_code_editor/flutter_code_editor.dart. - Small tidy-ups in the new files (import pruning, redundant default argument removal, dartdoc link imports). --- lib/flutter_code_editor.dart | 5 ++++ .../default_suggestion_provider.dart | 2 +- lib/src/autocomplete/suggestion.dart | 2 ++ lib/src/autocomplete/suggestion_request.dart | 2 ++ lib/src/code_field/code_controller.dart | 3 --- .../suggestion_provider_test.dart | 4 ++-- .../code_controller_suggestions_test.dart | 24 +++++++++---------- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/flutter_code_editor.dart b/lib/flutter_code_editor.dart index cc07e5d6..294a55d6 100644 --- a/lib/flutter_code_editor.dart +++ b/lib/flutter_code_editor.dart @@ -5,6 +5,11 @@ export 'src/analyzer/models/analysis_result.dart'; export 'src/analyzer/models/issue.dart'; export 'src/analyzer/models/issue_type.dart'; +export 'src/autocomplete/default_suggestion_provider.dart'; +export 'src/autocomplete/suggestion.dart'; +export 'src/autocomplete/suggestion_provider.dart'; +export 'src/autocomplete/suggestion_request.dart'; + export 'src/code/code.dart'; export 'src/code/code_line.dart'; export 'src/code/string.dart'; diff --git a/lib/src/autocomplete/default_suggestion_provider.dart b/lib/src/autocomplete/default_suggestion_provider.dart index 12e54593..be7f83a3 100644 --- a/lib/src/autocomplete/default_suggestion_provider.dart +++ b/lib/src/autocomplete/default_suggestion_provider.dart @@ -20,7 +20,7 @@ class DefaultSuggestionProvider implements SuggestionProvider { Future> suggestionsFor(SuggestionRequest request) async { final words = await autocompleter.getSuggestions(request.prefix); return words - .map((w) => Suggestion(label: w, type: SuggestionType.text)) + .map((w) => Suggestion(label: w)) .toList(growable: false); } } diff --git a/lib/src/autocomplete/suggestion.dart b/lib/src/autocomplete/suggestion.dart index c9161b4e..49f73d6d 100644 --- a/lib/src/autocomplete/suggestion.dart +++ b/lib/src/autocomplete/suggestion.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; +import 'suggestion_provider.dart'; + /// A single completion candidate returned by a [SuggestionProvider]. /// /// The class decouples the user-facing label from the text that is actually diff --git a/lib/src/autocomplete/suggestion_request.dart b/lib/src/autocomplete/suggestion_request.dart index 6ef751fb..cb96acc6 100644 --- a/lib/src/autocomplete/suggestion_request.dart +++ b/lib/src/autocomplete/suggestion_request.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:highlight/highlight_core.dart'; +import 'suggestion_provider.dart'; + /// A snapshot of the editor state used to ask a [SuggestionProvider] /// for completion candidates. /// diff --git a/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart index 26cad484..b472ebc2 100644 --- a/lib/src/code_field/code_controller.dart +++ b/lib/src/code_field/code_controller.dart @@ -10,9 +10,6 @@ import 'package:meta/meta.dart'; import '../../flutter_code_editor.dart'; import '../autocomplete/autocompleter.dart'; -import '../autocomplete/default_suggestion_provider.dart'; -import '../autocomplete/suggestion_provider.dart'; -import '../autocomplete/suggestion_request.dart'; import '../code/code_edit_result.dart'; import '../code/key_event.dart'; import '../code_modifiers/insertion.dart'; diff --git a/test/src/autocomplete/suggestion_provider_test.dart b/test/src/autocomplete/suggestion_provider_test.dart index 62f37717..3c246a57 100644 --- a/test/src/autocomplete/suggestion_provider_test.dart +++ b/test/src/autocomplete/suggestion_provider_test.dart @@ -32,7 +32,7 @@ void main() { ]; final provider = _FixedProvider(items); - final result = await provider.suggestionsFor(request); + final result = provider.suggestionsFor(request); expect(result, items); }); @@ -58,7 +58,7 @@ void main() { ]; final provider = _FixedProvider(unsorted); - final result = await provider.suggestionsFor(request); + final result = provider.suggestionsFor(request); expect(result.map((e) => e.label).toList(), [ 'beta', diff --git a/test/src/code_field/code_controller_suggestions_test.dart b/test/src/code_field/code_controller_suggestions_test.dart index 70f24622..234b5d06 100644 --- a/test/src/code_field/code_controller_suggestions_test.dart +++ b/test/src/code_field/code_controller_suggestions_test.dart @@ -1,8 +1,8 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_code_editor/src/autocomplete/suggestion.dart'; import 'package:flutter_code_editor/src/autocomplete/suggestion_provider.dart'; import 'package:flutter_code_editor/src/autocomplete/suggestion_request.dart'; import 'package:flutter_code_editor/src/code_field/code_controller.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; /// Test double that records every request it received and returns a fixed @@ -32,14 +32,14 @@ void main() { controller.autocompleter.setCustomWords(['apple', 'apricot']); // Simulate user typing a prefix and invoking suggestions. - controller.value = TextEditingValue( + controller.value = const TextEditingValue( text: 'a', - selection: const TextSelection.collapsed(offset: 1), + selection: TextSelection.collapsed(offset: 1), ); await controller.generateSuggestions(); expect(controller.popupController.suggestions, - containsAll(['apple', 'apricot'])); + containsAll(['apple', 'apricot']),); }, ); @@ -62,15 +62,15 @@ void main() { suggestionProvider: provider, ); - controller.value = TextEditingValue( + controller.value = const TextEditingValue( text: 'p', - selection: const TextSelection.collapsed(offset: 1), + selection: TextSelection.collapsed(offset: 1), ); await controller.generateSuggestions(); expect(controller.popupController.items, custom); expect(controller.popupController.suggestions, - ['title', 'platform_is']); + ['title', 'platform_is'],); // The controller may also fire generateSuggestions via its change // listener, so we assert the request was made at least once with the // expected editor state rather than pinning down a specific count. @@ -88,15 +88,15 @@ void main() { ]); controller.suggestionProvider = replacement; - controller.value = TextEditingValue( + controller.value = const TextEditingValue( text: 'z', - selection: const TextSelection.collapsed(offset: 1), + selection: TextSelection.collapsed(offset: 1), ); await controller.generateSuggestions(); expect(controller.popupController.items.single.label, 'zzz'); expect(controller.popupController.items.single.type, - SuggestionType.custom); + SuggestionType.custom,); expect(replacement.received, isNotEmpty); }); @@ -107,9 +107,9 @@ void main() { suggestionProvider: provider, ); - controller.value = TextEditingValue( + controller.value = const TextEditingValue( text: 'q', - selection: const TextSelection.collapsed(offset: 1), + selection: TextSelection.collapsed(offset: 1), ); await controller.generateSuggestions(); From d81ee8fa1c43226a1cca1b81706e6a8c818cc84b Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:11:51 +0300 Subject: [PATCH 7/7] Polish lint and cover more edge cases - Drop a dartdoc reference that required importing CodeController just for the link. - Turn sync `_FixedProvider` tests into plain sync tests (no unused async modifier). - Extra tests: - `DefaultSuggestionProvider` respects `Autocompleter.blacklist`. - `PopupController.showItems` called twice resets the selection and replaces the list. - Assigning the same `SuggestionProvider` instance is a no-op (no spurious listener notifications). --- lib/src/autocomplete/suggestion_provider.dart | 2 +- .../default_suggestion_provider_test.dart | 12 +++++++ .../suggestion_provider_test.dart | 33 ++++++++++--------- .../code_controller_suggestions_test.dart | 11 +++++++ .../autocomplete/popup_controller_test.dart | 18 ++++++++++ 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/lib/src/autocomplete/suggestion_provider.dart b/lib/src/autocomplete/suggestion_provider.dart index f68d81ca..fa2ae6cd 100644 --- a/lib/src/autocomplete/suggestion_provider.dart +++ b/lib/src/autocomplete/suggestion_provider.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'suggestion.dart'; import 'suggestion_request.dart'; -/// Source of completion candidates for a [CodeController]. +/// Source of completion candidates for a CodeController. /// /// Implementations are free to combine in-buffer words, language keywords, /// LSP responses, schema-driven field catalogs, or any other origin. The diff --git a/test/src/autocomplete/default_suggestion_provider_test.dart b/test/src/autocomplete/default_suggestion_provider_test.dart index cab2fb88..114a260f 100644 --- a/test/src/autocomplete/default_suggestion_provider_test.dart +++ b/test/src/autocomplete/default_suggestion_provider_test.dart @@ -43,5 +43,17 @@ void main() { expect(provider.autocompleter, same(ac)); }); + + test('respects Autocompleter.blacklist', () async { + final ac = Autocompleter() + ..setCustomWords(['foo', 'foobar']) + ..blacklist = ['foo']; + final provider = DefaultSuggestionProvider(ac); + + const request = SuggestionRequest(text: 'f', offset: 1, prefix: 'f'); + final result = await provider.suggestionsFor(request); + + expect(result.map((e) => e.label), ['foobar']); + }); }); } diff --git a/test/src/autocomplete/suggestion_provider_test.dart b/test/src/autocomplete/suggestion_provider_test.dart index 3c246a57..f9cd2b19 100644 --- a/test/src/autocomplete/suggestion_provider_test.dart +++ b/test/src/autocomplete/suggestion_provider_test.dart @@ -25,7 +25,7 @@ void main() { group('SuggestionProvider contract', () { const request = SuggestionRequest(text: '', offset: 0, prefix: ''); - test('sync provider returns the same list it was built with', () async { + test('sync provider returns the same list it was built with', () { const items = [ Suggestion(label: 'a', type: SuggestionType.field, priority: 2), Suggestion(label: 'b', type: SuggestionType.enumValue), @@ -49,22 +49,23 @@ void main() { }); test( - 'provider is the source of truth for ordering ' - '(caller is not expected to re-sort)', () async { - const unsorted = [ - Suggestion(label: 'beta'), - Suggestion(label: 'alpha'), - Suggestion(label: 'gamma'), - ]; - final provider = _FixedProvider(unsorted); + 'provider is the source of truth for ordering ' + '(caller is not expected to re-sort)', + () { + const unsorted = [ + Suggestion(label: 'beta'), + Suggestion(label: 'alpha'), + Suggestion(label: 'gamma'), + ]; + final provider = _FixedProvider(unsorted); - final result = provider.suggestionsFor(request); + final result = provider.suggestionsFor(request); - expect(result.map((e) => e.label).toList(), [ - 'beta', - 'alpha', - 'gamma', - ]); - }); + expect( + result.map((e) => e.label).toList(), + ['beta', 'alpha', 'gamma'], + ); + }, + ); }); } diff --git a/test/src/code_field/code_controller_suggestions_test.dart b/test/src/code_field/code_controller_suggestions_test.dart index 234b5d06..585a2fe4 100644 --- a/test/src/code_field/code_controller_suggestions_test.dart +++ b/test/src/code_field/code_controller_suggestions_test.dart @@ -115,5 +115,16 @@ void main() { expect(controller.popupController.shouldShow, isFalse); }); + + test('assigning the same provider instance is a no-op', () { + final controller = CodeController(text: ''); + final same = controller.suggestionProvider; + var notifications = 0; + controller.addListener(() => notifications++); + + controller.suggestionProvider = same; + + expect(notifications, 0); + }); }); } diff --git a/test/src/wip/autocomplete/popup_controller_test.dart b/test/src/wip/autocomplete/popup_controller_test.dart index 57ac2697..abe489f5 100644 --- a/test/src/wip/autocomplete/popup_controller_test.dart +++ b/test/src/wip/autocomplete/popup_controller_test.dart @@ -54,6 +54,24 @@ void main() { expect(controller.shouldShow, isFalse); }); + + test( + 'a second showItems resets selectedIndex and replaces the list', + () { + controller.showItems(const [ + Suggestion(label: 'a'), + Suggestion(label: 'b'), + Suggestion(label: 'c'), + ]); + controller.selectedIndex = 2; + + controller.showItems(const [Suggestion(label: 'x')]); + + expect(controller.items.single, const Suggestion(label: 'x')); + expect(controller.selectedIndex, 0); + expect(controller.shouldShow, isTrue); + }, + ); }); group('show(List) backward compatibility', () {