Skip to content
5 changes: 5 additions & 0 deletions lib/flutter_code_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
26 changes: 26 additions & 0 deletions lib/src/autocomplete/default_suggestion_provider.dart
Original file line number Diff line number Diff line change
@@ -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<List<Suggestion>> suggestionsFor(SuggestionRequest request) async {
final words = await autocompleter.getSuggestions(request.prefix);
return words
.map((w) => Suggestion(label: w))
.toList(growable: false);
}
}
88 changes: 88 additions & 0 deletions lib/src/autocomplete/suggestion.dart
Original file line number Diff line number Diff line change
@@ -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,
}
20 changes: 20 additions & 0 deletions lib/src/autocomplete/suggestion_provider.dart
Original file line number Diff line number Diff line change
@@ -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<List<Suggestion>> suggestionsFor(SuggestionRequest request);
}
48 changes: 48 additions & 0 deletions lib/src/autocomplete/suggestion_request.dart
Original file line number Diff line number Diff line change
@@ -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);
}
30 changes: 27 additions & 3 deletions lib/src/code_field/code_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ class CodeController extends TextEditingController {
final _modifierMap = <String, CodeModifier>{};
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
Expand Down Expand Up @@ -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 ?? '');
Expand Down Expand Up @@ -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();
}
Expand Down
51 changes: 43 additions & 8 deletions lib/src/wip/autocomplete/popup_controller.dart
Original file line number Diff line number Diff line change
@@ -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<String> suggestions;
List<Suggestion> _items = const [];
int _selectedIndex = 0;
bool shouldShow = false;
bool enabled = true;
Expand All @@ -16,19 +18,34 @@ class PopupController extends ChangeNotifier {

PopupController({required this.onCompletionSelected}) : super();

/// Currently shown rich suggestions.
List<Suggestion> 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<String>` contract. New code should render
/// directly from [items] to have access to detail, documentation, and
/// type metadata.
List<String> get suggestions =>
_items.map((e) => e.label).toList(growable: false);

set selectedIndex(int value) {
_selectedIndex = value;
notifyListeners();
}

int get selectedIndex => _selectedIndex;

void show(List<String> suggestions) {
/// Displays rich [Suggestion]s in the popup.
///
/// Resets the selected index and scrolls the list to the top.
void showItems(List<Suggestion> items) {
if (!enabled) {
return;
}

this.suggestions = suggestions;
_items = items;
_selectedIndex = 0;
shouldShow = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Expand All @@ -39,19 +56,31 @@ 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<String> suggestions) {
showItems(
suggestions
.map((w) => Suggestion(label: w, type: SuggestionType.text))
.toList(growable: false),
);
}

void hide() {
shouldShow = false;
notifyListeners();
}

/// 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) {
Expand All @@ -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);
Expand All @@ -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
Expand Down
Loading