Skip to content

Commit 51796a7

Browse files
committed
autocomplete [nfc]: Factor out generic AutocompleteView
The mechanism of query and results is generic to the idea of autocomplete in general, it's not specific to autocomplete on @-mentions vs. topics vs. anything else.
1 parent 8adcf17 commit 51796a7

File tree

3 files changed

+124
-93
lines changed

3 files changed

+124
-93
lines changed

lib/model/autocomplete.dart

Lines changed: 120 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import 'narrow.dart';
88
import 'store.dart';
99

1010
extension ComposeContentAutocomplete on ComposeContentController {
11-
AutocompleteIntent? autocompleteIntent() {
11+
AutocompleteIntent<MentionAutocompleteQuery>? autocompleteIntent() {
1212
if (!selection.isValid || !selection.isNormalized) {
1313
// We don't require [isCollapsed] to be true because we've seen that
1414
// autocorrect and even backspace involve programmatically expanding the
@@ -68,8 +68,8 @@ final RegExp mentionAutocompleteMarkerRegex = (() {
6868
unicode: true);
6969
})();
7070

71-
/// The content controller's recognition that the user might want autocomplete UI.
72-
class AutocompleteIntent {
71+
/// The text controller's recognition that the user might want autocomplete UI.
72+
class AutocompleteIntent<QueryT extends AutocompleteQuery> {
7373
AutocompleteIntent({
7474
required this.syntaxStart,
7575
required this.query,
@@ -91,7 +91,7 @@ class AutocompleteIntent {
9191
// that use a custom/subclassed [TextEditingValue], so that's not convenient.
9292
final int syntaxStart;
9393

94-
final MentionAutocompleteQuery query; // TODO other autocomplete query types
94+
final QueryT query;
9595

9696
/// The [TextEditingValue] whose text [syntaxStart] refers to.
9797
final TextEditingValue textEditingValue;
@@ -151,21 +151,90 @@ class AutocompleteViewManager {
151151
// void dispose() { … }
152152
}
153153

154-
/// A view-model for a mention-autocomplete interaction.
154+
/// A view-model for an autocomplete interaction.
155155
///
156156
/// The owner of one of these objects must call [dispose] when the object
157157
/// will no longer be used, in order to free resources on the [PerAccountStore].
158158
///
159159
/// Lifecycle:
160-
/// * Create with [init].
160+
/// * Create an instance of a concrete subtype.
161161
/// * Add listeners with [addListener].
162162
/// * Use the [query] setter to start a search for a query.
163163
/// * On reassemble, call [reassemble].
164164
/// * When the object will no longer be used, call [dispose] to free
165165
/// resources on the [PerAccountStore].
166-
class MentionAutocompleteView extends ChangeNotifier {
166+
abstract class AutocompleteView<QueryT extends AutocompleteQuery, ResultT extends AutocompleteResult, CandidateT> extends ChangeNotifier {
167+
AutocompleteView({required this.store});
168+
169+
final PerAccountStore store;
170+
171+
Iterable<CandidateT> getSortedItemsToTest(QueryT query);
172+
173+
ResultT? testItem(QueryT query, CandidateT item);
174+
175+
QueryT? get query => _query;
176+
QueryT? _query;
177+
set query(QueryT? query) {
178+
_query = query;
179+
if (query != null) {
180+
_startSearch(query);
181+
}
182+
}
183+
184+
/// Called when the app is reassembled during debugging, e.g. for hot reload.
185+
///
186+
/// This will redo the search from scratch for the current query, if any.
187+
void reassemble() {
188+
if (_query != null) {
189+
_startSearch(_query!);
190+
}
191+
}
192+
193+
Iterable<ResultT> get results => _results;
194+
List<ResultT> _results = [];
195+
196+
Future<void> _startSearch(QueryT query) async {
197+
final newResults = await _computeResults(query);
198+
if (newResults == null) {
199+
// Query was old; new search is in progress. Or, no listeners to notify.
200+
return;
201+
}
202+
203+
_results = newResults;
204+
notifyListeners();
205+
}
206+
207+
Future<List<ResultT>?> _computeResults(QueryT query) async {
208+
final List<ResultT> results = [];
209+
final Iterable<CandidateT> data = getSortedItemsToTest(query);
210+
211+
final iterator = data.iterator;
212+
bool isDone = false;
213+
while (!isDone) {
214+
// CPU perf: End this task; enqueue a new one for resuming this work
215+
await Future(() {});
216+
217+
if (query != _query || !hasListeners) { // false if [dispose] has been called.
218+
return null;
219+
}
220+
221+
for (int i = 0; i < 1000; i++) {
222+
if (!iterator.moveNext()) {
223+
isDone = true;
224+
break;
225+
}
226+
final CandidateT item = iterator.current;
227+
final result = testItem(query, item);
228+
if (result != null) results.add(result);
229+
}
230+
}
231+
return results;
232+
}
233+
}
234+
235+
class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery, MentionAutocompleteResult, User> {
167236
MentionAutocompleteView._({
168-
required this.store,
237+
required super.store,
169238
required this.narrow,
170239
required this.sortedUsers,
171240
});
@@ -183,6 +252,9 @@ class MentionAutocompleteView extends ChangeNotifier {
183252
return view;
184253
}
185254

255+
final Narrow narrow;
256+
final List<User> sortedUsers;
257+
186258
static List<User> _usersByRelevance({
187259
required PerAccountStore store,
188260
required Narrow narrow,
@@ -289,6 +361,19 @@ class MentionAutocompleteView extends ChangeNotifier {
289361
streamId: streamId, senderId: userB.userId));
290362
}
291363

364+
@override
365+
Iterable<User> getSortedItemsToTest(MentionAutocompleteQuery query) {
366+
return sortedUsers;
367+
}
368+
369+
@override
370+
MentionAutocompleteResult? testItem(MentionAutocompleteQuery query, User item) {
371+
if (query.testUser(item, store.autocompleteViewManager.autocompleteDataCache)) {
372+
return UserMentionAutocompleteResult(userId: item.userId);
373+
}
374+
return null;
375+
}
376+
292377
/// Determines which of the two users is more recent in DM conversations.
293378
///
294379
/// Returns a negative number if [userA] is more recent than [userB],
@@ -349,82 +434,44 @@ class MentionAutocompleteView extends ChangeNotifier {
349434
// TODO test that logic (may involve detecting an unhandled Future rejection; how?)
350435
super.dispose();
351436
}
437+
}
352438

353-
final PerAccountStore store;
354-
final Narrow narrow;
355-
final List<User> sortedUsers;
439+
abstract class AutocompleteQuery {
440+
AutocompleteQuery(this.raw)
441+
: _lowercaseWords = raw.toLowerCase().split(' ');
356442

357-
MentionAutocompleteQuery? get query => _query;
358-
MentionAutocompleteQuery? _query;
359-
set query(MentionAutocompleteQuery? query) {
360-
_query = query;
361-
if (query != null) {
362-
_startSearch(query);
363-
}
364-
}
443+
final String raw;
444+
final List<String> _lowercaseWords;
365445

366-
/// Called when the app is reassembled during debugging, e.g. for hot reload.
446+
/// Whether all of this query's words have matches in [words] that appear in order.
367447
///
368-
/// This will redo the search from scratch for the current query, if any.
369-
void reassemble() {
370-
if (_query != null) {
371-
_startSearch(_query!);
372-
}
373-
}
374-
375-
Iterable<MentionAutocompleteResult> get results => _results;
376-
List<MentionAutocompleteResult> _results = [];
377-
378-
Future<void> _startSearch(MentionAutocompleteQuery query) async {
379-
final newResults = await _computeResults(query);
380-
if (newResults == null) {
381-
// Query was old; new search is in progress. Or, no listeners to notify.
382-
return;
383-
}
384-
385-
_results = newResults;
386-
notifyListeners();
387-
}
388-
389-
Future<List<MentionAutocompleteResult>?> _computeResults(MentionAutocompleteQuery query) async {
390-
final List<MentionAutocompleteResult> results = [];
391-
final iterator = sortedUsers.iterator;
392-
bool isDone = false;
393-
while (!isDone) {
394-
// CPU perf: End this task; enqueue a new one for resuming this work
395-
await Future(() {});
396-
397-
if (query != _query || !hasListeners) { // false if [dispose] has been called.
398-
return null;
448+
/// A "match" means the word in [words] starts with the query word.
449+
bool _testContainsQueryWords(List<String> words) {
450+
// TODO(#237) test with diacritics stripped, where appropriate
451+
int wordsIndex = 0;
452+
int queryWordsIndex = 0;
453+
while (true) {
454+
if (queryWordsIndex == _lowercaseWords.length) {
455+
return true;
456+
}
457+
if (wordsIndex == words.length) {
458+
return false;
399459
}
400460

401-
for (int i = 0; i < 1000; i++) {
402-
if (!iterator.moveNext()) {
403-
isDone = true;
404-
break;
405-
}
406-
407-
final User user = iterator.current;
408-
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
409-
results.add(UserMentionAutocompleteResult(userId: user.userId));
410-
}
461+
if (words[wordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
462+
queryWordsIndex++;
411463
}
464+
wordsIndex++;
412465
}
413-
return results;
414466
}
415467
}
416468

417-
class MentionAutocompleteQuery {
418-
MentionAutocompleteQuery(this.raw, {this.silent = false})
419-
: _lowercaseWords = raw.toLowerCase().split(' ');
420-
421-
final String raw;
469+
class MentionAutocompleteQuery extends AutocompleteQuery {
470+
MentionAutocompleteQuery(super.raw, {this.silent = false});
422471

423472
/// Whether the user wants a silent mention (@_query, vs. @query).
424473
final bool silent;
425474

426-
final List<String> _lowercaseWords;
427-
428475
bool testUser(User user, AutocompleteDataCache cache) {
429476
// TODO(#236) test email too, not just name
430477

@@ -434,25 +481,7 @@ class MentionAutocompleteQuery {
434481
}
435482

436483
bool _testName(User user, AutocompleteDataCache cache) {
437-
// TODO(#237) test with diacritics stripped, where appropriate
438-
439-
final List<String> nameWords = cache.nameWordsForUser(user);
440-
441-
int nameWordsIndex = 0;
442-
int queryWordsIndex = 0;
443-
while (true) {
444-
if (queryWordsIndex == _lowercaseWords.length) {
445-
return true;
446-
}
447-
if (nameWordsIndex == nameWords.length) {
448-
return false;
449-
}
450-
451-
if (nameWords[nameWordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
452-
queryWordsIndex++;
453-
}
454-
nameWordsIndex++;
455-
}
484+
return _testContainsQueryWords(cache.nameWordsForUser(user));
456485
}
457486

458487
@override
@@ -489,7 +518,9 @@ class AutocompleteDataCache {
489518
}
490519
}
491520

492-
sealed class MentionAutocompleteResult {}
521+
class AutocompleteResult {}
522+
523+
sealed class MentionAutocompleteResult extends AutocompleteResult {}
493524

494525
class UserMentionAutocompleteResult extends MentionAutocompleteResult {
495526
UserMentionAutocompleteResult({required this.userId});

test/model/autocomplete_checks.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import 'package:zulip/model/autocomplete.dart';
33
import 'package:zulip/widgets/compose_box.dart';
44

55
extension ComposeContentControllerChecks on Subject<ComposeContentController> {
6-
Subject<AutocompleteIntent?> get autocompleteIntent => has((c) => c.autocompleteIntent(), 'autocompleteIntent');
6+
Subject<AutocompleteIntent<MentionAutocompleteQuery>?> get autocompleteIntent => has((c) => c.autocompleteIntent(), 'autocompleteIntent');
77
}
88

9-
extension AutocompleteIntentChecks on Subject<AutocompleteIntent> {
9+
extension AutocompleteIntentChecks on Subject<AutocompleteIntent<AutocompleteQuery>> {
1010
Subject<int> get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart');
11-
Subject<MentionAutocompleteQuery> get query => has((i) => i.query, 'query');
11+
Subject<AutocompleteQuery> get query => has((i) => i.query, 'query');
1212
}
1313

1414
extension UserMentionAutocompleteResultChecks on Subject<UserMentionAutocompleteResult> {

test/widgets/compose_box_checks.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ extension ComposeContentControllerChecks on Subject<ComposeContentController> {
88

99
extension AutocompleteIntentChecks on Subject<AutocompleteIntent> {
1010
Subject<int> get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart');
11-
Subject<MentionAutocompleteQuery> get query => has((i) => i.query, 'query');
11+
Subject<AutocompleteQuery> get query => has((i) => i.query, 'query');
1212
}
1313

1414
extension UserMentionAutocompleteResultChecks on Subject<UserMentionAutocompleteResult> {

0 commit comments

Comments
 (0)