Skip to content

Commit 4d18990

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 49a00fd commit 4d18990

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

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

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

326-
MentionAutocompleteQuery? get query => _query;
327-
MentionAutocompleteQuery? _query;
328-
set query(MentionAutocompleteQuery? query) {
329-
_query = query;
330-
if (query != null) {
331-
_startSearch(query);
332-
}
333-
}
412+
final String raw;
413+
final List<String> _lowercaseWords;
334414

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

370-
for (int i = 0; i < 1000; i++) {
371-
if (!iterator.moveNext()) {
372-
isDone = true;
373-
break;
374-
}
375-
376-
final User user = iterator.current;
377-
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
378-
results.add(UserMentionAutocompleteResult(userId: user.userId));
379-
}
430+
if (words[wordsIndex].startsWith(_lowercaseWords[queryWordsIndex])) {
431+
queryWordsIndex++;
380432
}
433+
wordsIndex++;
381434
}
382-
return results;
383435
}
384436
}
385437

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

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

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

@@ -403,25 +450,7 @@ class MentionAutocompleteQuery {
403450
}
404451

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

427456
@override
@@ -450,7 +479,9 @@ class AutocompleteDataCache {
450479
}
451480
}
452481

453-
sealed class MentionAutocompleteResult {}
482+
class AutocompleteResult {}
483+
484+
sealed class MentionAutocompleteResult extends AutocompleteResult {}
454485

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