fix(widget): Improve MiddleEllipsisText Unicode and surrogate handling
This commit is contained in:
@@ -2,6 +2,9 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
/// A single-line text widget that truncates the middle of long strings
|
||||
/// with an ellipsis (e.g., "prefix…suffix") so both ends remain visible.
|
||||
///
|
||||
/// This widget handles Unicode text safely, including emojis and other
|
||||
/// characters that span multiple UTF-16 code units (surrogate pairs).
|
||||
class MiddleEllipsisText extends StatelessWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
@@ -20,6 +23,9 @@ class MiddleEllipsisText extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Sanitize text to remove any unpaired surrogates that could cause crashes.
|
||||
final String safeText = _sanitizeUtf16(text);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final TextStyle effectiveStyle = DefaultTextStyle.of(
|
||||
@@ -29,7 +35,7 @@ class MiddleEllipsisText extends StatelessWidget {
|
||||
final double maxWidth = constraints.maxWidth;
|
||||
|
||||
// Measure full text width first.
|
||||
final fullSpan = TextSpan(text: text, style: effectiveStyle);
|
||||
final fullSpan = TextSpan(text: safeText, style: effectiveStyle);
|
||||
final fullPainter = TextPainter(
|
||||
text: fullSpan,
|
||||
textDirection: direction,
|
||||
@@ -38,7 +44,7 @@ class MiddleEllipsisText extends StatelessWidget {
|
||||
|
||||
if (fullPainter.width <= maxWidth) {
|
||||
return Text(
|
||||
text,
|
||||
safeText,
|
||||
style: effectiveStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
@@ -47,6 +53,11 @@ class MiddleEllipsisText extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Use grapheme clusters (Characters) to safely split text without
|
||||
// breaking surrogate pairs or emoji sequences.
|
||||
final characters = safeText.characters;
|
||||
final int totalGraphemes = characters.length;
|
||||
|
||||
// Pre-measure ellipsis width (used implicitly during search).
|
||||
final ellipsisSpan = TextSpan(text: ellipsis, style: effectiveStyle);
|
||||
final ellipsisPainter = TextPainter(
|
||||
@@ -56,24 +67,25 @@ class MiddleEllipsisText extends StatelessWidget {
|
||||
)..layout(minWidth: 0, maxWidth: double.infinity);
|
||||
final double _ = ellipsisPainter.width; // hint width; not used directly
|
||||
|
||||
// Binary search the maximum number of visible characters (k), split
|
||||
// Binary search the maximum number of visible graphemes (k), split
|
||||
// between start and end. For a given k, we use ceil(k/2) from start
|
||||
// and floor(k/2) from end.
|
||||
int low = 0;
|
||||
int high = text.length; // exclusive upper bound in practice
|
||||
int high = totalGraphemes;
|
||||
int bestK = 0;
|
||||
String bestStart = '';
|
||||
String bestEnd = '';
|
||||
|
||||
while (low <= high) {
|
||||
final int k = (low + high) >> 1; // candidate visible char count
|
||||
final int k = (low + high) >> 1; // candidate visible grapheme count
|
||||
final int leftCount = (k + 1) >> 1; // ceil(k/2)
|
||||
final int rightCount = k - leftCount; // floor(k/2)
|
||||
|
||||
final String start = text.substring(0, leftCount);
|
||||
// Use Characters.take/takeLast to safely extract grapheme clusters.
|
||||
final String start = characters.take(leftCount).toString();
|
||||
final String end = rightCount == 0
|
||||
? ''
|
||||
: text.substring(text.length - rightCount);
|
||||
: characters.takeLast(rightCount).toString();
|
||||
|
||||
final trialSpan = TextSpan(
|
||||
text: '$start$ellipsis$end',
|
||||
@@ -102,7 +114,7 @@ class MiddleEllipsisText extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
textAlign: textAlign,
|
||||
semanticsLabel: semanticsLabel ?? text,
|
||||
semanticsLabel: semanticsLabel ?? safeText,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,9 +125,48 @@ class MiddleEllipsisText extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
textAlign: textAlign,
|
||||
semanticsLabel: semanticsLabel ?? text,
|
||||
semanticsLabel: semanticsLabel ?? safeText,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes unpaired UTF-16 surrogates that would cause "not well-formed
|
||||
/// UTF-16" errors during text layout.
|
||||
///
|
||||
/// A valid UTF-16 string requires:
|
||||
/// - High surrogates (0xD800-0xDBFF) must be followed by low surrogates
|
||||
/// - Low surrogates (0xDC00-0xDFFF) must be preceded by high surrogates
|
||||
static String _sanitizeUtf16(String input) {
|
||||
if (input.isEmpty) return input;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (int i = 0; i < input.length; i++) {
|
||||
final int codeUnit = input.codeUnitAt(i);
|
||||
|
||||
// Check if this is a high surrogate (0xD800-0xDBFF)
|
||||
if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) {
|
||||
// Check if next character is a valid low surrogate
|
||||
if (i + 1 < input.length) {
|
||||
final int nextCodeUnit = input.codeUnitAt(i + 1);
|
||||
if (nextCodeUnit >= 0xDC00 && nextCodeUnit <= 0xDFFF) {
|
||||
// Valid surrogate pair - include both
|
||||
buffer.writeCharCode(codeUnit);
|
||||
buffer.writeCharCode(nextCodeUnit);
|
||||
i++; // Skip the low surrogate in next iteration
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Unpaired high surrogate - replace with replacement character
|
||||
buffer.writeCharCode(0xFFFD);
|
||||
} else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) {
|
||||
// Unpaired low surrogate - replace with replacement character
|
||||
buffer.writeCharCode(0xFFFD);
|
||||
} else {
|
||||
// Regular character - include as-is
|
||||
buffer.writeCharCode(codeUnit);
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user