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 <noreply@anthropic.com>
pull/241/head
Strycher 3 months ago
parent d39893f120
commit ad29ea3f0e

@ -42,10 +42,10 @@ class _Entry {
/// - Typing `:` at the start of a word lists matching [emojiShortcodes] and /// - Typing `:` at the start of a word lists matching [emojiShortcodes] and
/// inserts the emoji character. Typing a full `:name:` auto-converts it. /// inserts the emoji character. Typing a full `:name:` auto-converts it.
/// ///
/// Up/Down move the highlight, Tab or click selects, Esc dismisses. Enter /// Up/Down move the highlight, Tab or click selects without sending, Esc
/// sends, and first completes an unambiguous emoji (single or exact shortcode /// dismisses. Enter accepts the highlighted item (emoji or mention) then sends;
/// match) so it ships the glyph rather than the literal text. Either feature /// dismiss with Esc first to send literal text. Either feature is disabled by
/// is disabled by leaving its data empty/null. /// leaving its data empty/null.
class MentionAutocompleteField extends StatefulWidget { class MentionAutocompleteField extends StatefulWidget {
final int maxBytes; final int maxBytes;
final TextEditingController controller; final TextEditingController controller;
@ -84,10 +84,6 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
int _highlighted = -1; int _highlighted = -1;
int _tokenStart = -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 @override
void initState() { void initState() {
super.initState(); super.initState();
@ -211,7 +207,6 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
} }
void _filterMentions(String query) { void _filterMentions(String query) {
_emojiQuery = null;
if (widget.candidates.isEmpty) { if (widget.candidates.isEmpty) {
_close(); _close();
return; return;
@ -306,7 +301,6 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
.toList(); .toList();
_recentStart = _matches.length; // no divider for emoji _recentStart = _matches.length; // no divider for emoji
_highlighted = _matches.length - 1; _highlighted = _matches.length - 1;
_emojiQuery = query;
_show(); _show();
} }
@ -330,7 +324,6 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
_matches = const []; _matches = const [];
_highlighted = -1; _highlighted = -1;
_tokenStart = -1; _tokenStart = -1;
_emojiQuery = null;
} }
} }
@ -358,18 +351,14 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
} }
if (key == LogicalKeyboardKey.enter || if (key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter) { key == LogicalKeyboardKey.numpadEnter) {
// For an unambiguous emoji shortcode (a single match, or the exact name), // Enter accepts the highlighted item (emoji or mention) like Tab, then
// Enter completes the glyph first, then sends — so `:rofl` + Enter ships // sends — so `:shrug` + Enter ships the highlighted glyph, and `@Bob` +
// the emoji, not the literal text. Ambiguous matches still send raw; use // Enter inserts the mention. Tab accepts without sending; Esc dismisses
// Tab to pick one deliberately. // the dropdown to send literal text. (#231)
final q = _emojiQuery; if (_highlighted >= 0 && _highlighted < _matches.length) {
if (q != null && _matches.isNotEmpty) { _select(_matches[_highlighted]);
final best = _matches[_highlighted]; widget.onSubmitted?.call(widget.controller.text);
if (_matches.length == 1 || best.label == ':$q:') { return KeyEventResult.handled;
_select(best); // inserts the glyph and closes the dropdown
widget.onSubmitted?.call(widget.controller.text);
return KeyEventResult.handled;
}
} }
final text = widget.controller.text; final text = widget.controller.text;
_close(); _close();

@ -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]'));
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.