updateEditingValueWithDeltas method

  1. @override
void updateEditingValueWithDeltas(
  1. List<TextEditingDelta> deltas
)
override

Requests that this client update its editing state by applying the deltas received from the engine.

The list of TextEditingDelta's are treated as changes that will be applied to the client's editing state. A change is any mutation to the raw text value, or any updates to the selection and/or composing region.

{@tool snippet} This example shows what an implementation of this method could look like.

class MyClient with DeltaTextInputClient {
  TextEditingValue? _localValue;

  @override
  void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
    if (_localValue == null) {
      return;
    }
    TextEditingValue newValue = _localValue!;
    for (final TextEditingDelta delta in textEditingDeltas) {
      newValue = delta.apply(newValue);
    }
    _localValue = newValue;
  }

  // ...
}

{@end-tool}

Implementation

@override
void updateEditingValueWithDeltas(List<TextEditingDelta> deltas) {
  if (_updatingSelf) return;
  if (_structuralChangeInProgress) return;
  if (_document == null) return;
  // Check if we're handling preedit/composing data - sanitize at source
  if (deltas.any((d) => d.composing.isValid)) {
    // Apply deltas to get the final value, then sanitize it
    final value = _applyDeltasSafely(
      currentTextEditingValue ?? const TextEditingValue(),
      deltas,
    );
    final cleanText = _sanitizeUtf16(value.text);
    final cleanValue = TextEditingValue(
      text: cleanText,
      selection: value.selection,
      composing: value.composing,
    );
    updateEditingValue(cleanValue);
    return;
  }

  // Our local buffer (currentTextEditingValue) is intentionally kept
  // EMPTY whenever there is no active composition — we apply finalized
  // text directly to the document and reset the platform buffer
  // immediately after. This means a pure deletion delta (backspace with
  // no composition in progress) always computes as "delete from an empty
  // string", which produces no visible text change and was silently
  // swallowed by updateEditingValue's "new composition" branch.
  //
  // Deletion deltas must be handled explicitly here, before trying to
  // reconstruct a TextEditingValue from a buffer that doesn't represent
  // real document content.
  //
  // PLATFORM QUIRK (iOS): UIKit's text input system does not reliably
  // emit TextEditingDeltaDeletion for a plain Backspace the way Android's
  // IME does. A single backspace on iOS very often arrives as a
  // TextEditingDeltaReplacement with an empty replacementText covering
  // the range to remove (effectively "replace these N characters with
  // nothing"), sometimes even when there is no real "replacement"
  // happening from the user's perspective. If we only special-case
  // TextEditingDeltaDeletion, backspace silently does nothing on iOS once
  // text has been committed, while insertion (which always arrives as a
  // proper TextEditingDeltaInsertion) keeps working — exactly the
  // reported symptom. We treat any TextEditingDeltaReplacement whose
  // replacementText is empty as a deletion too.
  if (!_isComposing) {
    // iOS/macOS/Windows: buffer is synced with fragment text, apply deltas to document.
    if (_shouldSyncBuffer) {
      final _isMacOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS;

      // PLATFORM QUIRK (iOS, empty paragraph): UIKit treats a completely
      // empty text buffer as having nothing for Backspace to act on —
      // pressing Backspace with text: '' produces NO callback at all (no
      // delta, no KeyEvent); UIKit swallows the keystroke before it ever
      // reaches Flutter. Since the current fragment's real text is ''
      // whenever the paragraph is empty, we mirror a single zero-width
      // placeholder character to UIKit instead (see
      // _emptyFragmentPlaceholder / syncImeBufferToFragment /
      // currentTextEditingValue) so Backspace has something real to
      // delete. When the user backspaces it, UIKit now sends a normal
      // deletion/replacement delta shrinking the placeholder buffer from
      // length 1 to 0. We intercept that exact signature here and treat
      // it as a structural backspace (merge with the previous node)
      // instead of letting it fall through and try to write the
      // placeholder into the document.
      if (_isIOS && cursorIsAtFragmentStart) {
        // On iOS we prepend a zero-width placeholder (\u200B) to the IME
        // buffer whenever the cursor is at offset 0 — whether the fragment
        // is empty or not — so Backspace always has something to delete.
        // Detect that placeholder deletion here and treat it as a
        // structural backspace (merge with the previous node).
        final deletedPlaceholder = deltas.any((d) {
          if (d is TextEditingDeltaDeletion) {
            return d.oldText.startsWith(_emptyFragmentPlaceholder) &&
                d.deletedRange.start == 0 &&
                d.deletedRange.end == _emptyFragmentPlaceholder.length;
          }
          if (d is TextEditingDeltaReplacement) {
            return d.oldText.startsWith(_emptyFragmentPlaceholder) &&
                d.replacementText.isEmpty &&
                d.replacedRange.start == 0 &&
                d.replacedRange.end == _emptyFragmentPlaceholder.length;
          }
          return false;
        });
        if (deletedPlaceholder) {
          _document!.saveState(description: 'Backspace', forceNewAction: false);
          executeHandleBackspace(_document!);
          syncImeBufferToFragment();
          return;
        }
      }

      // PLATFORM QUIRK (iOS, non-empty fragment at offset 0): as a
      // residual safety net, also catch the case where the fragment text
      // is NOT empty (so the placeholder above does not apply) but UIKit
      // still reports a zero-content delete attempt — e.g. a batch of
      // only TextEditingDeltaNonTextUpdate entries, or a
      // Deletion/Replacement whose range is already collapsed
      // (start == end). The normal length-diff detection further below
      // only fires on an actual 1/2 character shrink, so this signature
      // would otherwise fall through to updateEditingValue() and be
      // swallowed by the "skip our own echo" guard.
      if (_isIOS && cursorIsAtFragmentStart && deltas.isNotEmpty) {
        final isZeroContentDeleteAttempt = deltas.every((d) {
          if (d is TextEditingDeltaNonTextUpdate) return true;
          if (d is TextEditingDeltaDeletion) {
            return d.deletedRange.start == d.deletedRange.end;
          }
          if (d is TextEditingDeltaReplacement) {
            return d.replacementText.isEmpty &&
                d.replacedRange.start == d.replacedRange.end;
          }
          return false;
        });
        if (isZeroContentDeleteAttempt) {
          _document!.saveState(description: 'Backspace', forceNewAction: false);
          executeHandleBackspace(_document!);
          syncImeBufferToFragment();
          return;
        }
      }

      TextEditingValue value = currentTextEditingValue ?? const TextEditingValue();
      for (final delta in deltas) {
        // Intercept newline insertion from iOS virtual keyboard.
        // On buffer-sync platforms the OS may send the enter as a delta
        // instead of (or in addition to) performAction(newline).
        // If we apply the '\n' to the buffer and then pass it to
        // updateEditingValue, the text ends up duplicated in the new
        // paragraph because the buffer still holds the old fragment text.
        if ((delta is TextEditingDeltaInsertion && delta.textInserted.contains('\n')) ||
            (delta is TextEditingDeltaReplacement && delta.replacementText.contains('\n'))) {
          // Start grace period BEFORE the structural change so any delta
          // echo that arrives during the split is ignored.
          _structuralChangeInProgress = true;
          _structuralChangeTimer?.cancel();

          _document!.saveState(description: 'Enter', forceNewAction: true);
          executeHandleEnter(_document!);
          _justHandledEnter = true;

          // Sync the IME buffer with the NEW fragment text so the platform
          // computes subsequent deltas against the correct content. On iOS
          // this also injects the zero-width placeholder when the cursor is
          // at offset 0, preventing Backspace from being swallowed.
          _lastSyncedFragmentId = _document!.cursor.focusId.isNotEmpty
              ? _document!.cursor.focusId
              : _document!.cursor.anchorId;
          syncImeBufferToFragment();

          _structuralChangeTimer = Timer(_structuralChangeGracePeriod, () {
            _structuralChangeInProgress = false;
          });
          return;
        }

        // On macOS the physical keyboard sends a KeyEvent for backspace;
        // handling the deletion delta as well would double-delete.
        if (_isMacOS && (delta is TextEditingDeltaDeletion ||
            (delta is TextEditingDeltaReplacement &&
                delta.replacementText.isEmpty))) {
          continue;
        }

        // Race-condition guard for rapid backspace on buffer-sync platforms
        // (iOS/Windows). When the user backspaces quickly, the platform may
        // compute this delta against its stale internal buffer (our previous
        // syncImeBufferToFragment / setEditingState hasn't been processed
        // yet). Applying such a delta to our already-updated buffer produces
        // a wrong result and causes the wrong character to be deleted.
        // Instead, detect the mismatch and call executeHandleBackspace
        // directly — the cursor is already at the correct position from the
        // previous operation, so this deletes exactly the right character.
        if (delta.oldText != value.text &&
            (delta is TextEditingDeltaDeletion ||
             (delta is TextEditingDeltaReplacement &&
              delta.replacementText.isEmpty))) {
          final deletionRange = delta is TextEditingDeltaDeletion
              ? delta.deletedRange
              : (delta as TextEditingDeltaReplacement).replacedRange;
          final deleteStart = deletionRange.start.clamp(0, delta.oldText.length);
          final deleteEnd = deletionRange.end.clamp(0, delta.oldText.length);
          final deletedText = delta.oldText.substring(deleteStart, deleteEnd);
          final graphemeCount = deletedText.characters.length;
          _document!.saveState(description: 'Backspace', forceNewAction: false);
          if (!_document!.cursor.isCollapsed) {
            // Active selection: delete it in one shot. Iterating
            // per-grapheme would over-delete after the first call
            // collapses the selection.
            executeHandleBackspace(_document!);
          } else {
            for (int i = 0; i < graphemeCount; i++) {
              executeHandleBackspace(_document!);
            }
          }
          syncImeBufferToFragment();
          return;
        }

        // Handle emoji deletion on Windows: if deleting at start of surrogate pair,
        // expand deletion to include the complete emoji (2 code units)
        if (!_isMacOS && (delta is TextEditingDeltaDeletion ||
            (delta is TextEditingDeltaReplacement &&
                delta.replacementText.isEmpty))) {
          final deletionRange = delta is TextEditingDeltaDeletion
              ? delta.deletedRange
              : (delta as TextEditingDeltaReplacement).replacedRange;

          // Check if we're deleting inside a surrogate pair (emoji)
          final deletionLength = deletionRange.end - deletionRange.start;
          if (deletionRange.isValid && deletionLength == 1) {
            final textBefore = value.text;
            final deleteStart = deletionRange.start.clamp(0, textBefore.length);
            var effectiveDeleteStart = deleteStart;
            if (deleteStart > 0 && deleteStart < textBefore.length) {
              final prev = textBefore.codeUnitAt(deleteStart - 1);
              final curr = textBefore.codeUnitAt(deleteStart);
              if (prev >= 0xD800 && prev <= 0xDBFF &&
                  curr >= 0xDC00 && curr <= 0xDFFF) {
                // Deleting the low surrogate: expand to cover the whole pair
                effectiveDeleteStart = deleteStart - 1;
              }
            }
            if (effectiveDeleteStart < textBefore.length) {
              final graphemeLen = FragmentOperations.getGraphemeLengthAt(textBefore, effectiveDeleteStart);
              if (graphemeLen > 1) {
                // Multi-code-unit grapheme cluster (emoji, CJK + variation
                // selector, etc.) — need to delete all code units together.
                // Adjust selection if we expanded backwards so the delta stays valid.
                var adjustedSelection = delta.selection;
                if (effectiveDeleteStart < deleteStart) {
                  final shift = deleteStart - effectiveDeleteStart;
                  adjustedSelection = delta.selection.copyWith(
                    baseOffset: delta.selection.baseOffset >= deleteStart
                        ? delta.selection.baseOffset - shift
                        : delta.selection.baseOffset,
                    extentOffset: delta.selection.extentOffset >= deleteStart
                        ? delta.selection.extentOffset - shift
                        : delta.selection.extentOffset,
                  );
                }
                // Create a modified delta with expanded range
                if (delta is TextEditingDeltaDeletion) {
                  final expandedDelta = TextEditingDeltaDeletion(
                    oldText: delta.oldText,
                    deletedRange: TextRange(start: effectiveDeleteStart, end: effectiveDeleteStart + graphemeLen),
                    selection: adjustedSelection,
                    composing: delta.composing,
                  );
                  if (value.text != delta.oldText) {
                    value = TextEditingValue(
                      text: delta.oldText,
                      selection: delta.selection,
                      composing: delta.composing,
                    );
                  }
                  if (_isDeltaRangeValid(expandedDelta, value.text)) {
                    value = _safeApplyDelta(expandedDelta, value);
                  }
                } else if (delta is TextEditingDeltaReplacement) {
                  final expandedDelta = TextEditingDeltaReplacement(
                    oldText: delta.oldText,
                    replacementText: '',
                    replacedRange: TextRange(start: effectiveDeleteStart, end: effectiveDeleteStart + graphemeLen),
                    selection: adjustedSelection,
                    composing: delta.composing,
                  );
                  if (value.text != delta.oldText) {
                    value = TextEditingValue(
                      text: delta.oldText,
                      selection: delta.selection,
                      composing: delta.composing,
                    );
                  }
                  if (_isDeltaRangeValid(expandedDelta, value.text)) {
                    value = _safeApplyDelta(expandedDelta, value);
                  }
                }
                continue;
              }
            }
          }
        }

        if (value.text != delta.oldText) {
          value = TextEditingValue(
            text: delta.oldText,
            selection: delta.selection,
            composing: delta.composing,
          );
        }
        if (_isDeltaRangeValid(delta, value.text)) {
          value = _safeApplyDelta(delta, value);
        } else {
          debugPrint('FluentTextInputHandler: skipping malformed delta '
              '(range exceeds oldText length ${value.text.length}): $delta');
          value = TextEditingValue(
            text: delta.oldText,
            selection: delta.selection,
            composing: delta.composing,
          );
        }
      }
      // Pass the selection offset to position cursor correctly after emoji insertion
      // If the selection would cut through a surrogate pair, use the end of text instead
      int? cursorOffset;
      if (value.selection.isValid) {
        final selOffset = value.selection.extentOffset;
        final textLen = value.text.length;
        // Check if selection is in the middle of a surrogate pair
        if (selOffset > 0 && selOffset < textLen) {
          final prev = value.text.codeUnitAt(selOffset - 1);
          final curr = value.text.codeUnitAt(selOffset);
          if (prev >= 0xD800 && prev <= 0xDBFF && curr >= 0xDC00 && curr <= 0xDFFF) {
            // Selection is in the middle of a surrogate pair, use end of text
            cursorOffset = textLen;
          } else {
            cursorOffset = selOffset;
          }
        } else {
          cursorOffset = selOffset;
        }
      }
      updateEditingValue(value, cursorOffset: cursorOffset);
      return;
    }

    // ─── Android & Desktop: buffer NOT synced, process deltas directly ──
    // IMPORTANT: If any delta contains a valid composing range, we should NOT
    // process the deltas here. Instead, we should let them be handled by the
    // composition branch below (line 479+) which knows how to handle preedit.
    final hasComposing = deltas.any((d) => d.composing.isValid);
    if (hasComposing) {
      // A composition is starting/active - handle via updateEditingValue
      final value = _applyDeltasSafely(
        currentTextEditingValue ?? const TextEditingValue(),
        deltas,
      );
      updateEditingValue(value);
      return;
    }

    final doc = _document!;
    for (final delta in deltas) {
      if (delta is TextEditingDeltaDeletion ||
          (delta is TextEditingDeltaReplacement &&
              delta.replacementText.isEmpty)) {
        // Backspace/Delete operation
        doc.saveState(description: 'Delete', forceNewAction: false);

        // If there's an active selection, delete it in one shot instead
        // of iterating per-grapheme (which would over-delete or delete
        // from the wrong position since the buffer doesn't reflect the
        // full selection on non-sync platforms like Android).
        if (!doc.cursor.isCollapsed) {
          executeHandleBackspace(doc);
          _resetPlatformBuffer();
          return;
        }

        final deletionRange = delta is TextEditingDeltaDeletion
            ? delta.deletedRange
            : (delta as TextEditingDeltaReplacement).replacedRange;

        if (deletionRange.isValid && deletionRange.start < deletionRange.end) {
          final oldText = delta.oldText;
          final deleteStart = deletionRange.start.clamp(0, oldText.length);
          final deleteEnd = deletionRange.end.clamp(0, oldText.length);
          final deletedText = oldText.substring(deleteStart, deleteEnd);
          // executeHandleBackspace is grapheme-aware, so call it once per
          // grapheme cluster rather than per UTF-16 code unit.
          final graphemeCount = deletedText.characters.length;
          for (int i = 0; i < graphemeCount; i++) {
            executeHandleBackspace(doc);
          }
        } else if (deletionRange.isValid && deletionRange.start == deletionRange.end) {
          // Zero-length deletion on Android: the IME buffer is empty but
          // the user pressed backspace. If the cursor is on an empty
          // fragment or at the start of a fragment, treat this as a
          // structural backspace so the cursor can enter the adjacent node.
          final currentFragText = _getCurrentFragmentText() ?? '';
          if (currentFragText.isEmpty || _getCursorOffsetInFragment() == 0) {
            executeHandleBackspace(doc);
          }
        }
      } else if (delta is TextEditingDeltaNonTextUpdate) {
        // Selection or composing range changed without text mutation.
        // At this point we've already checked that composing.isValid is false
        // for all deltas, so this is just a selection change.
        // No action needed - selection changes don't affect document content.
      } else if (delta is TextEditingDeltaInsertion) {
        // Text insertion (regular character or emoji input)
        doc.saveState(description: 'Insert text', forceNewAction: false);
        final insertedText = delta.textInserted;
        _insertTextOrReplaceSelection(insertedText, doc);
      } else if (delta is TextEditingDeltaReplacement) {
        // Text replacement (selection replaced with new text)
        // This handles autocorrect, suggestion acceptance, etc.
        doc.saveState(description: 'Replace text', forceNewAction: false);

        final replacedRange = delta.replacedRange;
        final replacementText = delta.replacementText;
        final oldText = delta.oldText;

        if (replacedRange.isValid && replacedRange.start != replacedRange.end) {
          final fragId = doc.cursor.focusId.isNotEmpty ? doc.cursor.focusId : doc.cursor.anchorId;
          final currentOffset = doc.cursor.focusOffset;

          // On Android the buffer is empty before typing, so oldText contains
          // only what was typed in this session and was inserted sequentially.
          final typedGraphemeCount = oldText.characters.length;
          final bufferStartInDoc = currentOffset - typedGraphemeCount;

          // Convert buffer code-unit offsets to grapheme offsets
          final beforeReplace = oldText.substring(0, replacedRange.start.clamp(0, oldText.length));
          final replaceStartGraphemes = beforeReplace.characters.length;

          final upToReplaceEnd = oldText.substring(0, replacedRange.end.clamp(0, oldText.length));
          final replaceEndGraphemes = upToReplaceEnd.characters.length;

          final docStart = bufferStartInDoc + replaceStartGraphemes;
          final docEnd = bufferStartInDoc + replaceEndGraphemes;

          // Select the exact range to replace
          doc.cursor.batchUpdate(() {
            doc.cursor.anchorId = fragId;
            doc.cursor.anchorOffset = docStart;
            doc.cursor.focusId = fragId;
            doc.cursor.focusOffset = docEnd;
          });
          executeHandleReplaceSelection(replacementText, doc);
        } else {
          // No existing text to replace; just insert the new text
          _insertTextOrReplaceSelection(replacementText, doc);
        }
        _resetPlatformBuffer();
      }
    }
    // On Android, if deletion left the current fragment empty, reset the
    // platform buffer so the IME starts from a clean slate on the next
    // keystroke and doesn't compute deltas against stale text.
    if (!_shouldSyncBuffer) {
      final currentFragText = _getCurrentFragmentText();
      if (currentFragText != null && currentFragText.isEmpty) {
        _resetPlatformBuffer();
      }
    }
    doc.updateContent();
    return;
  }

  // Composition active: reconstruct the final TextEditingValue by
  // applying deltas sequentially against our preedit buffer, as before.
  final value = _applyDeltasSafely(
    currentTextEditingValue ?? const TextEditingValue(),
    deltas,
  );
  updateEditingValue(value);
}