From 9df482f57c3560b9460776008ebc854daf17c16c Mon Sep 17 00:00:00 2001 From: Vitor Maciel Date: Tue, 12 May 2026 19:54:25 +0100 Subject: [PATCH] Fix double-emit when committing composing characters on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CustomTextEdit resets the platform editing state to the static _initEditingState after every successful insert. On macOS, that reset is silently dropped when a character commits through the composing path (e.g. typing the apostrophe key, which arrives as composing=(0,1) then collapses). The next keystroke therefore arrives as a cumulative TextEditingValue (text="'d" instead of "d"), and the existing diff against _initEditingState re-emits the already-emitted apostrophe — so typing "I" + "'" + "d" was sending "I''d" down the pty rather than "I'd". Track the platform's last-reported text in a local _seenText mirror and diff inserts against that instead of the static init. In deleteDetection mode (Android soft-keyboard placeholder backspace detection) the original placeholder-and-reset behaviour is preserved because removing it would break the IME's ability to keep emitting backspace events. In the non-deleteDetection path the reset is dropped: the mirror keeps the diff math correct regardless of whether setEditingState() would have taken. Verified on macOS Flutter desktop with a minimal TerminalView harness: typing I + ' + d now emits onOutput once per character (codes [73],[39],[100]) instead of [73],[39],[39,100]. --- lib/src/ui/custom_text_edit.dart | 50 ++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/lib/src/ui/custom_text_edit.dart b/lib/src/ui/custom_text_edit.dart index 1d33c7a2..85c34bfb 100644 --- a/lib/src/ui/custom_text_edit.dart +++ b/lib/src/ui/custom_text_edit.dart @@ -197,6 +197,15 @@ class CustomTextEditState extends State with TextInputClient { late var _currentEditingState = _initEditingState.copyWith(); + // Mirror of the platform's last-reported text in non-deleteDetection mode. + // We diff against this instead of [_initEditingState] because some embedders + // (notably macOS, when an apostrophe arrives via the composing path) silently + // drop the setEditingState() reset we'd otherwise issue after every event. + // When that happens, the next keystroke arrives as a cumulative + // TextEditingValue, and diffing against a stale init would re-emit + // already-emitted characters (e.g. typing "I" + "'" + "d" produced "I''d"). + late var _seenText = _initEditingState.text; + @override TextEditingValue? get currentTextEditingValue { return _currentEditingState; @@ -221,21 +230,38 @@ class CustomTextEditState extends State with TextInputClient { widget.onComposing(null); - if (_currentEditingState.text.length < _initEditingState.text.length) { - widget.onDelete(); - } else { - final textDelta = _currentEditingState.text.substring( - _initEditingState.text.length, - ); - - widget.onInsert(textDelta); + if (widget.deleteDetection) { + // Placeholder-based delete detection (Android soft-keyboard path). + // Preserve original semantics: diff against the static placeholder, + // emit delete when the field shrinks below it, and reset the platform + // state after every event to refill the placeholder so the IME keeps + // generating backspace events. + if (_currentEditingState.text.length < _initEditingState.text.length) { + widget.onDelete(); + } else { + widget.onInsert( + _currentEditingState.text.substring(_initEditingState.text.length), + ); + } + if (_currentEditingState.text != _initEditingState.text) { + _connection!.setEditingState(_initEditingState); + } + _seenText = _initEditingState.text; + return; } - // Reset editing state if composing is done - if (_currentEditingState.composing.isCollapsed && - _currentEditingState.text != _initEditingState.text) { - _connection!.setEditingState(_initEditingState); + // Non-placeholder path. Diff against the platform's last-reported text + // rather than the static init state, so the math stays correct even when + // setEditingState() didn't take effect on the previous event. We also + // skip the post-event reset for the same reason: trying to force the + // platform back to init is what created the divergence in the first + // place; just letting the platform's text accumulate keeps it + // consistent with our mirror. + final newText = _currentEditingState.text; + if (newText.length > _seenText.length) { + widget.onInsert(newText.substring(_seenText.length)); } + _seenText = newText; } @override