diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index 56d66070a3..3af0629690 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' show min, max; import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle; import 'package:flutter/foundation.dart'; @@ -387,6 +388,8 @@ class TextPainter { canvas.drawParagraph(_paragraph, offset); } + // Complex glyphs can be represented by two or more UTF16 codepoints. This + // checks if the value represents a UTF16 glyph by itself or is a 'surrogate'. bool _isUtf16Surrogate(int value) { return value & 0xF800 == 0xD800; } @@ -411,32 +414,80 @@ class TextPainter { return _isUtf16Surrogate(prevCodeUnit) ? offset - 2 : offset - 1; } + // Unicode value for a zero width joiner character. + static const int _zwjUtf16 = 0x200d; + + // TODO(garyq): Use actual extended grapheme cluster length instead of + // an increasing cluster length amount to achieve deterministic performance. Offset _getOffsetFromUpstream(int offset, Rect caretPrototype) { - final int prevCodeUnit = _text.codeUnitAt(offset - 1); + final int prevCodeUnit = _text.codeUnitAt(max(0, offset - 1)); if (prevCodeUnit == null) return null; - final int prevRuneOffset = _isUtf16Surrogate(prevCodeUnit) ? offset - 2 : offset - 1; - final List boxes = _paragraph.getBoxesForRange(prevRuneOffset, offset); - if (boxes.isEmpty) - return null; - final TextBox box = boxes[0]; - final double caretEnd = box.end; - final double dx = box.direction == TextDirection.rtl ? caretEnd - caretPrototype.width : caretEnd; - return Offset(dx, box.top); + // Check for multi-code-unit glyphs such as emojis or zero width joiner + final bool needsSearch = _isUtf16Surrogate(prevCodeUnit) || _text.codeUnitAt(offset) == _zwjUtf16; + int graphemeClusterLength = needsSearch ? 2 : 1; + List boxes = []; + while (boxes.isEmpty && _text.text != null) { + final int prevRuneOffset = offset - graphemeClusterLength; + boxes = _paragraph.getBoxesForRange(prevRuneOffset, offset); + // When the range does not include a full cluster, no boxes will be returned. + if (boxes.isEmpty) { + // When we are at the beginning of the line, a non-surrogate position will + // return empty boxes. We break and try from downstream instead. + if (!needsSearch) + break; // Only perform one iteration if no search is required. + if (prevRuneOffset < -_text.text.length) + break; // Stop iterating when beyond the max length of the text. + // Multiply by two to log(n) time cover the entire text span. This allows + // faster discovery of very long clusters and reduces the possibility + // of certain large clusters taking much longer than others, which can + // cause jank. + graphemeClusterLength *= 2; + continue; + } + final TextBox box = boxes[0]; + final double caretEnd = box.end; + final double dx = box.direction == TextDirection.rtl ? caretEnd - caretPrototype.width : caretEnd; + return Offset(dx, box.top); + } + return null; } + // TODO(garyq): Use actual extended grapheme cluster length instead of + // an increasing cluster length amount to achieve deterministic performance. Offset _getOffsetFromDownstream(int offset, Rect caretPrototype) { - final int nextCodeUnit = _text.codeUnitAt(offset - 1); + // We cap the offset at the final index of the _text. + final int nextCodeUnit = _text.codeUnitAt(min(offset, _text.text == null ? 0 : _text.text.length - 1)); if (nextCodeUnit == null) return null; - final int nextRuneOffset = _isUtf16Surrogate(nextCodeUnit) ? offset + 2 : offset + 1; - final List boxes = _paragraph.getBoxesForRange(offset, nextRuneOffset); - if (boxes.isEmpty) - return null; - final TextBox box = boxes[0]; - final double caretStart = box.start; - final double dx = box.direction == TextDirection.rtl ? caretStart - caretPrototype.width : caretStart; - return Offset(dx, box.top); + // Check for multi-code-unit glyphs such as emojis or zero width joiner + final bool needsSearch = _isUtf16Surrogate(nextCodeUnit) || nextCodeUnit == _zwjUtf16; + int graphemeClusterLength = needsSearch ? 2 : 1; + List boxes = []; + while (boxes.isEmpty && _text.text != null) { + final int nextRuneOffset = offset + graphemeClusterLength; + boxes = _paragraph.getBoxesForRange(offset, nextRuneOffset); + // When the range does not include a full cluster, no boxes will be returned. + if (boxes.isEmpty) { + // When we are at the end of the line, a non-surrogate position will + // return empty boxes. We break and try from upstream instead. + if (!needsSearch) + break; // Only perform one iteration if no search is required. + if (nextRuneOffset >= _text.text.length << 1) + break; // Stop iterating when beyond the max length of the text. + // Multiply by two to log(n) time cover the entire text span. This allows + // faster discovery of very long clusters and reduces the possibility + // of certain large clusters taking much longer than others, which can + // cause jank. + graphemeClusterLength *= 2; + continue; + } + final TextBox box = boxes[0]; + final double caretStart = box.start; + final double dx = box.direction == TextDirection.rtl ? caretStart - caretPrototype.width : caretStart; + return Offset(dx, box.top); + } + return null; } Offset get _emptyOffset { diff --git a/packages/flutter/test/painting/text_painter_test.dart b/packages/flutter/test/painting/text_painter_test.dart index 3ce2837098..06c5dedab8 100644 --- a/packages/flutter/test/painting/text_painter_test.dart +++ b/packages/flutter/test/painting/text_painter_test.dart @@ -29,6 +29,90 @@ void main() { expect(caretOffset.dx, painter.width); }); + test('TextPainter caret emoji test', () { + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr; + + // Format: 'πŸ‘©β€πŸ‘©β€πŸ‘¦πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘§πŸ‡ΊπŸ‡Έ' + // One three-person family, one four person family, one US flag. + const String text = 'πŸ‘©β€πŸ‘©β€πŸ‘¦πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘§πŸ‡ΊπŸ‡Έ'; + painter.text = const TextSpan(text: text); + painter.layout(); + + expect(text.length, 23); + + Offset caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); + expect(caretOffset.dx, 0); // πŸ‘©β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: text.length), ui.Rect.zero); + expect(caretOffset.dx, painter.width); + + // Two UTF-16 codepoints per emoji, one codepoint per zwj + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); + expect(caretOffset.dx, 42); // πŸ‘©β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 2), ui.Rect.zero); + expect(caretOffset.dx, 42); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 3), ui.Rect.zero); + expect(caretOffset.dx, 42); // πŸ‘©β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 4), ui.Rect.zero); + expect(caretOffset.dx, 42); // πŸ‘©β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 5), ui.Rect.zero); + expect(caretOffset.dx, 42); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 6), ui.Rect.zero); + expect(caretOffset.dx, 42); // πŸ‘¦ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 7), ui.Rect.zero); + expect(caretOffset.dx, 42); // πŸ‘¦ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 8), ui.Rect.zero); + expect(caretOffset.dx, 42); // πŸ‘©β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 9), ui.Rect.zero); + expect(caretOffset.dx, 98); // πŸ‘©β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 10), ui.Rect.zero); + expect(caretOffset.dx, 98); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 11), ui.Rect.zero); + expect(caretOffset.dx, 98); // πŸ‘©β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 12), ui.Rect.zero); + expect(caretOffset.dx, 98); // πŸ‘©β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 13), ui.Rect.zero); + expect(caretOffset.dx, 98); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 14), ui.Rect.zero); + expect(caretOffset.dx, 98); // πŸ‘§β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 15), ui.Rect.zero); + expect(caretOffset.dx, 98); // πŸ‘§β€ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 16), ui.Rect.zero); + expect(caretOffset.dx, 98); // + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 17), ui.Rect.zero); + expect(caretOffset.dx, 98); // πŸ‘§ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 18), ui.Rect.zero); + expect(caretOffset.dx, 98); // πŸ‘§ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 19), ui.Rect.zero); + expect(caretOffset.dx, 98); // πŸ‡Ί + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 20), ui.Rect.zero); + expect(caretOffset.dx, 112); // πŸ‡Ί + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 21), ui.Rect.zero); + expect(caretOffset.dx, 112); // πŸ‡Έ + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 22), ui.Rect.zero); + expect(caretOffset.dx, 112); // πŸ‡Έ + }); + + test('TextPainter caret center space test', () { + final TextPainter painter = TextPainter() + ..textDirection = TextDirection.ltr; + + const String text = 'test text with space at end '; + painter.text = const TextSpan(text: text); + painter.textAlign = TextAlign.center; + painter.layout(); + + Offset caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 0), ui.Rect.zero); + expect(caretOffset.dx, 21); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: text.length), ui.Rect.zero); + expect(caretOffset.dx, 399); + + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 1), ui.Rect.zero); + expect(caretOffset.dx, 35); + caretOffset = painter.getOffsetForCaret(const ui.TextPosition(offset: 2), ui.Rect.zero); + expect(caretOffset.dx, 49); + }); + test('TextPainter error test', () { final TextPainter painter = TextPainter(textDirection: TextDirection.ltr); expect(() { painter.paint(null, Offset.zero); }, throwsFlutterError); @@ -198,7 +282,7 @@ void main() { text = 'aaa\n'; painter.text = TextSpan(text: text); painter.layout(); - caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: text.length), ui.Rect.zero); + caretOffset = painter.getOffsetForCaret(ui.TextPosition(offset: text.length, affinity: TextAffinity.downstream), ui.Rect.zero); expect(caretOffset.dx, closeTo(0.0, 0.0001)); expect(caretOffset.dy, closeTo(14.0, 0.0001));