From 40b54cb4882589647b5039ef2d6394eaddf9ffe9 Mon Sep 17 00:00:00 2001 From: DrkXo Date: Thu, 1 Oct 2026 15:24:05 +0530 Subject: [PATCH 1/6] feat(core): add getBoxesForCharRange to RenderHyperBox for character-level highlight bounds --- .../lib/src/core/render_hyper_box.dart | 155 ++++++++++++++++++ .../test/boxes_for_char_range_test.dart | 73 +++++++++ 2 files changed, 228 insertions(+) create mode 100644 packages/hyper_render_core/test/boxes_for_char_range_test.dart diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart index 9d5d00c..f7f5f61 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart @@ -1786,6 +1786,161 @@ 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 two fragment fields that [debugFragments] omits: + /// [Fragment.rubyText], the reading drawn above the base text, which differs + /// from the base characters in content and in length; and + /// [Fragment.ellipsisVisibleLength], the clamp limiting how much of a + /// truncated fragment reached the screen. Without the latter a consumer cannot + /// tell that the remaining characters were never painted. + List> debugLineFragments() { + final result = >[]; + 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; + result.add({ + 'type': fragment.type.name, + 'text': text, + 'rubyText': fragment.rubyText, + 'charStart': fragment.globalOffset, + 'charEnd': fragment.globalOffset + (text?.length ?? 0), + '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; + } + + /// Returns exact pixel-snapped bounding boxes for characters in `[charStart, charEnd)` + /// within this RenderHyperBox's local coordinate space. + /// + /// This computes exact glyph boundaries using [TextPainter.getBoxesForSelection] + /// with [ui.BoxHeightStyle.tight], matching the precision used by text selection. + /// Adjacent boxes on the same line are merged to produce clean contiguous highlight rects. + List getBoxesForCharRange(int charStart, int charEnd) { + if (charEnd <= charStart || _lines.isEmpty) return const []; + final rects = []; + + for (final line in _lines) { + final currentLineRects = []; + + 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 the range + 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) { + while (visualStart < visualEnd && text[visualStart] == ' ') { + visualStart++; + } + while (visualEnd > visualStart && text[visualEnd - 1] == ' ') { + visualEnd--; + } + } + + if (visualStart < visualEnd) { + if (fragment.type == FragmentType.ruby) { + final fragmentOffset = fragment.offset ?? Offset.zero; + currentLineRects.add(Rect.fromLTWH( + fragmentOffset.dx, + fragmentOffset.dy, + fragment.width, + fragment.height, + )); + } 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.fromLTRB( + fragmentOffset.dx + box.left, + fragmentOffset.dy + box.top, + fragmentOffset.dx + box.right, + fragmentOffset.dy + box.bottom, + )); + } + } + } + } + } + } + + if (currentLineRects.isEmpty) continue; + + // Merge horizontally contiguous rects on the same line + currentLineRects.sort((a, b) => a.left.compareTo(b.left)); + Rect current = currentLineRects.first; + for (var i = 1; i < currentLineRects.length; i++) { + final next = currentLineRects[i]; + if (next.left <= current.right + 2.0 && + (next.top - current.top).abs() <= 3.0 && + (next.bottom - current.bottom).abs() <= 3.0) { + current = Rect.fromLTRB( + current.left, + math.min(current.top, next.top), + math.max(current.right, next.right), + math.max(current.bottom, next.bottom), + ); + } else { + rects.add(current); + current = next; + } + } + rects.add(current); + } + + return rects; + } + Map _serializeNode(UDTNode node) { return { 'id': node.id, diff --git a/packages/hyper_render_core/test/boxes_for_char_range_test.dart b/packages/hyper_render_core/test/boxes_for_char_range_test.dart new file mode 100644 index 0000000..8a0fd07 --- /dev/null +++ b/packages/hyper_render_core/test/boxes_for_char_range_test.dart @@ -0,0 +1,73 @@ +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 _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('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'); + }); + }); +} From 026e2266b4ecee77fe42860a4270c62f1e182324 Mon Sep 17 00:00:00 2001 From: DrkXo Date: Thu, 1 Oct 2026 16:57:41 +0530 Subject: [PATCH 2/6] fix(core): bridge inter-word gaps and preserve internal spaces in getBoxesForCharRange --- .../lib/src/core/render_hyper_box.dart | 17 ++++--- .../test/boxes_for_char_range_test.dart | 46 ++++++++++++++++--- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart index f7f5f61..b59d5ff 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart @@ -1870,11 +1870,15 @@ class RenderHyperBox extends RenderBox final isPreformatted = ws == 'pre' || ws == 'pre-wrap' || ws == 'break-spaces'; if (!isPreformatted) { - while (visualStart < visualEnd && text[visualStart] == ' ') { - visualStart++; + if (fragmentStart <= charStart) { + while (visualStart < visualEnd && text[visualStart] == ' ') { + visualStart++; + } } - while (visualEnd > visualStart && text[visualEnd - 1] == ' ') { - visualEnd--; + if (fragmentEnd >= charEnd) { + while (visualEnd > visualStart && text[visualEnd - 1] == ' ') { + visualEnd--; + } } } @@ -1919,11 +1923,10 @@ class RenderHyperBox extends RenderBox // Merge horizontally contiguous rects on the same line currentLineRects.sort((a, b) => a.left.compareTo(b.left)); Rect current = currentLineRects.first; + final maxGap = math.max(16.0, line.height); for (var i = 1; i < currentLineRects.length; i++) { final next = currentLineRects[i]; - if (next.left <= current.right + 2.0 && - (next.top - current.top).abs() <= 3.0 && - (next.bottom - current.bottom).abs() <= 3.0) { + if (next.left <= current.right + maxGap) { current = Rect.fromLTRB( current.left, math.min(current.top, next.top), diff --git a/packages/hyper_render_core/test/boxes_for_char_range_test.dart b/packages/hyper_render_core/test/boxes_for_char_range_test.dart index 8a0fd07..2295a51 100644 --- a/packages/hyper_render_core/test/boxes_for_char_range_test.dart +++ b/packages/hyper_render_core/test/boxes_for_char_range_test.dart @@ -12,7 +12,8 @@ RenderHyperBox? _findBox(RenderObject? root) { return found; } -Future _pump(WidgetTester tester, DocumentNode document, {double width = 300}) async { +Future _pump(WidgetTester tester, DocumentNode document, + {double width = 300}) async { await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -31,12 +32,14 @@ Future _pump(WidgetTester tester, DocumentNode document, {double return box!; } -DocumentNode _paragraph(String text) => - DocumentNode(children: [BlockNode.p(children: [TextNode(text)])]); +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 { + 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); @@ -53,7 +56,8 @@ void main() { expect(rect.left, greaterThan(0.0)); }); - testWidgets('returns contiguous boxes across multiple words on single line', (tester) async { + 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); @@ -62,12 +66,42 @@ void main() { 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'); + expect(rects.length, greaterThan(1), + reason: 'should produce rects per line'); }); }); } From 235a1486afe2dc876852ebb910abddab4a6071ee Mon Sep 17 00:00:00 2001 From: DrkXo Date: Thu, 1 Oct 2026 18:37:24 +0530 Subject: [PATCH 3/6] fix(core): use line top and height for highlight bounding boxes --- .../hyper_render_core/lib/src/core/render_hyper_box.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart index b59d5ff..2a28b09 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart @@ -1887,9 +1887,9 @@ class RenderHyperBox extends RenderBox final fragmentOffset = fragment.offset ?? Offset.zero; currentLineRects.add(Rect.fromLTWH( fragmentOffset.dx, - fragmentOffset.dy, + line.top, fragment.width, - fragment.height, + line.height, )); } else { final painter = @@ -1907,9 +1907,9 @@ class RenderHyperBox extends RenderBox if (box.right <= box.left) continue; currentLineRects.add(Rect.fromLTRB( fragmentOffset.dx + box.left, - fragmentOffset.dy + box.top, + line.top, fragmentOffset.dx + box.right, - fragmentOffset.dy + box.bottom, + line.top + line.height, )); } } From b550a38458b4f12f3dbecc34238e1df09908c452 Mon Sep 17 00:00:00 2001 From: DrkXo Date: Fri, 2 Oct 2026 20:44:43 +0530 Subject: [PATCH 4/6] fix(core): pass textScaler, fix doc comment, add pre/image tests for getBoxesForCharRange --- .../lib/src/core/render_hyper_box.dart | 5 +- .../test/boxes_for_char_range_test.dart | 52 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart index 2a28b09..c2cc097 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart @@ -1840,8 +1840,9 @@ class RenderHyperBox extends RenderBox /// Returns exact pixel-snapped bounding boxes for characters in `[charStart, charEnd)` /// within this RenderHyperBox's local coordinate space. /// - /// This computes exact glyph boundaries using [TextPainter.getBoxesForSelection] - /// with [ui.BoxHeightStyle.tight], matching the precision used by text selection. + /// Glyph x-boundaries come from [TextPainter.getBoxesForSelection] with + /// [ui.BoxHeightStyle.tight]; y-boundaries use the line's [top] and [height] + /// so highlight rows align with the rendered line grid. /// Adjacent boxes on the same line are merged to produce clean contiguous highlight rects. List getBoxesForCharRange(int charStart, int charEnd) { if (charEnd <= charStart || _lines.isEmpty) return const []; diff --git a/packages/hyper_render_core/test/boxes_for_char_range_test.dart b/packages/hyper_render_core/test/boxes_for_char_range_test.dart index 2295a51..48153f9 100644 --- a/packages/hyper_render_core/test/boxes_for_char_range_test.dart +++ b/packages/hyper_render_core/test/boxes_for_char_range_test.dart @@ -103,5 +103,57 @@ void main() { 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); + expect(rects, isNotEmpty, + reason: + 'pre-formatted text with leading spaces should produce a rect'); + // The rect must include the space width — wider than just "hello". + expect(rects.first.width, greaterThan(20.0)); + }); + + 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..11 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, isNotEmpty); + // 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'); + } + }); }); } From aded2c5a2b4fa34045ef00b1f84edfac0ad1b681 Mon Sep 17 00:00:00 2001 From: DrkXo Date: Sun, 4 Oct 2026 00:02:19 +0530 Subject: [PATCH 5/6] fix(selection): preserve atom splits and debugLineFragments --- .../lib/src/core/render_hyper_box.dart | 117 +----------- .../src/core/render_hyper_box_selection.dart | 121 ++++++++++++ .../test/boxes_for_char_range_test.dart | 9 +- .../test/debug_line_fragments_test.dart | 176 ++++++++++++++++++ 4 files changed, 305 insertions(+), 118 deletions(-) create mode 100644 packages/hyper_render_core/test/debug_line_fragments_test.dart diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart index c2cc097..037c4ca 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart @@ -1801,12 +1801,9 @@ class RenderHyperBox extends RenderBox /// have no rect. Consumers must tolerate gaps rather than assume that one /// fragment's `charEnd` is the next fragment's `charStart`. /// - /// This also exposes two fragment fields that [debugFragments] omits: - /// [Fragment.rubyText], the reading drawn above the base text, which differs - /// from the base characters in content and in length; and - /// [Fragment.ellipsisVisibleLength], the clamp limiting how much of a - /// truncated fragment reached the screen. Without the latter a consumer cannot - /// tell that the remaining characters were never painted. + /// 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> debugLineFragments() { final result = >[]; for (var lineIndex = 0; lineIndex < _lines.length; lineIndex++) { @@ -1837,114 +1834,6 @@ class RenderHyperBox extends RenderBox return result; } - /// Returns exact pixel-snapped 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]; y-boundaries use the line's [top] and [height] - /// so highlight rows align with the rendered line grid. - /// Adjacent boxes on the same line are merged to produce clean contiguous highlight rects. - List getBoxesForCharRange(int charStart, int charEnd) { - if (charEnd <= charStart || _lines.isEmpty) return const []; - final rects = []; - - for (final line in _lines) { - final currentLineRects = []; - - 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 the range - 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.fromLTWH( - fragmentOffset.dx, - line.top, - fragment.width, - line.height, - )); - } 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.fromLTRB( - fragmentOffset.dx + box.left, - line.top, - fragmentOffset.dx + box.right, - line.top + line.height, - )); - } - } - } - } - } - } - - if (currentLineRects.isEmpty) continue; - - // Merge horizontally contiguous rects on the same line - currentLineRects.sort((a, b) => a.left.compareTo(b.left)); - Rect current = currentLineRects.first; - final maxGap = math.max(16.0, line.height); - for (var i = 1; i < currentLineRects.length; i++) { - final next = currentLineRects[i]; - if (next.left <= current.right + maxGap) { - current = Rect.fromLTRB( - current.left, - math.min(current.top, next.top), - math.max(current.right, next.right), - math.max(current.bottom, next.bottom), - ); - } else { - rects.add(current); - current = next; - } - } - rects.add(current); - } - - return rects; - } - Map _serializeNode(UDTNode node) { return { 'id': node.id, diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_selection.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_selection.dart index bb1a1c2..026f725 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box_selection.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_selection.dart @@ -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 getBoxesForCharRange(int charStart, int charEnd) { + if (charEnd <= charStart || _lines.isEmpty) return const []; + final rects = []; + + 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(); diff --git a/packages/hyper_render_core/test/boxes_for_char_range_test.dart b/packages/hyper_render_core/test/boxes_for_char_range_test.dart index 48153f9..d201c0d 100644 --- a/packages/hyper_render_core/test/boxes_for_char_range_test.dart +++ b/packages/hyper_render_core/test/boxes_for_char_range_test.dart @@ -119,11 +119,12 @@ void main() { 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'); - // The rect must include the space width — wider than just "hello". - expect(rects.first.width, greaterThan(20.0)); + expect(helloRects, hasLength(1)); + expect(rects.first.width, greaterThan(helloRects.first.width)); }); testWidgets( @@ -145,10 +146,10 @@ void main() { ]); final box = await _pump(tester, doc, width: 600); - // Char range 0..11 covers the logical text "hello world" (image is not + // 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, isNotEmpty); + 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), diff --git a/packages/hyper_render_core/test/debug_line_fragments_test.dart b/packages/hyper_render_core/test/debug_line_fragments_test.dart new file mode 100644 index 0000000..939880a --- /dev/null +++ b/packages/hyper_render_core/test/debug_line_fragments_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render_core/hyper_render_core.dart'; + +/// Long enough to wrap several times at the widths used below. +const _longText = + 'The quick brown fox jumps over the lazy dog while the reader turns ' + 'another page of a long chapter without pausing to consider anything.'; + +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 _pump(WidgetTester tester, DocumentNode document) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + 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('debugLineFragments', () { + testWidgets('reports a positioned fragment for every line', ( + tester, + ) async { + final box = await _pump(tester, _paragraph(_longText)); + + final lines = box.debugLines(); + expect(lines.length, greaterThan(1), reason: 'the text must wrap'); + + final lineFragments = box.debugLineFragments(); + + final expected = lines.fold( + 0, + (sum, line) => sum + (line['fragmentCount'] as int), + ); + expect(lineFragments.length, expected); + + for (final fragment in lineFragments) { + expect(fragment['offsetX'], isA()); + expect(fragment['offsetY'], isA()); + expect(fragment['width'], isA()); + expect(fragment['charStart'], isA()); + expect(fragment['charEnd'], isA()); + } + }); + + testWidgets('reports monotonically ordered character ranges', ( + tester, + ) async { + final box = await _pump(tester, _paragraph(_longText)); + final fragments = box.debugLineFragments(); + expect(fragments, isNotEmpty); + + for (var index = 1; index < fragments.length; index++) { + expect( + fragments[index]['charStart'] as int, + greaterThanOrEqualTo(fragments[index - 1]['charStart'] as int), + ); + } + + final joined = + fragments.map((fragment) => fragment['text'] as String? ?? '').join(); + String stripWhitespace(String text) => + text.replaceAll(RegExp(r'\s+'), ''); + expect(stripWhitespace(joined), stripWhitespace(_longText)); + expect(fragments.last['charEnd'], box.totalCharacterCount); + }); + + testWidgets('agrees with debugLines on line bounds and index', ( + tester, + ) async { + final box = await _pump(tester, _paragraph(_longText)); + final lines = box.debugLines(); + final fragments = box.debugLineFragments(); + + final byIndex = >>{}; + for (final fragment in fragments) { + byIndex + .putIfAbsent(fragment['lineIndex'] as int, () => []) + .add(fragment); + } + + for (var index = 0; index < lines.length; index++) { + final inLine = byIndex[index]; + if (inLine == null) continue; + for (final fragment in inLine) { + expect(fragment['lineTop'], lines[index]['top']); + expect(fragment['lineHeight'], lines[index]['height']); + } + } + }); + + testWidgets('reports rubyText with the positioned base text', ( + tester, + ) async { + final document = DocumentNode( + children: [ + BlockNode.p( + children: [RubyNode(baseText: '漢字', rubyText: 'かんじ')], + ), + ], + ); + final box = await _pump(tester, document); + + final ruby = box + .debugLineFragments() + .where((fragment) => fragment['type'] == 'ruby') + .toList(); + expect(ruby, isNotEmpty); + expect(ruby.first['rubyText'], 'かんじ'); + expect(ruby.first['text'], '漢字'); + + final debugRuby = box + .debugFragments() + .where((fragment) => fragment['type'] == 'ruby') + .toList(); + expect(debugRuby, isNotEmpty); + expect(debugRuby.first['rubyText'], 'かんじ'); + }); + + testWidgets('includes ellipsisVisibleLength for every fragment', ( + tester, + ) async { + final box = await _pump(tester, _paragraph(_longText)); + for (final fragment in box.debugLineFragments()) { + expect(fragment.containsKey('ellipsisVisibleLength'), isTrue); + } + }); + }); + + group('debugFragments geometry for wrapped text', () { + testWidgets('remains unpositioned while line fragments have geometry', ( + tester, + ) async { + final box = await _pump(tester, _paragraph(_longText)); + expect(box.debugLines().length, greaterThan(1)); + + final textFragments = box + .debugFragments() + .where((fragment) => (fragment['text'] as String? ?? '').isNotEmpty) + .toList(); + expect(textFragments, isNotEmpty); + expect( + textFragments.every((fragment) => fragment['offsetX'] == null), + isTrue, + ); + expect( + box + .debugLineFragments() + .every((fragment) => fragment['offsetX'] is double), + isTrue, + ); + }); + }); +} From 5a151ff851d115eb29305b5d8447eee2b128d1cc Mon Sep 17 00:00:00 2001 From: DrkXo Date: Wed, 7 Oct 2026 11:23:53 +0530 Subject: [PATCH 6/6] fix(core): handle ellipsisVisibleLength in line fragments and bounds - Set ellipsisVisibleLength on truncFrag and ellipsisFrag during layout - Use ellipsisVisibleLength in debugLineFragments to exclude ellipsis glyph - Unify getSelectionRects and getBoxesForCharRange via shared helper - Document RenderHyperBox coordinate locality in getBoxesForCharRange - Drop stale commit hash in boxes_for_char_range_test - Add regression test for truncated text bounds at narrow width Refs #19 --- .../lib/src/core/render_hyper_box.dart | 4 +- .../lib/src/core/render_hyper_box_layout.dart | 11 +- .../src/core/render_hyper_box_selection.dart | 241 ++++++++---------- .../test/boxes_for_char_range_test.dart | 6 +- test/review_fixes_v1_3_2_test.dart | 47 ++++ 5 files changed, 167 insertions(+), 142 deletions(-) diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart index 037c4ca..c21e5d1 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart @@ -1812,12 +1812,14 @@ class RenderHyperBox extends RenderBox final rect = fragment.rect; if (rect == null) continue; final text = fragment.text; + final charLength = + fragment.ellipsisVisibleLength ?? (text?.length ?? 0); result.add({ 'type': fragment.type.name, 'text': text, 'rubyText': fragment.rubyText, 'charStart': fragment.globalOffset, - 'charEnd': fragment.globalOffset + (text?.length ?? 0), + 'charEnd': fragment.globalOffset + charLength, 'ellipsisVisibleLength': fragment.ellipsisVisibleLength, 'lineIndex': lineIndex, 'lineTop': line.top, diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart index d01f115..1964bea 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart @@ -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); @@ -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); diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_selection.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_selection.dart index 026f725..86e3cc3 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box_selection.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_selection.dart @@ -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 _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 = []; + 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 getSelectionRects() { if (_selection == null || !_selection!.isValid || _selection!.isCollapsed) { @@ -229,72 +316,11 @@ 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, + )); } } @@ -304,6 +330,9 @@ extension RenderHyperBoxSelection on RenderHyperBox { /// 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 @@ -322,75 +351,17 @@ extension RenderHyperBoxSelection on RenderHyperBox { 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, - )); - } - } - } - } + 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)); } } diff --git a/packages/hyper_render_core/test/boxes_for_char_range_test.dart b/packages/hyper_render_core/test/boxes_for_char_range_test.dart index d201c0d..d589af6 100644 --- a/packages/hyper_render_core/test/boxes_for_char_range_test.dart +++ b/packages/hyper_render_core/test/boxes_for_char_range_test.dart @@ -130,9 +130,9 @@ void main() { 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. + // "hello " + 16px image + " world" — the maxGap heuristic 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 '), diff --git a/test/review_fixes_v1_3_2_test.dart b/test/review_fixes_v1_3_2_test.dart index c66ca6d..c10e4a8 100644 --- a/test/review_fixes_v1_3_2_test.dart +++ b/test/review_fixes_v1_3_2_test.dart @@ -159,6 +159,53 @@ void main() { }, ); + testWidgets( + '#5 — debugLineFragments and getBoxesForCharRange respect ellipsisVisibleLength', + (tester) async { + const html = '

x

' + 'visible head SECRET TAIL
'; + + final key = GlobalKey(); + final doc = _parseAndResolve(html); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 80, + height: 200, + child: HyperSelectionOverlay(key: key, document: doc), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final box = tester.renderObject( + find.byType(HyperRenderWidget), + ); + + final lineFragments = box.debugLineFragments(); + final truncLineFrag = lineFragments.firstWhere( + (f) => (f['text'] as String? ?? '').endsWith('\u2026'), + ); + + expect(truncLineFrag['ellipsisVisibleLength'], 4); + expect(truncLineFrag['charStart'], 1); + expect(truncLineFrag['charEnd'], 5); + + // Char 5 is 'b' (the 5th character of "visible", 0-indexed offset 5 + // in the document where 'x' is 0, 'v' is 1, 'i' is 2, 's' is 3, 'i' is 4). + // It is clipped behind the ellipsis glyph, so getBoxesForCharRange(5, 6) + // must not return any bounding box. + expect(box.getBoxesForCharRange(5, 6), isEmpty); + + // Visible chars 1..5 ('visi') return non-empty boxes. + expect(box.getBoxesForCharRange(1, 5), isNotEmpty); + }, + ); + testWidgets( '#6 — selection drag above first line snaps to start, not -1', (tester) async {