From ad29ea3f0e3ecdac130f31b01c1661706712c624 Mon Sep 17 00:00:00 2001 From: Strycher Date: Tue, 14 Jul 2026 15:19:49 -0400 Subject: [PATCH] fix(#238): Enter selects the highlighted emoji/mention in autocomplete The autocomplete only completed an unambiguous emoji on Enter; ambiguous queries (e.g. `:shrug` with multiple matches) sent the literal text instead of the highlighted glyph, and mentions only selected via Tab. Enter now accepts the highlighted item (emoji or mention) like Tab, then sends; Tab still selects without sending, Esc dismisses to send literal text. Removes the now-dead _emojiQuery field. (#231, emoji + mentions per Ben) Widget tests: Enter inserts the highlighted emoji (ambiguous `:shr`) and the highlighted mention (`@Bo` -> @[Bob]). Part of #231. Co-Authored-By: Claude Opus 4.8 --- lib/widgets/mention_autocomplete.dart | 35 +++----- .../mention_autocomplete_enter_test.dart | 90 +++++++++++++++++++ 2 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 test/widgets/mention_autocomplete_enter_test.dart diff --git a/lib/widgets/mention_autocomplete.dart b/lib/widgets/mention_autocomplete.dart index e998f44..5302d98 100644 --- a/lib/widgets/mention_autocomplete.dart +++ b/lib/widgets/mention_autocomplete.dart @@ -42,10 +42,10 @@ class _Entry { /// - Typing `:` at the start of a word lists matching [emojiShortcodes] and /// inserts the emoji character. Typing a full `:name:` auto-converts it. /// -/// Up/Down move the highlight, Tab or click selects, Esc dismisses. Enter -/// sends, and first completes an unambiguous emoji (single or exact shortcode -/// match) so it ships the glyph rather than the literal text. Either feature -/// is disabled by leaving its data empty/null. +/// Up/Down move the highlight, Tab or click selects without sending, Esc +/// dismisses. Enter accepts the highlighted item (emoji or mention) then sends; +/// dismiss with Esc first to send literal text. Either feature is disabled by +/// leaving its data empty/null. class MentionAutocompleteField extends StatefulWidget { final int maxBytes; final TextEditingController controller; @@ -84,10 +84,6 @@ class _MentionAutocompleteFieldState extends State { int _highlighted = -1; int _tokenStart = -1; - /// Active emoji query while the dropdown shows emoji matches (null for - /// mentions or when closed). Lets Enter complete an unambiguous emoji + send. - String? _emojiQuery; - @override void initState() { super.initState(); @@ -211,7 +207,6 @@ class _MentionAutocompleteFieldState extends State { } void _filterMentions(String query) { - _emojiQuery = null; if (widget.candidates.isEmpty) { _close(); return; @@ -306,7 +301,6 @@ class _MentionAutocompleteFieldState extends State { .toList(); _recentStart = _matches.length; // no divider for emoji _highlighted = _matches.length - 1; - _emojiQuery = query; _show(); } @@ -330,7 +324,6 @@ class _MentionAutocompleteFieldState extends State { _matches = const []; _highlighted = -1; _tokenStart = -1; - _emojiQuery = null; } } @@ -358,18 +351,14 @@ class _MentionAutocompleteFieldState extends State { } if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) { - // For an unambiguous emoji shortcode (a single match, or the exact name), - // Enter completes the glyph first, then sends — so `:rofl` + Enter ships - // the emoji, not the literal text. Ambiguous matches still send raw; use - // Tab to pick one deliberately. - final q = _emojiQuery; - if (q != null && _matches.isNotEmpty) { - final best = _matches[_highlighted]; - if (_matches.length == 1 || best.label == ':$q:') { - _select(best); // inserts the glyph and closes the dropdown - widget.onSubmitted?.call(widget.controller.text); - return KeyEventResult.handled; - } + // Enter accepts the highlighted item (emoji or mention) like Tab, then + // sends — so `:shrug` + Enter ships the highlighted glyph, and `@Bob` + + // Enter inserts the mention. Tab accepts without sending; Esc dismisses + // the dropdown to send literal text. (#231) + if (_highlighted >= 0 && _highlighted < _matches.length) { + _select(_matches[_highlighted]); + widget.onSubmitted?.call(widget.controller.text); + return KeyEventResult.handled; } final text = widget.controller.text; _close(); diff --git a/test/widgets/mention_autocomplete_enter_test.dart b/test/widgets/mention_autocomplete_enter_test.dart new file mode 100644 index 0000000..493e40b --- /dev/null +++ b/test/widgets/mention_autocomplete_enter_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/widgets/mention_autocomplete.dart'; + +void main() { + testWidgets('Enter inserts the highlighted emoji, not literal text (#231)', ( + tester, + ) async { + final controller = TextEditingController(); + final focusNode = FocusNode(); + addTearDown(() { + controller.dispose(); + focusNode.dispose(); + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MentionAutocompleteField( + maxBytes: 200, + controller: controller, + focusNode: focusNode, + // Ambiguous query: ':shr' matches both, so the pre-#231 code would + // have sent the literal ':shr' instead of the highlighted glyph. + emojiShortcodes: const { + 'shrug': '\u{1F937}', + 'shrimp': '\u{1F990}', + }, + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + + controller.value = const TextEditingValue( + text: ':shr', + selection: TextSelection.collapsed(offset: 4), + ); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(controller.text.contains(':shr'), isFalse); + expect( + controller.text.contains('\u{1F937}') || + controller.text.contains('\u{1F990}'), + isTrue, + ); + }); + + testWidgets('Enter inserts the highlighted mention (#231)', (tester) async { + final controller = TextEditingController(); + final focusNode = FocusNode(); + addTearDown(() { + controller.dispose(); + focusNode.dispose(); + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MentionAutocompleteField( + maxBytes: 200, + controller: controller, + focusNode: focusNode, + candidates: const [MentionCandidate(name: 'Bob', recent: false)], + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + + controller.value = const TextEditingValue( + text: '@Bo', + selection: TextSelection.collapsed(offset: 3), + ); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(controller.text, contains('@[Bob]')); + }); +}