Skip to content

IME: multi-character CJK candidate pick duplicates composing run on macOS (user-reported; canonical-delta repro does not fail) #580

Description

@JulianPscheid

Summary

A Traditional Chinese user on macOS reports that picking multi-character candidates in FleatherEditor produces duplicated text. Single-character picks work. Representative output from their note (intended 但神不會過去式):

但神不會但神不會過去會過去式

Same shape as the closed #150.

What I investigated

I couldn't reproduce with a programmatic test. A minimal widget test that drives RawEditorState.updateEditingValueWithDeltas with the canonical delta sequence — N TextEditingDeltaInsertion events for composing keystrokes, then a single TextEditingDeltaReplacement for the candidate pick — passes. Test source is included below.

I also tried a "stale-IME-state" hypothesis (IME emits deltas against its own pre-pick view of the text). That crashes parchment with index == 0 || (index > 0 && index < length), so whatever macOS actually emits must stay index-consistent with the post-pick document.

Ask

Does anyone with a Traditional Chinese IME (Pinyin or Zhuyin) on macOS see this? A debugPrint of the received delta list inside RawEditorStateTextInputClientMixin.updateEditingValueWithDeltas during a real multi-character candidate pick would let us tell whether this is a fleather bug or something on the Flutter macOS engine side that needs to go upstream to flutter/flutter.

Environment

  • OS: macOS (user-reported)
  • Flutter: 3.41.7 (stable, 2026-04-15)
  • Fleather: 1.26.0
  • Parchment: 1.25.1

Canonical-delta test (passes — included as a baseline)

// Baseline test for Fleather's IME delta handling, written as part of
// investigating a user-reported bug where multi-character CJK candidate
// picks produce duplicated text on macOS (fleather 1.26.0, Flutter 3.41.7).
//
// This test confirms that fleather handles the CANONICAL delta sequence
// correctly: a run of TextEditingDeltaInsertion events (one per composing
// keystroke) followed by a TextEditingDeltaReplacement (candidate pick)
// produces exactly the candidate text with no duplication.
//
// Since this passes, the user-reported duplication must originate from a
// different delta stream than the canonical one — most likely from macOS's
// platform-to-Dart translation for CJK input methods, not from fleather's
// delta handler itself. Upstream maintainers with a Chinese IME installed
// should capture the actual delta stream for comparison.
//
// Run with: flutter test test/fleather_ime_test.dart
import 'package:fleather/fleather.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

/// Feeds an IME composition sequence: N insertions (one per phonetic key) and
/// a final replacement when the user picks the candidate.
Future<void> _composeAndPick({
  required WidgetTester tester,
  required RawEditorState editorState,
  required String existingText,
  required String composing,
  required String candidate,
}) async {
  final baseOffset = existingText.length;
  String running = existingText;
  for (var i = 0; i < composing.length; i++) {
    final ch = composing[i];
    final oldText = running;
    running = '$running$ch';
    editorState.updateEditingValueWithDeltas([
      TextEditingDeltaInsertion(
        oldText: oldText,
        textInserted: ch,
        insertionOffset: baseOffset + i,
        selection: TextSelection.collapsed(offset: baseOffset + i + 1),
        composing: TextRange(start: baseOffset, end: baseOffset + i + 1),
      ),
    ]);
    await tester.pump();
  }

  editorState.updateEditingValueWithDeltas([
    TextEditingDeltaReplacement(
      oldText: running,
      replacedRange: TextRange(
          start: baseOffset, end: baseOffset + composing.length),
      replacementText: candidate,
      selection: TextSelection.collapsed(offset: baseOffset + candidate.length),
      composing: TextRange.empty,
    ),
  ]);
  await tester.pump();
}

void main() {
  testWidgets(
    'canonical IME delta sequence: multi-character candidate pick replaces composing run',
    (tester) async {
      final controller = FleatherController();
      final focusNode = FocusNode();

      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: FleatherEditor(
            controller: controller,
            focusNode: focusNode,
            autofocus: true,
          ),
        ),
      ));

      focusNode.requestFocus();
      await tester.pumpAndSettle();
      final editorState = tester.state<RawEditorState>(find.byType(RawEditor));

      await _composeAndPick(
        tester: tester,
        editorState: editorState,
        existingText: '',
        composing: 'guoqu',
        candidate: '過去',
      );
      expect(
        controller.document.toPlainText().replaceAll('\n', ''),
        '過去',
        reason: 'single candidate pick should yield only the candidate',
      );

      await _composeAndPick(
        tester: tester,
        editorState: editorState,
        existingText: '過去',
        composing: 'danshenbuhui',
        candidate: '但神不會',
      );
      expect(
        controller.document.toPlainText().replaceAll('\n', ''),
        '過去但神不會',
        reason:
            'two successive candidate picks must append cleanly (no accumulation)',
      );

      // Let fleather's history-throttle timer drain before teardown.
      await tester.pump(const Duration(milliseconds: 600));
    },
  );
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions