Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,127 @@ extension RenderHyperBoxSelection on RenderHyperBox {
return rects;
}

/// Returns bounding boxes for characters in `[charStart, charEnd)` within
/// this RenderHyperBox's local coordinate space.
///
/// 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;
}

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;

if (fragmentEnd > charStart && fragmentStart < charEnd) {
final selectStart = math.max(0, charStart - fragmentStart);
final selectEnd = math.min(fragmentLength, charEnd - 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 (fragmentStart <= charStart) {
while (visualStart < visualEnd && text[visualStart] == ' ') {
visualStart++;
}
}
if (fragmentEnd >= charEnd) {
while (visualEnd > visualStart && text[visualEnd - 1] == ' ') {
visualEnd--;
}
}
}

if (visualStart < visualEnd) {
if (fragment.type == FragmentType.ruby) {
final fragmentOffset = fragment.offset ?? Offset.zero;
currentLineRects.add((
rect: Rect.fromLTWH(
fragmentOffset.dx,
line.top,
fragment.width,
line.height,
),
mergeGroup: mergeGroup,
));
} else {
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;
currentLineRects.add((
rect: Rect.fromLTRB(
fragmentOffset.dx + box.left,
line.top,
fragmentOffset.dx + box.right,
line.top + line.height,
),
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;
}

/// Get the rect for start handle
Rect? getStartHandleRect() {
final rects = getSelectionRects();
Expand Down
160 changes: 160 additions & 0 deletions packages/hyper_render_core/test/boxes_for_char_range_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hyper_render_core/hyper_render_core.dart';

const _sampleText = 'The quick brown fox jumps over the lazy dog';

RenderHyperBox? _findBox(RenderObject? root) {
if (root == null) return null;
if (root is RenderHyperBox) return root;
RenderHyperBox? found;
root.visitChildren((child) => found ??= _findBox(child));
return found;
}

Future<RenderHyperBox> _pump(WidgetTester tester, DocumentNode document,
{double width = 300}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: width,
child: HyperRenderWidget(document: document),
),
),
),
);
await tester.pumpAndSettle();
final box = _findBox(
find.byType(HyperRenderWidget).evaluate().first.renderObject,
);
expect(box, isNotNull, reason: 'no RenderHyperBox was built');
return box!;
}

DocumentNode _paragraph(String text) => DocumentNode(children: [
BlockNode.p(children: [TextNode(text)])
]);

void main() {
group('RenderHyperBox.getBoxesForCharRange', () {
testWidgets('returns empty list for inverted or invalid ranges',
(tester) async {
final box = await _pump(tester, _paragraph(_sampleText));
expect(box.getBoxesForCharRange(10, 5), isEmpty);
expect(box.getBoxesForCharRange(5, 5), isEmpty);
});

testWidgets('returns exact bounding box for single word', (tester) async {
final box = await _pump(tester, _paragraph(_sampleText));
// "quick" is at offsets 4..9
final rects = box.getBoxesForCharRange(4, 9);
expect(rects, hasLength(1));
final rect = rects.first;
expect(rect.width, greaterThan(10.0));
expect(rect.height, greaterThan(8.0));
expect(rect.left, greaterThan(0.0));
});

testWidgets('returns contiguous boxes across multiple words on single line',
(tester) async {
final box = await _pump(tester, _paragraph(_sampleText), width: 600);
// "The quick brown" at 0..15
final rects = box.getBoxesForCharRange(0, 15);
expect(rects, hasLength(1));
expect(rects.first.left, closeTo(0.0, 2.0));
expect(rects.first.width, greaterThan(50.0));
});

testWidgets(
'merges mixed-styled inline fragments into a single contiguous rect on the same line',
(tester) async {
// Document with normal text followed by a bold span and normal text on one line:
// "Hello " (normal) + "world" (bold) + " again" (normal)
final doc = DocumentNode(children: [
BlockNode.p(children: [
TextNode('Hello '),
InlineNode.strong(
children: [TextNode('world')],
),
TextNode(' again'),
]),
]);

final box = await _pump(tester, doc, width: 600);
// Range 0..17 covers "Hello world again"
final rects = box.getBoxesForCharRange(0, 17);
expect(
rects,
hasLength(1),
reason:
'mixed-styled fragments on the same line must merge into a single seamless line highlight',
);
expect(rects.first.left, closeTo(0.0, 2.0));
expect(rects.first.top, closeTo(16.0, 4.0));
expect(rects.first.width, greaterThan(200.0));
});

testWidgets('returns boxes across lines when range wraps', (tester) async {
// Narrow container forces wrapping
final box = await _pump(tester, _paragraph(_sampleText), width: 120);
// Full sentence range 0..43
final rects = box.getBoxesForCharRange(0, _sampleText.length);
expect(rects.length, greaterThan(1),
reason: 'should produce rects per line');
});

testWidgets('preserves spaces in white-space:pre fragment', (tester) async {
// A text node carrying white-space:pre directly — internal spaces must NOT
// be trimmed by the isPreformatted guard in getBoxesForCharRange.
// Without the guard, leading spaces at the selection edge would be stripped
// and the returned rect would be narrower (or empty if all spaces).
const preText = ' hello'; // 3 leading spaces + word
final doc = DocumentNode(children: [
BlockNode.p(children: [
TextNode(preText, style: ComputedStyle(whiteSpace: 'pre')),
]),
]);

final box = await _pump(tester, doc, width: 400);
// Range 0..8 covers the entire " hello" text including leading spaces.
final rects = box.getBoxesForCharRange(0, preText.length);
final helloRects = box.getBoxesForCharRange(3, 8);
expect(rects, isNotEmpty,
reason:
'pre-formatted text with leading spaces should produce a rect');
expect(helloRects, hasLength(1));
expect(rects.first.width, greaterThan(helloRects.first.width));
});

testWidgets(
'inline image between two words does not spuriously merge word rects',
(tester) async {
// "hello " + 16px image + " world" — the maxGap heuristic introduced in
// f5a313b must not bridge the gap caused by the image column and collapse
// two separate word rects into one.
final doc = DocumentNode(children: [
BlockNode.p(children: [
TextNode('hello '),
AtomicNode.img(
src: 'https://example.com/1x1.png',
width: 16,
height: 16,
),
TextNode(' world'),
]),
]);

final box = await _pump(tester, doc, width: 600);
// Char range 0..12 covers the logical text "hello world" (image is not
// a text character, so "hello " is 6 chars and " world" is 6 chars).
final rects = box.getBoxesForCharRange(0, 12);
expect(rects, hasLength(2));
// Each rect must have non-trivial width — not a collapsed zero-width rect.
for (final r in rects) {
expect(r.width, greaterThan(10.0),
reason: 'each word rect should have real width');
}
});
});
}
Loading