Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/hyper_render_core/lib/src/core/render_hyper_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1786,6 +1786,56 @@ class RenderHyperBox extends RenderBox
}).toList();
}

/// Serialize the fragments of every laid-out line, in layout order.
///
/// [debugFragments] reports the tokenizer output. When a text node wraps, the
/// pieces it was split into are new fragments that the layout positions but
/// never adds back to the tokenizer's list, so [debugFragments] reports the
/// original fragment — unpositioned — for text that was in fact laid out and
/// painted. This method walks the lines instead, so every entry carries the
/// position the renderer actually used and the [Fragment.globalOffset] that
/// the selection and IME character space already works in.
///
/// Character ranges are non-contiguous by design. Whitespace trimmed at a
/// wrap belongs to no fragment, so a range may land in a gap and correctly
/// have no rect. Consumers must tolerate gaps rather than assume that one
/// fragment's `charEnd` is the next fragment's `charStart`.
///
/// This also exposes [Fragment.ellipsisVisibleLength] separately from the
/// derived character length in [debugFragments], so consumers can tell how
/// much of a truncated fragment reached the screen.
List<Map<String, dynamic>> debugLineFragments() {
final result = <Map<String, dynamic>>[];
for (var lineIndex = 0; lineIndex < _lines.length; lineIndex++) {
final line = _lines[lineIndex];
for (final fragment in line.fragments) {
final rect = fragment.rect;
if (rect == null) continue;
final text = fragment.text;
final charLength =
fragment.ellipsisVisibleLength ?? (text?.length ?? 0);
result.add(<String, dynamic>{
'type': fragment.type.name,
'text': text,
'rubyText': fragment.rubyText,
'charStart': fragment.globalOffset,
'charEnd': fragment.globalOffset + charLength,
'ellipsisVisibleLength': fragment.ellipsisVisibleLength,
'lineIndex': lineIndex,
'lineTop': line.top,
'lineHeight': line.height,
'offsetX': rect.left,
'offsetY': rect.top,
'width': rect.width,
'height': rect.height,
'nodeId': fragment.sourceNode.id,
'nodeTag': fragment.sourceNode.tagName,
});
}
}
return result;
}

Map<String, dynamic> _serializeNode(UDTNode node) {
return {
'id': node.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1314,7 +1314,9 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
sourceNode: fragment.sourceNode,
style: fragment.style,
characterOffset: fragment.characterOffset,
)..globalOffset = fragment.globalOffset;
)
..globalOffset = fragment.globalOffset
..ellipsisVisibleLength = clippedText.length;
_measureFragment(truncFrag);
truncFrag.offset = Offset(currentX, currentY);
currentLineFragments.add(truncFrag);
Expand All @@ -1325,14 +1327,17 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
fragment.ellipsisVisibleLength = clippedText.length;
}
}
} else if (currentLineFragments.isEmpty) {
}
if (currentLineFragments.isEmpty) {
// Not even enough room for ellipsis alone — just show ellipsis
final ellipsisFrag = Fragment.text(
text: ellipsisChar,
sourceNode: fragment.sourceNode,
style: fragment.style,
characterOffset: fragment.characterOffset,
)..globalOffset = fragment.globalOffset;
)
..globalOffset = fragment.globalOffset
..ellipsisVisibleLength = 0;
_measureFragment(ellipsisFrag);
ellipsisFrag.offset = Offset(currentX, currentY);
currentLineFragments.add(ellipsisFrag);
Expand Down
222 changes: 157 additions & 65 deletions packages/hyper_render_core/lib/src/core/render_hyper_box_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,93 @@ extension RenderHyperBoxSelection on RenderHyperBox {
}
}

/// Computes bounding boxes for a single text or ruby [fragment] overlapping
/// `[rangeStart, rangeEnd)`.
///
/// When [lineTop] and [lineHeight] are provided (as in [getBoxesForCharRange]),
/// the returned rects span the full vertical line grid. When omitted (as in
/// [getSelectionRects]), boxes use tight glyph height relative to the fragment offset.
List<Rect> _getFragmentBoxesForRange({
required Fragment fragment,
required int rangeStart,
required int rangeEnd,
double? lineTop,
double? lineHeight,
bool trimBoundarySpacesOnly = false,
}) {
if ((fragment.type != FragmentType.text &&
fragment.type != FragmentType.ruby) ||
fragment.text == null) {
return const [];
}

final fragmentLength =
fragment.ellipsisVisibleLength ?? fragment.text!.length;
if (fragmentLength == 0) return const [];
final fragmentStart = fragment.globalOffset;
final fragmentEnd = fragmentStart + fragmentLength;

if (fragmentEnd <= rangeStart || fragmentStart >= rangeEnd) {
return const [];
}

final selectStart = math.max(0, rangeStart - fragmentStart);
final selectEnd = math.min(fragmentLength, rangeEnd - fragmentStart);

final text = fragment.text!;
int visualStart = selectStart;
int visualEnd = selectEnd;
final ws = fragment.style.whiteSpace;
final isPreformatted =
ws == 'pre' || ws == 'pre-wrap' || ws == 'break-spaces';
if (!isPreformatted) {
if (!trimBoundarySpacesOnly || fragmentStart <= rangeStart) {
while (visualStart < visualEnd && text[visualStart] == ' ') {
visualStart++;
}
}
if (!trimBoundarySpacesOnly || fragmentEnd >= rangeEnd) {
while (visualEnd > visualStart && text[visualEnd - 1] == ' ') {
visualEnd--;
}
}
}

if (visualStart >= visualEnd) return const [];

final fragmentOffset = fragment.offset ?? Offset.zero;
final useLineHeight = lineTop != null && lineHeight != null;

if (fragment.type == FragmentType.ruby) {
return [
Rect.fromLTWH(
fragmentOffset.dx,
useLineHeight ? lineTop : fragmentOffset.dy,
fragment.width,
useLineHeight ? lineHeight : fragment.height,
),
];
}

final painter = _getTextPainter(text, _effectiveFragmentStyle(fragment));
final boxes = painter.getBoxesForSelection(
TextSelection(baseOffset: visualStart, extentOffset: visualEnd),
boxHeightStyle: ui.BoxHeightStyle.tight,
);

final result = <Rect>[];
for (final box in boxes) {
if (box.right <= box.left) continue;
result.add(Rect.fromLTRB(
fragmentOffset.dx + box.left,
useLineHeight ? lineTop : fragmentOffset.dy + box.top,
fragmentOffset.dx + box.right,
useLineHeight ? lineTop + lineHeight : fragmentOffset.dy + box.bottom,
));
}
return result;
}

/// Get selection rects for rendering handles
List<Rect> getSelectionRects() {
if (_selection == null || !_selection!.isValid || _selection!.isCollapsed) {
Expand All @@ -229,73 +316,78 @@ extension RenderHyperBoxSelection on RenderHyperBox {

for (final line in _lines) {
for (final fragment in line.fragments) {
if ((fragment.type == FragmentType.text ||
fragment.type == FragmentType.ruby) &&
fragment.text != null) {
final fragmentLength = fragment.text!.length;
final fragmentStart = fragment.globalOffset;
final fragmentEnd = fragmentStart + fragmentLength;

// Check if this fragment overlaps with selection
if (fragmentEnd > _selection!.start &&
fragmentStart < _selection!.end) {
final selectStart = math.max(0, _selection!.start - fragmentStart);
final selectEnd =
math.min(fragmentLength, _selection!.end - fragmentStart);

// Trim trailing/leading spaces for visual bounds, but preserve them
// in preformatted contexts where indentation is meaningful.
final text = fragment.text!;
int visualStart = selectStart;
int visualEnd = selectEnd;
final ws = fragment.style.whiteSpace;
final isPreformatted =
ws == 'pre' || ws == 'pre-wrap' || ws == 'break-spaces';
if (!isPreformatted) {
while (visualStart < visualEnd && text[visualStart] == ' ') {
visualStart++;
}
while (visualEnd > visualStart && text[visualEnd - 1] == ' ') {
visualEnd--;
}
}

if (visualStart < visualEnd) {
if (fragment.type == FragmentType.ruby) {
// Ruby selection highlight covers the whole fragment rect
final fragmentOffset = fragment.offset ?? Offset.zero;
rects.add(Rect.fromLTWH(
fragmentOffset.dx,
fragmentOffset.dy,
fragment.width,
fragment.height,
));
} else {
// Effective style so justified selection boxes line up with
// the widened glyphs.
final painter =
_getTextPainter(text, _effectiveFragmentStyle(fragment));
final boxes = painter.getBoxesForSelection(
TextSelection(
baseOffset: visualStart, extentOffset: visualEnd),
boxHeightStyle: ui.BoxHeightStyle.tight,
);

final fragmentOffset = fragment.offset ?? Offset.zero;
for (final box in boxes) {
if (box.right <= box.left) continue;
rects.add(Rect.fromLTRB(
fragmentOffset.dx + box.left,
fragmentOffset.dy + box.top,
fragmentOffset.dx + box.right,
fragmentOffset.dy + box.bottom,
));
}
}
}
}
rects.addAll(_getFragmentBoxesForRange(
fragment: fragment,
rangeStart: _selection!.start,
rangeEnd: _selection!.end,
));
}
}

return rects;
}

/// Returns bounding boxes for characters in `[charStart, charEnd)` within
/// this RenderHyperBox's local coordinate space.
///
/// Offsets are local to this [RenderHyperBox]. In virtualized/auto mode
/// (>10k chars), each chunk has its own box, and offsets restart at 0.
///
/// Glyph x-boundaries come from [TextPainter.getBoxesForSelection] with
/// [ui.BoxHeightStyle.tight]; vertical bounds use `line.top` and
/// `line.height` to align highlights with the rendered line grid. Adjacent
/// text boxes merge unless an atomic fragment separates them.
List<Rect> getBoxesForCharRange(int charStart, int charEnd) {
if (charEnd <= charStart || _lines.isEmpty) return const [];
final rects = <Rect>[];

for (final line in _lines) {
final currentLineRects = <({Rect rect, int mergeGroup})>[];
var mergeGroup = 0;

for (final fragment in line.fragments) {
if (fragment.type == FragmentType.atomic) {
mergeGroup++;
continue;
}

final fragmentBoxes = _getFragmentBoxesForRange(
fragment: fragment,
rangeStart: charStart,
rangeEnd: charEnd,
lineTop: line.top,
lineHeight: line.height,
trimBoundarySpacesOnly: true,
);

for (final box in fragmentBoxes) {
currentLineRects.add((rect: box, mergeGroup: mergeGroup));
}
}

if (currentLineRects.isEmpty) continue;

currentLineRects.sort((a, b) => a.rect.left.compareTo(b.rect.left));
var current = currentLineRects.first.rect;
var currentGroup = currentLineRects.first.mergeGroup;
final maxGap = math.max(16.0, line.height);
for (var i = 1; i < currentLineRects.length; i++) {
final next = currentLineRects[i];
if (next.mergeGroup == currentGroup &&
next.rect.left <= current.right + maxGap) {
current = Rect.fromLTRB(
current.left,
math.min(current.top, next.rect.top),
math.max(current.right, next.rect.right),
math.max(current.bottom, next.rect.bottom),
);
} else {
rects.add(current);
current = next.rect;
currentGroup = next.mergeGroup;
}
}
rects.add(current);
}

return rects;
Expand Down
Loading
Loading