From 40b54cb4882589647b5039ef2d6394eaddf9ffe9 Mon Sep 17 00:00:00 2001 From: DrkXo Date: Thu, 1 Oct 2026 15:24:05 +0530 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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 b587caf7d053e6df292022fabee0028529b4896d Mon Sep 17 00:00:00 2001 From: DrkXo Date: Sun, 4 Oct 2026 00:02:19 +0530 Subject: [PATCH 5/5] fix(selection): split boxes at inline atoms Keep contiguous text highlights merged without spanning replaced inline content. --- .../lib/src/core/render_hyper_box.dart | 159 ------------------ .../src/core/render_hyper_box_selection.dart | 121 +++++++++++++ .../test/boxes_for_char_range_test.dart | 9 +- 3 files changed, 126 insertions(+), 163 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 c2cc097..9d5d00c 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,165 +1786,6 @@ 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. - /// - /// 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),