diff --git a/pkg/web_app/lib/src/widget/completion/suggest.dart b/pkg/web_app/lib/src/widget/completion/suggest.dart index 7aba40b1c4..46e8a6c81b 100644 --- a/pkg/web_app/lib/src/widget/completion/suggest.dart +++ b/pkg/web_app/lib/src/widget/completion/suggest.dart @@ -9,7 +9,7 @@ import 'package:collection/collection.dart'; typedef Suggestions = List; -class Suggestion { +class Suggestion implements Comparable { final int start; final int end; final String value; @@ -32,15 +32,25 @@ class Suggestion { 'html': html, 'score': score, }; + + @override + int compareTo(Suggestion other) { + final sc = -score.compareTo(other.score); + if (sc != 0) return sc; + final lc = value.length.compareTo(other.value.length); + if (lc != 0) return lc; + return value.compareTo(other.value); + } } /// Given [data] and [caret] position inside [text] what suggestions do we /// want to offer and should completion be automatically triggered? -({bool trigger, Suggestions suggestions}) suggest( +({bool trigger, Suggestions suggestions, bool isTrimmed}) suggest( CompletionData data, String text, - int caret, -) { + int caret, { + int maxOptionCount = 50, +}) { // Get position before caret final beforeCaret = caret > 0 ? caret - 1 : 0; // Get position of space after the caret @@ -81,6 +91,7 @@ class Suggestion { return ( trigger: false, suggestions: [], + isTrimmed: false, ); } @@ -94,6 +105,7 @@ class Suggestion { return ( trigger: false, suggestions: [], + isTrimmed: false, ); } // We don't to auto trigger completion unless there is an option that is @@ -106,7 +118,7 @@ class Suggestion { // Terminate suggestion with a ' ' suffix, if this is a terminal completion final suffix = completion.terminal ? ' ' : ''; - final suggestions = completion.options.map((option) { + var suggestions = completion.options.map((option) { final overlap = _lcs(prefix, option); var html = option; // highlight the overlapping part of the text @@ -129,15 +141,32 @@ class Suggestion { html: html, score: score, ); - }).sorted((a, b) { - final x = -a.score.compareTo(b.score); - if (x != 0) return x; - return a.value.compareTo(b.value); - }); + }).toList(); + final isTrimmed = suggestions.length > maxOptionCount; + if (!isTrimmed) { + suggestions.sort(); + } else { + // List of score bucket entries ordered by decreasing score. + final buckets = suggestions + .groupListsBy((s) => s.score.floor()) + .entries + .toList() + ..sort((a, b) => -a.key.compareTo(b.key)); + suggestions = []; + for (final bucket in buckets) { + bucket.value.sort(); + suggestions + .addAll(bucket.value.take(maxOptionCount - suggestions.length)); + if (suggestions.length >= maxOptionCount) { + break; + } + } + } return ( trigger: trigger, suggestions: suggestions, + isTrimmed: isTrimmed, ); } diff --git a/pkg/web_app/lib/src/widget/completion/widget.dart b/pkg/web_app/lib/src/widget/completion/widget.dart index 8c6090c002..1bccc6d54e 100644 --- a/pkg/web_app/lib/src/widget/completion/widget.dart +++ b/pkg/web_app/lib/src/widget/completion/widget.dart @@ -118,6 +118,9 @@ final class _State { /// Selected suggestion final int selectedIndex; + /// Whether the suggestion list is trimmed for space consideratoins. + final bool isTrimmed; + _State({ this.inactive = false, this.closed = false, @@ -127,6 +130,7 @@ final class _State { this.caret = 0, this.suggestions = const [], this.selectedIndex = 0, + this.isTrimmed = false, }); _State update({ @@ -138,6 +142,7 @@ final class _State { int? caret, Suggestions? suggestions, int? selectedIndex, + bool? isTrimmed, }) => _State( inactive: inactive ?? this.inactive, @@ -148,11 +153,12 @@ final class _State { caret: caret ?? this.caret, suggestions: suggestions ?? this.suggestions, selectedIndex: selectedIndex ?? this.selectedIndex, + isTrimmed: isTrimmed ?? this.isTrimmed, ); @override String toString() => - '_State(forced: $forced, triggered: $triggered, caret: $caret, text: $text, selected: $selectedIndex)'; + '_State(forced: $forced, triggered: $triggered, caret: $caret, text: $text, selected: $selectedIndex, isTrimmed: $isTrimmed)'; } final class _CompletionWidget { @@ -212,7 +218,7 @@ final class _CompletionWidget { delta = state.text.substring(caret, state.caret); } final crossedWordBoundary = delta.contains(_whitespace); - final (:trigger, :suggestions) = suggest( + final (:trigger, :suggestions, :isTrimmed) = suggest( data, text, caret, @@ -223,6 +229,7 @@ final class _CompletionWidget { suggestions: suggestions, text: text, caret: caret, + isTrimmed: isTrimmed, ); update(); } @@ -262,6 +269,9 @@ final class _CompletionWidget { ..setAttribute('data-completion-option-index', i.toString()) ..classList.add(optionClass)); } + if (state.isTrimmed) { + dropdown.appendChild(HTMLDivElement()..textContent = '[...]'); + } } _renderedSuggestions = state.suggestions; diff --git a/pkg/web_app/test/widget/completion/suggest_test.dart b/pkg/web_app/test/widget/completion/suggest_test.dart index c4b61c9902..6078ece3b5 100644 --- a/pkg/web_app/test/widget/completion/suggest_test.dart +++ b/pkg/web_app/test/widget/completion/suggest_test.dart @@ -46,15 +46,15 @@ void main() { { 'start': 0, 'end': 6, - 'value': 'has:', - 'html': 'has:', + 'value': 'is:', + 'html': 'is:', 'score': 0.0, }, { 'start': 0, 'end': 6, - 'value': 'is:', - 'html': 'is:', + 'value': 'has:', + 'html': 'has:', 'score': 0.0, }, ]); @@ -98,16 +98,16 @@ void main() { { 'start': 0, 'end': 5, - 'value': 'is:null-safe ', - 'html': 'is:null-safe', - 'score': 0.0, + 'value': 'is:plugin ', + 'html': 'is:plugin', + 'score': 0.0 }, { 'start': 0, 'end': 5, - 'value': 'is:plugin ', - 'html': 'is:plugin', - 'score': 0.0 + 'value': 'is:null-safe ', + 'html': 'is:null-safe', + 'score': 0.0, }, { 'start': 0,