Skip to content

Commit 7d9cc85

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 265ad97 commit 7d9cc85

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,
@@ -278,6 +350,19 @@ class MentionAutocompleteView extends ChangeNotifier {
278350
streamId: streamId, senderId: userB.userId));
279351
}
280352

353+
@override
354+
Iterable<User> getSortedItemsToTest(MentionAutocompleteQuery query) {
355+
return sortedUsers;
356+
}
357+
358+
@override
359+
MentionAutocompleteResult? testItem(MentionAutocompleteQuery query, User item) {
360+
if (query.testUser(item, store.autocompleteViewManager.autocompleteDataCache)) {
361+
return UserMentionAutocompleteResult(userId: item.userId);
362+
}
363+
return null;
364+
}
365+
281366
/// Determines which of the two users is more recent in DM conversations.
282367
///
283368
/// Returns a negative number if [userA] is more recent than [userB],
@@ -317,82 +402,44 @@ class MentionAutocompleteView extends ChangeNotifier {
317402
// TODO test that logic (may involve detecting an unhandled Future rejection; how?)
318403
super.dispose();
319404
}
405+
}
320406

321-
final PerAccountStore store;
322-
final Narrow narrow;
323-
final List<User> sortedUsers;
407+
abstract class AutocompleteQuery {
408+
AutocompleteQuery(this.raw)
409+
: _lowercaseWords = raw.toLowerCase().split(' ');
324410

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-
}
411+
final String raw;
412+
final List<String> _lowercaseWords;
333413

334-
/// Called when the app is reassembled during debugging, e.g. for hot reload.
414+
/// Whether all of this query's words have matches in [words] that appear in order.
335415
///
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;
416+
/// A "match" means the word in [words] starts with the query word.
417+
bool _testContainsQueryWords(List<String> words) {
418+
// TODO(#237) test with diacritics stripped, where appropriate
419+
int wordsIndex = 0;
420+
int queryWordsIndex = 0;
421+
while (true) {
422+
if (queryWordsIndex == _lowercaseWords.length) {
423+
return true;
424+
}
425+
if (wordsIndex == words.length) {
426+
return false;
367427
}
368428

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-
}
429+
if (words[wordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
430+
queryWordsIndex++;
379431
}
432+
wordsIndex++;
380433
}
381-
return results;
382434
}
383435
}
384436

385-
class MentionAutocompleteQuery {
386-
MentionAutocompleteQuery(this.raw, {this.silent = false})
387-
: _lowercaseWords = raw.toLowerCase().split(' ');
388-
389-
final String raw;
437+
class MentionAutocompleteQuery extends AutocompleteQuery {
438+
MentionAutocompleteQuery(super.raw, {this.silent = false});
390439

391440
/// Whether the user wants a silent mention (@_query, vs. @query).
392441
final bool silent;
393442

394-
final List<String> _lowercaseWords;
395-
396443
bool testUser(User user, AutocompleteDataCache cache) {
397444
// TODO(#236) test email too, not just name
398445

@@ -402,25 +449,7 @@ class MentionAutocompleteQuery {
402449
}
403450

404451
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-
}
452+
return _testContainsQueryWords(cache.nameWordsForUser(user));
424453
}
425454

426455
@override
@@ -449,7 +478,9 @@ class AutocompleteDataCache {
449478
}
450479
}
451480

452-
sealed class MentionAutocompleteResult {}
481+
class AutocompleteResult {}
482+
483+
sealed class MentionAutocompleteResult extends AutocompleteResult {}
453484

454485
class UserMentionAutocompleteResult extends MentionAutocompleteResult {
455486
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)