Skip to content

Commit 28c2694

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 c6d3ae1 commit 28c2694

File tree

3 files changed

+137
-110
lines changed

3 files changed

+137
-110
lines changed

lib/model/autocomplete.dart

Lines changed: 134 additions & 107 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
@@ -69,7 +69,7 @@ final RegExp mentionAutocompleteMarkerRegex = (() {
6969
})();
7070

7171
/// The content controller's recognition that the user might want autocomplete UI.
72-
class AutocompleteIntent {
72+
class AutocompleteIntent<Q 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 Q query;
9595

9696
/// The [TextEditingValue] whose text [syntaxStart] refers to.
9797
final TextEditingValue textEditingValue;
@@ -151,36 +151,97 @@ 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 {
167-
MentionAutocompleteView._({
168-
required this.store,
169-
required this.narrow,
170-
required this.sortedUsers,
171-
});
166+
abstract class AutocompleteView<Q extends AutocompleteQuery, R extends AutocompleteResult, T> extends ChangeNotifier {
167+
final PerAccountStore store;
172168

173-
factory MentionAutocompleteView.init({
174-
required PerAccountStore store,
175-
required Narrow narrow,
176-
}) {
177-
final view = MentionAutocompleteView._(
178-
store: store,
179-
narrow: narrow,
180-
sortedUsers: _usersByRelevance(store: store, narrow: narrow),
181-
);
182-
store.autocompleteViewManager.registerMentionAutocomplete(view);
183-
return view;
169+
AutocompleteView({required this.store});
170+
171+
Iterable<T> getSortedItemsToTest(Q query);
172+
173+
R? testItem(Q query, T item);
174+
175+
Q? get query => _query;
176+
Q? _query;
177+
set query(Q? 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<R> get results => _results;
194+
List<R> _results = [];
195+
196+
Future<void> _startSearch(Q 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<R>?> _computeResults(Q query) async {
208+
final List<R> results = [];
209+
final Iterable<T> 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 T 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> {
236+
final Narrow narrow;
237+
final List<User> sortedUsers;
238+
239+
MentionAutocompleteView.init({
240+
required super.store,
241+
required this.narrow,
242+
}) : sortedUsers = _usersByRelevance(store: store, narrow: narrow)
243+
{
244+
store.autocompleteViewManager.registerMentionAutocomplete(this);
184245
}
185246

186247
static List<User> _usersByRelevance({
@@ -278,6 +339,19 @@ class MentionAutocompleteView extends ChangeNotifier {
278339
streamId: streamId, senderId: userB.userId));
279340
}
280341

342+
@override
343+
Iterable<User> getSortedItemsToTest(MentionAutocompleteQuery query) {
344+
return sortedUsers;
345+
}
346+
347+
@override
348+
MentionAutocompleteResult? testItem(MentionAutocompleteQuery query, User item) {
349+
if (query.testUser(item, store.autocompleteViewManager.autocompleteDataCache)) {
350+
return UserMentionAutocompleteResult(userId: item.userId);
351+
}
352+
return null;
353+
}
354+
281355
/// Determines which of the two users is more recent in DM conversations.
282356
///
283357
/// Returns a negative number if [userA] is more recent than [userB],
@@ -317,110 +391,61 @@ class MentionAutocompleteView extends ChangeNotifier {
317391
// TODO test that logic (may involve detecting an unhandled Future rejection; how?)
318392
super.dispose();
319393
}
394+
}
320395

321-
final PerAccountStore store;
322-
final Narrow narrow;
323-
final List<User> sortedUsers;
396+
abstract class AutocompleteQuery {
397+
final String raw;
398+
final List<String> _lowercaseWords;
324399

325-
MentionAutocompleteQuery? get query => _query;
326-
MentionAutocompleteQuery? _query;
327-
set query(MentionAutocompleteQuery? query) {
328-
_query = query;
329-
if (query != null) {
330-
_startSearch(query);
331-
}
332-
}
400+
AutocompleteQuery(this.raw) : _lowercaseWords = raw.toLowerCase().split(' ');
333401

334-
/// Called when the app is reassembled during debugging, e.g. for hot reload.
402+
/// Whether all of this query's words have matches in [words] that appear in order.
335403
///
336-
/// This will redo the search from scratch for the current query, if any.
337-
void reassemble() {
338-
if (_query != null) {
339-
_startSearch(_query!);
340-
}
341-
}
342-
343-
Iterable<MentionAutocompleteResult> get results => _results;
344-
List<MentionAutocompleteResult> _results = [];
345-
346-
Future<void> _startSearch(MentionAutocompleteQuery query) async {
347-
final newResults = await _computeResults(query);
348-
if (newResults == null) {
349-
// Query was old; new search is in progress. Or, no listeners to notify.
350-
return;
351-
}
352-
353-
_results = newResults;
354-
notifyListeners();
355-
}
356-
357-
Future<List<MentionAutocompleteResult>?> _computeResults(MentionAutocompleteQuery query) async {
358-
final List<MentionAutocompleteResult> results = [];
359-
final iterator = sortedUsers.iterator;
360-
bool isDone = false;
361-
while (!isDone) {
362-
// CPU perf: End this task; enqueue a new one for resuming this work
363-
await Future(() {});
364-
365-
if (query != _query || !hasListeners) { // false if [dispose] has been called.
366-
return null;
404+
/// A "match" means the word in [words] starts with the query word.
405+
bool _testContainsQueryWords(List<String> words) {
406+
// TODO(#237) test with diacritics stripped, where appropriate
407+
int wordsIndex = 0;
408+
int queryWordsIndex = 0;
409+
while (true) {
410+
if (queryWordsIndex == _lowercaseWords.length) {
411+
return true;
412+
}
413+
if (wordsIndex == words.length) {
414+
return false;
367415
}
368416

369-
for (int i = 0; i < 1000; i++) {
370-
if (!iterator.moveNext()) {
371-
isDone = true;
372-
break;
373-
}
374-
375-
final User user = iterator.current;
376-
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
377-
results.add(UserMentionAutocompleteResult(userId: user.userId));
378-
}
417+
if (words[wordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
418+
queryWordsIndex++;
379419
}
420+
wordsIndex++;
380421
}
381-
return results;
382422
}
383-
}
384423

385-
class MentionAutocompleteQuery {
386-
MentionAutocompleteQuery(this.raw, {this.silent = false})
387-
: _lowercaseWords = raw.toLowerCase().split(' ');
424+
@override
425+
String toString() {
426+
return '${objectRuntimeType(this, 'AutocompleteQuery')}(raw: $raw})';
427+
}
388428

389-
final String raw;
429+
@override
430+
bool operator ==(Object other) {
431+
return other is AutocompleteQuery && other.raw == raw;
432+
}
390433

434+
@override
435+
int get hashCode => Object.hash('AutocompleteQuery', raw);
436+
}
437+
438+
class MentionAutocompleteQuery extends AutocompleteQuery {
391439
/// Whether the user wants a silent mention (@_query, vs. @query).
392440
final bool silent;
393441

394-
final List<String> _lowercaseWords;
442+
MentionAutocompleteQuery(super.raw, {this.silent = false});
395443

396444
bool testUser(User user, AutocompleteDataCache cache) {
397445
// TODO(#236) test email too, not just name
398-
399446
if (!user.isActive) return false;
400447

401-
return _testName(user, cache);
402-
}
403-
404-
bool _testName(User user, AutocompleteDataCache cache) {
405-
// TODO(#237) test with diacritics stripped, where appropriate
406-
407-
final List<String> nameWords = cache.nameWordsForUser(user);
408-
409-
int nameWordsIndex = 0;
410-
int queryWordsIndex = 0;
411-
while (true) {
412-
if (queryWordsIndex == _lowercaseWords.length) {
413-
return true;
414-
}
415-
if (nameWordsIndex == nameWords.length) {
416-
return false;
417-
}
418-
419-
if (nameWords[nameWordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
420-
queryWordsIndex++;
421-
}
422-
nameWordsIndex++;
423-
}
448+
return _testContainsQueryWords(cache.nameWordsForUser(user));
424449
}
425450

426451
@override
@@ -449,7 +474,9 @@ class AutocompleteDataCache {
449474
}
450475
}
451476

452-
sealed class MentionAutocompleteResult {}
477+
class AutocompleteResult {}
478+
479+
sealed class MentionAutocompleteResult extends AutocompleteResult {}
453480

454481
class UserMentionAutocompleteResult extends MentionAutocompleteResult {
455482
UserMentionAutocompleteResult({required this.userId});

test/model/autocomplete_checks.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ 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<MentionAutocompleteQuery>> {
1010
Subject<int> get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart');
1111
Subject<MentionAutocompleteQuery> get query => has((i) => i.query, 'query');
1212
}

test/widgets/compose_box_checks.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ extension ComposeContentControllerChecks on Subject<ComposeContentController> {
66
Subject<List<ContentValidationError>> get validationErrors => has((c) => c.validationErrors, 'validationErrors');
77
}
88

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

0 commit comments

Comments
 (0)