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..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 @@ -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> 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; + final charLength = + fragment.ellipsisVisibleLength ?? (text?.length ?? 0); + result.add({ + '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 _serializeNode(UDTNode node) { return { 'id': node.id, 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 bb1a1c2..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,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 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; + } + + 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; 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..d589af6 --- /dev/null +++ b/packages/hyper_render_core/test/boxes_for_char_range_test.dart @@ -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 _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 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'); + } + }); + }); +} 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, + ); + }); + }); +} 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 {