Skip to content

Commit ab18a94

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

File tree

3 files changed

+138
-81
lines changed

3 files changed

+138
-81
lines changed

lib/model/autocomplete.dart

Lines changed: 135 additions & 78 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,38 +151,115 @@ 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> extends ChangeNotifier {
172167

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;
168+
/// This could be used to transform results after they've been
169+
/// computed for sorting, filtering etc.
170+
final List<R> Function(List<R> results)? resultsFilter;
171+
final PerAccountStore store;
172+
173+
AutocompleteView({this.resultsFilter, required this.store});
174+
175+
Iterable<Object> getDataForQuery(Q query);
176+
177+
R? testItem(Q query, Object item);
178+
179+
Q? get query => _query;
180+
Q? _query;
181+
set query(Q? query) {
182+
_query = query;
183+
if (query != null) {
184+
_startSearch(query);
185+
}
186+
}
187+
188+
/// Called when the app is reassembled during debugging, e.g. for hot reload.
189+
///
190+
/// This will redo the search from scratch for the current query, if any.
191+
void reassemble() {
192+
if (_query != null) {
193+
_startSearch(_query!);
194+
}
195+
}
196+
197+
Iterable<R> get results => _results;
198+
List<R> _results = [];
199+
200+
Future<void> _startSearch(Q query) async {
201+
List<R>? newResults;
202+
203+
while (true) {
204+
try {
205+
newResults = await _computeResults(query);
206+
break;
207+
} on ConcurrentModificationError {
208+
// Retry
209+
// TODO backoff?
210+
}
211+
}
212+
213+
if (newResults == null) {
214+
// Query was old; new search is in progress. Or, no listeners to notify.
215+
return;
216+
}
217+
218+
_results = newResults;
219+
notifyListeners();
220+
}
221+
222+
Future<List<R>?> _computeResults(Q query) async {
223+
final List<R> results = [];
224+
final Iterable<Object> data = getDataForQuery(query);
225+
226+
final iterator = data.iterator;
227+
bool isDone = false;
228+
while (!isDone) {
229+
// CPU perf: End this task; enqueue a new one for resuming this work
230+
await Future(() {});
231+
232+
if (query != _query || !hasListeners) { // false if [dispose] has been called.
233+
return null;
234+
}
235+
236+
for (int i = 0; i < 1000; i++) {
237+
if (!iterator.moveNext()) {
238+
isDone = true;
239+
break;
240+
}
241+
final Object item = iterator.current;
242+
final result = testItem(query, item);
243+
if (result != null) results.add(result);
244+
}
245+
}
246+
return resultsFilter?.call(results) ?? results;
247+
}
248+
}
249+
250+
class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery, MentionAutocompleteResult> {
251+
final Narrow narrow;
252+
253+
MentionAutocompleteView.init({
254+
required super.store,
255+
required this.narrow,
256+
}) : sortedUsers = _usersByRelevance(store: store, narrow: narrow)
257+
{
258+
store.autocompleteViewManager.registerMentionAutocomplete(this);
184259
}
185260

261+
final List<User> sortedUsers;
262+
186263
static List<User> _usersByRelevance({
187264
required PerAccountStore store,
188265
required Narrow narrow,
@@ -192,6 +269,21 @@ class MentionAutocompleteView extends ChangeNotifier {
192269
..sort((userA, userB) => compareByDms(userA, userB, store: store));
193270
}
194271

272+
@override
273+
Iterable<Object> getDataForQuery(MentionAutocompleteQuery query) {
274+
return sortedUsers;
275+
}
276+
277+
@override
278+
MentionAutocompleteResult? testItem(MentionAutocompleteQuery query, Object item) {
279+
if (item is User) {
280+
if (query.testUser(item, store.autocompleteViewManager.autocompleteDataCache)) {
281+
return UserMentionAutocompleteResult(userId: item.userId);
282+
}
283+
}
284+
return null;
285+
}
286+
195287
/// Determines which of the two users is more recent in DM conversations.
196288
///
197289
/// Returns a negative number if [userA] is more recent than [userB],
@@ -221,76 +313,39 @@ class MentionAutocompleteView extends ChangeNotifier {
221313
super.dispose();
222314
}
223315

224-
final PerAccountStore store;
225-
final Narrow narrow;
226-
final List<User> sortedUsers;
227-
228-
MentionAutocompleteQuery? get query => _query;
229-
MentionAutocompleteQuery? _query;
230-
set query(MentionAutocompleteQuery? query) {
231-
_query = query;
232-
if (query != null) {
233-
_startSearch(query);
234-
}
235-
}
236-
237-
/// Called when the app is reassembled during debugging, e.g. for hot reload.
316+
/// Recompute user results for the current query, if any.
238317
///
239-
/// This will redo the search from scratch for the current query, if any.
240-
void reassemble() {
318+
/// Called in particular when we get a [RealmUserEvent].
319+
void refreshStaleUserResults() {
241320
if (_query != null) {
242321
_startSearch(_query!);
243322
}
244323
}
324+
}
245325

246-
Iterable<MentionAutocompleteResult> get results => _results;
247-
List<MentionAutocompleteResult> _results = [];
326+
abstract class AutocompleteQuery {
327+
AutocompleteQuery(this.raw);
248328

249-
Future<void> _startSearch(MentionAutocompleteQuery query) async {
250-
final newResults = await _computeResults(query);
251-
if (newResults == null) {
252-
// Query was old; new search is in progress. Or, no listeners to notify.
253-
return;
254-
}
329+
final String raw;
255330

256-
_results = newResults;
257-
notifyListeners();
331+
@override
332+
String toString() {
333+
return '${objectRuntimeType(this, 'AutocompleteQuery')}(raw: $raw})';
258334
}
259335

260-
Future<List<MentionAutocompleteResult>?> _computeResults(MentionAutocompleteQuery query) async {
261-
final List<MentionAutocompleteResult> results = [];
262-
final iterator = sortedUsers.iterator;
263-
bool isDone = false;
264-
while (!isDone) {
265-
// CPU perf: End this task; enqueue a new one for resuming this work
266-
await Future(() {});
267-
268-
if (query != _query || !hasListeners) { // false if [dispose] has been called.
269-
return null;
270-
}
271-
272-
for (int i = 0; i < 1000; i++) {
273-
if (!iterator.moveNext()) {
274-
isDone = true;
275-
break;
276-
}
277-
278-
final User user = iterator.current;
279-
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
280-
results.add(UserMentionAutocompleteResult(userId: user.userId));
281-
}
282-
}
283-
}
284-
return results;
336+
@override
337+
bool operator ==(Object other) {
338+
return other is AutocompleteQuery && other.raw == raw;
285339
}
340+
341+
@override
342+
int get hashCode => Object.hash('AutocompleteQuery', raw);
286343
}
287344

288-
class MentionAutocompleteQuery {
289-
MentionAutocompleteQuery(this.raw, {this.silent = false})
345+
class MentionAutocompleteQuery extends AutocompleteQuery {
346+
MentionAutocompleteQuery(super.raw, {this.silent = false})
290347
: _lowercaseWords = raw.toLowerCase().split(' ');
291348

292-
final String raw;
293-
294349
/// Whether the user wants a silent mention (@_query, vs. @query).
295350
final bool silent;
296351

@@ -352,7 +407,9 @@ class AutocompleteDataCache {
352407
}
353408
}
354409

355-
sealed class MentionAutocompleteResult {}
410+
class AutocompleteResult {}
411+
412+
sealed class MentionAutocompleteResult extends AutocompleteResult {}
356413

357414
class UserMentionAutocompleteResult extends MentionAutocompleteResult {
358415
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)