From 20eed13884eb8b59eb83b0af0b0c5e44d194578c Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 3 Nov 2026 15:50:46 -0500 Subject: [PATCH] Add weighted search Fixes #74 --- lib/controllers/bot_argument_suggestions.dart | 12 ++- lib/helpers/extensions/weighted_search.dart | 47 +++++++++++ .../composer/overlays/emoji_overlay.dart | 13 +-- .../composer/overlays/room_overlay.dart | 80 +++++++++---------- .../composer/overlays/user_overlay.dart | 68 +++++++--------- lib/widgets/settings/search_chooser.dart | 12 +-- lib/widgets/settings/settings_layout.dart | 23 ++---- lib/widgets/timezone_field.dart | 3 +- 8 files changed, 142 insertions(+), 116 deletions(-) create mode 100644 lib/helpers/extensions/weighted_search.dart diff --git a/lib/controllers/bot_argument_suggestions.dart b/lib/controllers/bot_argument_suggestions.dart index aaccf98..4150b8b 100644 --- a/lib/controllers/bot_argument_suggestions.dart +++ b/lib/controllers/bot_argument_suggestions.dart @@ -5,6 +5,7 @@ import "package:nexus/controllers/members_by_status.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/via.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; import "package:nexus/models/bot_argument_suggestion.dart"; import "package:nexus/models/bot_command_draft.dart"; import "package:nexus/models/content/bot_command_description.dart"; @@ -91,13 +92,10 @@ class BotArgumentSuggestionsController(final String roomId) ) .nonNulls, ) - .where( - (suggestion) => - !picked.contains(suggestion.text) && - [ - suggestion.title, - suggestion.subtitle, - ].any((text) => text?.toLowerCase().contains(query) == true), + .where((suggestion) => !picked.contains(suggestion.text)) + .search( + query, + (suggestion) => [(suggestion.title, 2), (suggestion.subtitle, 1)], ) .take(8) .toIList(); diff --git a/lib/helpers/extensions/weighted_search.dart b/lib/helpers/extensions/weighted_search.dart new file mode 100644 index 0000000..1f76b0a --- /dev/null +++ b/lib/helpers/extensions/weighted_search.dart @@ -0,0 +1,47 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +extension WeightedSearch on Iterable { + /// The items with a field matching [query], best match first. + IList search( + String query, + Iterable<(String?, int)> Function(T item) fieldsOf, + ) { + final needle = query.trim().toLowerCase(); + if (needle.isEmpty) return toIList(); + + int qualityOf(String text) { + final haystack = text.toLowerCase(); + if (haystack == needle) return 4; + if (haystack.startsWith(needle)) return 3; + if (haystack + .split(RegExp(r"[\s_\-:/.@#!]+")) + .any((w) => w.startsWith(needle))) { + return 2; + } + if (haystack.contains(needle)) return 1; + return 0; + } + + return .new( + map( + (item) => ( + item, + fieldsOf(item) + .map( + (field) => switch (field) { + (final text?, final weight) => + qualityOf(text) * weight, + (null, _) => 0, + }, + ) + .maxOrNull ?? + 0, + ), + ) + .where((scored) => scored.$2 > 0) + .sortedBy((scored) => -scored.$2) + .map((scored) => scored.$1), + ); + } +} diff --git a/lib/widgets/composer/overlays/emoji_overlay.dart b/lib/widgets/composer/overlays/emoji_overlay.dart index 3ff0d55..bb4b88f 100644 --- a/lib/widgets/composer/overlays/emoji_overlay.dart +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -3,6 +3,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/all_emoji.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/image_pack.dart"; @@ -21,11 +22,13 @@ class const EmojiOverlay( children: emojis .map((element) => element.emojis) .flattened - .where( - (emoji) => - emoji.aliases.join().contains(query) || - emoji.description.contains(query) || - emoji.tags.join().contains(query), + .search( + query, + (emoji) => [ + ...emoji.aliases.map((alias) => (alias, 3)), + ...emoji.tags.map((tag) => (tag, 2)), + (emoji.description, 1), + ], ) .map( (emoji) => ListTile( diff --git a/lib/widgets/composer/overlays/room_overlay.dart b/lib/widgets/composer/overlays/room_overlay.dart index 28eb4ab..f4778a8 100644 --- a/lib/widgets/composer/overlays/room_overlay.dart +++ b/lib/widgets/composer/overlays/room_overlay.dart @@ -2,6 +2,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/via.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; class const RoomOverlay( @@ -14,47 +15,46 @@ class const RoomOverlay( Widget build(BuildContext context, WidgetRef ref) { final rooms = ref.watch(RoomsController.provider); return ListView( - children: - (query.isEmpty - ? rooms.values - : rooms.values.where( - (room) => (room.metadata?.name ?? room.metadata?.id ?? "") - .toLowerCase() - .contains(query.toLowerCase()), - )) - .map((room) { - final name = - room.metadata?.name ?? - room.metadata?.canonicalAlias ?? - room.metadata?.id ?? - "Unknown Room"; - return ListTile( - leading: AvatarOrHash( - room.metadata?.avatar, - name, - fallback: Icon(Icons.numbers), - ), - title: Text(name), - subtitle: room.metadata?.topic == null - ? null - : Text(room.metadata!.topic!, maxLines: 1), - onTap: () { - final vias = ref - .watch(ViaController.provider(room)) - .viaQuery; - addTag( - id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", - name: - (room.metadata?.canonicalAlias ?? room.metadata?.id) - ?.substring(1) - .split(":") - .first ?? - "", - ); - }, + children: rooms.values + .search( + query, + (room) => [ + (room.metadata?.name, 3), + (room.metadata?.canonicalAlias, 2), + (room.metadata?.id, 1), + ], + ) + .map((room) { + final name = + room.metadata?.name ?? + room.metadata?.canonicalAlias ?? + room.metadata?.id ?? + "Unknown Room"; + return ListTile( + leading: AvatarOrHash( + room.metadata?.avatar, + name, + fallback: Icon(Icons.numbers), + ), + title: Text(name), + subtitle: room.metadata?.topic == null + ? null + : Text(room.metadata!.topic!, maxLines: 1), + onTap: () { + final vias = ref.watch(ViaController.provider(room)).viaQuery; + addTag( + id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", + name: + (room.metadata?.canonicalAlias ?? room.metadata?.id) + ?.substring(1) + .split(":") + .first ?? + "", ); - }) - .toList(), + }, + ); + }) + .toList(), ); } } diff --git a/lib/widgets/composer/overlays/user_overlay.dart b/lib/widgets/composer/overlays/user_overlay.dart index fe4f70f..e6f122b 100644 --- a/lib/widgets/composer/overlays/user_overlay.dart +++ b/lib/widgets/composer/overlays/user_overlay.dart @@ -3,6 +3,7 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/members_by_status.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -20,45 +21,34 @@ class const UserOverlay( ) .betterWhen( data: (members) => ListView( - children: - (query.isEmpty - ? members - : members.where( - (member) => - member.stateKey?.toLowerCase().contains( - query.toLowerCase(), - ) == - true || - switch (member.content) { - MembershipContent(:final displayName) => - displayName?.toLowerCase().contains( - query.toLowerCase(), - ) == - true, - _ => false, - }, - )) - .map( - (member) => switch (member.content) { - MembershipContent(:final displayName, :final avatarUrl) => - ListTile( - leading: AvatarOrHash( - avatarUrl, - displayName ?? member.stateKey!.localpart, - ), - title: Text( - displayName ?? member.stateKey!.localpart, - ), - subtitle: Text(member.stateKey!), - onTap: () => addTag( - id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", - name: member.stateKey!.localpart, - ), - ), - _ => SizedBox.shrink(), - }, - ) - .toList(), + children: members + .search( + query, + (member) => [ + if (member.content case MembershipContent(:final displayName)) + (displayName, 2), + (member.stateKey, 1), + ], + ) + .map( + (member) => switch (member.content) { + MembershipContent(:final displayName, :final avatarUrl) => + ListTile( + leading: AvatarOrHash( + avatarUrl, + displayName ?? member.stateKey!.localpart, + ), + title: Text(displayName ?? member.stateKey!.localpart), + subtitle: Text(member.stateKey!), + onTap: () => addTag( + id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", + name: member.stateKey!.localpart, + ), + ), + _ => SizedBox.shrink(), + }, + ) + .toList(), ), ); } diff --git a/lib/widgets/settings/search_chooser.dart b/lib/widgets/settings/search_chooser.dart index feadcdd..f78d476 100644 --- a/lib/widgets/settings/search_chooser.dart +++ b/lib/widgets/settings/search_chooser.dart @@ -1,6 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; /// The [chosen] items, above a field searching [options] to choose more. class const SearchChooser({ @@ -30,15 +31,10 @@ class const SearchChooser({ }) extends HookWidget { @override Widget build(BuildContext context) { - final query = useValueListenable(controller).text.trim().toLowerCase(); + final query = useValueListenable(controller).text; final shown = options - .where( - (id) => - !chosen.contains(id) && - (nameOf(id).toLowerCase().contains(query) || - id.toLowerCase().contains(query)), - ) - .toIList(); + .where((id) => !chosen.contains(id)) + .search(query, (id) => [(nameOf(id), 2), (id, 1)]); ListTile tileOf(String id, {Widget? trailing, VoidCallback? onTap}) => ListTile( diff --git a/lib/widgets/settings/settings_layout.dart b/lib/widgets/settings/settings_layout.dart index 7f6c527..52a7c16 100644 --- a/lib/widgets/settings/settings_layout.dart +++ b/lib/widgets/settings/settings_layout.dart @@ -7,6 +7,7 @@ import "package:hooks_riverpod/misc.dart"; import "package:m3e_card_list/m3e_card_list.dart"; import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/widgets/pages/settings_category.dart"; import "package:nexus/widgets/divider_text.dart"; @@ -43,32 +44,22 @@ class const SettingsLayout({ // Only searched once shown, by which point these have loaded final categories = ref.read(sectionsProvider).value ?? const IMap.empty(); - final query = controller.text.toLowerCase(); - final matches = categories.values.flattenedToList .asMap() .entries .expand( - (categoryEntry) => categoryEntry.value.settings - .asMap() - .entries - .where( - (settingEntry) => - settingEntry.value.title.toLowerCase().contains( - query, - ) || - settingEntry.value.description - .toLowerCase() - .contains(query), - ) - .map( + (categoryEntry) => + categoryEntry.value.settings.asMap().entries.map( (settingEntry) => ( (categoryEntry.key, settingEntry.key), settingEntry.value, ), ), ) - .toIList(); + .search( + controller.text, + (match) => [(match.$2.title, 2), (match.$2.description, 1)], + ); return matches.map( (match) => ListTile( diff --git a/lib/widgets/timezone_field.dart b/lib/widgets/timezone_field.dart index 9653e7b..6f802b6 100644 --- a/lib/widgets/timezone_field.dart +++ b/lib/widgets/timezone_field.dart @@ -4,6 +4,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/controllers/timezones.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/widgets/settings/inline_text_field.dart"; import "package:timezone/timezone.dart"; @@ -84,7 +85,7 @@ class const TimezoneField({ onTap: () => choose(controller, null), ), ...timezones - .where((timezone) => timezone.toLowerCase().contains(query)) + .search(query, (timezone) => [(timezone, 1)]) .map( (timezone) => ListTile( leading: Icon(