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 new file mode 100644 index 00000000..be7f83a3 --- /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)) + .toList(growable: false); + } +} diff --git a/lib/src/autocomplete/suggestion.dart b/lib/src/autocomplete/suggestion.dart new file mode 100644 index 00000000..49f73d6d --- /dev/null +++ b/lib/src/autocomplete/suggestion.dart @@ -0,0 +1,88 @@ +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 +/// 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/lib/src/autocomplete/suggestion_provider.dart b/lib/src/autocomplete/suggestion_provider.dart new file mode 100644 index 00000000..fa2ae6cd --- /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/lib/src/autocomplete/suggestion_request.dart b/lib/src/autocomplete/suggestion_request.dart new file mode 100644 index 00000000..cb96acc6 --- /dev/null +++ b/lib/src/autocomplete/suggestion_request.dart @@ -0,0 +1,48 @@ +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. +/// +/// 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/lib/src/code_field/code_controller.dart b/lib/src/code_field/code_controller.dart index adbe8b2d..b472ebc2 100644 --- a/lib/src/code_field/code_controller.dart +++ b/lib/src/code_field/code_controller.dart @@ -104,6 +104,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 +180,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 +838,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/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/autocomplete/default_suggestion_provider_test.dart b/test/src/autocomplete/default_suggestion_provider_test.dart new file mode 100644 index 00000000..114a260f --- /dev/null +++ b/test/src/autocomplete/default_suggestion_provider_test.dart @@ -0,0 +1,59 @@ +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)); + }); + + 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 new file mode 100644 index 00000000..f9cd2b19 --- /dev/null +++ b/test/src/autocomplete/suggestion_provider_test.dart @@ -0,0 +1,71 @@ +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', () { + const items = [ + Suggestion(label: 'a', type: SuggestionType.field, priority: 2), + Suggestion(label: 'b', type: SuggestionType.enumValue), + ]; + final provider = _FixedProvider(items); + + final result = 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)', + () { + const unsorted = [ + Suggestion(label: 'beta'), + Suggestion(label: 'alpha'), + Suggestion(label: 'gamma'), + ]; + final provider = _FixedProvider(unsorted); + + final result = provider.suggestionsFor(request); + + expect( + result.map((e) => e.label).toList(), + ['beta', 'alpha', 'gamma'], + ); + }, + ); + }); +} 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))); + }); + }); +} 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, + ]), + ); + }); + }); +} 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..585a2fe4 --- /dev/null +++ b/test/src/code_field/code_controller_suggestions_test.dart @@ -0,0 +1,130 @@ +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_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 = const TextEditingValue( + text: 'a', + selection: 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 = const TextEditingValue( + text: 'p', + selection: 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 = const TextEditingValue( + text: 'z', + selection: 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 = const TextEditingValue( + text: 'q', + selection: TextSelection.collapsed(offset: 1), + ); + await controller.generateSuggestions(); + + 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 new file mode 100644 index 00000000..abe489f5 --- /dev/null +++ b/test/src/wip/autocomplete/popup_controller_test.dart @@ -0,0 +1,126 @@ +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); + }); + + 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', () { + 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); + }); + }); + }); +}