Iterate through potential grapheme cluster lengths in text painter (#24797)
This commit is contained in:
parent
21c6dda1a1
commit
f8964ae250
@ -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<TextBox> 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<TextBox> boxes = <TextBox>[];
|
||||
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<TextBox> 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<TextBox> boxes = <TextBox>[];
|
||||
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 {
|
||||
|
@ -29,6 +29,90 @@ void main() {
|
||||
expect(caretOffset.dx, painter.width);
|
||||
});
|
||||
|
||||
test('TextPainter caret emoji test', () {
|
||||
final TextPainter painter = TextPainter()
|
||||
..textDirection = TextDirection.ltr;
|
||||
|
||||
// Format: '👩<zwj>👩<zwj>👦👩<zwj>👩<zwj>👧<zwj>👧🇺🇸'
|
||||
// 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); // <zwj>
|
||||
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); // <zwj>
|
||||
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); // <zwj>
|
||||
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); // <zwj>
|
||||
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); // <zwj>
|
||||
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));
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user