diff --git a/CHANGELOG.md b/CHANGELOG.md index b29705e..629c37c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.14.0 + +- **`box-sizing`** now applies ([#33](https://github.com/brewkits/hyper_render/issues/33)): `border-box` includes padding and border in `width` / `min-width` / `max-width`, including `%`, on in-flow blocks. Limits are in `doc/LIMITATIONS.md`. +- **`position: relative`** now applies ([#34](https://github.com/brewkits/hyper_render/issues/34)): `top` / `right` / `bottom` / `left` (lengths) move blocks, inline spans and flex/grid items without affecting layout, and hit-testing and selection follow. Moved content paints above normal content and is clipped to the render box, so untrusted HTML can't paint over the host app. Other content stays unclipped. +- **Rendering changes:** + - A block's background and border now follow its `width`. They used to span the whole container. + - `min-width: %` now applies on blocks. + - A first block styled only with a background or border now paints it. + - Gradients on canvas blocks and inline spans now paint. They painted nothing. + - A `
` toggle no longer leaves a block's old background behind. + - `HyperViewer(textDirection:)` and an ambient RTL `Directionality` now apply to short documents in sync mode. Virtualized and paged modes already honoured them. +- **Requires `hyper_render_core` `^1.14.0`.** + ## 1.13.1 Packaging and documentation only; no code changes in the library. diff --git a/doc/CSS_PROPERTIES_MATRIX.md b/doc/CSS_PROPERTIES_MATRIX.md index c751aa1..3654418 100644 --- a/doc/CSS_PROPERTIES_MATRIX.md +++ b/doc/CSS_PROPERTIES_MATRIX.md @@ -45,7 +45,7 @@ This document lists CSS property support in HyperRender. | `border-style` | ✅ | solid, dashed, dotted, double, none | Custom styles beyond Flutter defaults | | `border-color` | ✅ | All color formats | | | `border-radius` | ✅ | px, % | All 4 corners + shorthand | -| `box-sizing` | ❌ | — | Parsed, not applied: `width` always sizes the content box, so `border-box` is ignored (measured: `width:200px;padding:20px` wraps at 200px with or without it) | +| `box-sizing` | ⚠️ | content-box, border-box | Applied to `width` / `min-width` / `max-width`, including `%`, on in-flow blocks. Not applied to flex/grid containers, tables or images. Borders don't reserve layout space, so text can overlap a border. See [LIMITATIONS.md](LIMITATIONS.md) | | `aspect-ratio` | ✅ | W/H, bare number, auto | Applied to img/video sizing (v1.4.0) | --- @@ -59,7 +59,7 @@ This document lists CSS property support in HyperRender. | `opacity` | ✅ | 0-1 | | | `overflow` | ⚠️ | hidden, visible | No scroll support | | `position: static` | ✅ | static | Default | -| `position: relative` | ❌ | — | Parsed, not applied: `top` / `left` offsets do not move the element | +| `position: relative` | ⚠️ | top, right, bottom, left (lengths) | Moves blocks, inline spans, and flex/grid items; layout, hit-testing and selection follow. No `%` offsets. Moved content is clipped to the render box. See [LIMITATIONS.md](LIMITATIONS.md) | | `position: absolute` | ❌ | — | Use `pluginRegistry` for overlay widgets | | `position: fixed` | ❌ | — | Use `pluginRegistry` for overlay widgets | | `top` / `right` / `bottom` / `left` | ❌ | — | Requires absolute/fixed; not supported | diff --git a/doc/LIMITATIONS.md b/doc/LIMITATIONS.md index 419b403..fb56b06 100644 --- a/doc/LIMITATIONS.md +++ b/doc/LIMITATIONS.md @@ -46,7 +46,9 @@ are silently ignored. | Property | Limitation | |----------|------------| | `opacity` | Applied per-element; stacking context opacity not propagated | -| `position: relative` / `box-sizing: border-box` | Parsed but not applied: no offset for `relative`, and `width` always sizes the content box | +| `box-sizing: border-box` | Applied on in-flow blocks only. Borders don't reserve layout space, so text can overlap a border. A flex item's `width` already includes its padding whatever `box-sizing` says. `width` has no effect on a flex or grid container, `` or `
`, and floated images ignore padding, so `box-sizing` changes nothing there | +| Horizontal block `margin` | `margin-left`, `margin-right` and `margin: 0 auto` have no effect on an in-flow block, so a block with a `width` always sits at the left edge and can't be centred this way | +| `position: relative` | Lengths only: `%` offsets are ignored. Moved content paints above normal content but in no finer order (no `z-index` or stacking contexts), and is clipped to the render box so it can't paint over the host app (the sanitizer doesn't filter CSS properties). In virtualized mode that box is one chunk. A flex or grid item moved past its container is still visible inside the box. Not applied to ``. A large container flattened for virtualization loses its offset | | `calc()` | Arithmetic on px/em/rem only; `%` in calc not resolved | | `text-align` | Center/right/justify on canvas paragraphs, LTR and box-level RTL (`textDirection: rtl`). An explicit `text-align` overrides RTL's default right edge; `justify` in RTL is not yet distributed (keeps the start edge) | | `float` scope | A float reserves a box from the element's own `width`/`height`; it does **not** lay the floated element's content out inside that box. So float works for replaced elements (``) and for empty, explicitly sized boxes (`
`) — text wraps beside both. It does **not** work for an element sized by its own text: a floated `
` with content renders that content in normal flow while still reserving a phantom float box, which can make the block *taller* than not floating it; a floated inline `` (the drop-cap idiom) has no effect at all. Pinned by `test/flagship_execution_audit_test.dart` | diff --git a/doc/SUPPORTED_HTML.md b/doc/SUPPORTED_HTML.md index 2a30ea1..a9c94fb 100644 --- a/doc/SUPPORTED_HTML.md +++ b/doc/SUPPORTED_HTML.md @@ -97,7 +97,7 @@ or the `fallbackBuilder` parameter to delegate to a WebView or other renderer. | `border` | width, style, color | | `border-style` | solid, dashed, dotted, double, none | | `border-radius` | px, % | -| `box-sizing` | **Not applied** — `width` always sizes the content box | +| `box-sizing` | content-box, border-box on in-flow blocks ([limits](LIMITATIONS.md)) | | `overflow` | visible, hidden | ### Layout @@ -107,7 +107,7 @@ or the `fallbackBuilder` parameter to delegate to a WebView or other renderer. | `display` | block, inline, inline-block, none, flex, grid, table | | `float` | left, right, none (Full wrapping support) | | `clear` | left, right, both, none | -| `position` | **Not applied** — `static` flow only (no `relative` offsets, `absolute`, `fixed`) | +| `position` | `static`, `relative` with length offsets ([limits](LIMITATIONS.md)). `absolute` and `fixed` lay out as `static` | | Flexbox | Containers and items, incl. wrapping flex on a dedicated `RenderFlexWrap` (v1.9.0). Not applied: `align-content`; `flex-basis` drives wrapping containers only | | CSS Grid | `grid-template-columns/rows` (px, fr, auto, repeat), `grid-column/row` span, gap (v1.2.0). Not applied: `grid-auto-flow`, `align-content` | diff --git a/lib/hyper_render.dart b/lib/hyper_render.dart index eede5c6..e243291 100644 --- a/lib/hyper_render.dart +++ b/lib/hyper_render.dart @@ -50,6 +50,7 @@ export 'package:hyper_render_core/hyper_render_core.dart' HyperBorderCollapse, HyperOverflow, HyperFloat, + HyperBoxSizing, HyperClear, HyperTimingFunction, HyperTimingParams, diff --git a/lib/src/widgets/hyper_viewer.dart b/lib/src/widgets/hyper_viewer.dart index 541b910..0e1d66f 100644 --- a/lib/src/widgets/hyper_viewer.dart +++ b/lib/src/widgets/hyper_viewer.dart @@ -2096,6 +2096,7 @@ class _HyperViewerState extends State if (widget.selectable && widget.showSelectionMenu) { content = HyperSelectionOverlay( document: _syncDocument!, + textDirection: widget.textDirection, selectable: true, onLinkTap: _safeOnLinkTap, widgetBuilder: _effectiveWidgetBuilder, @@ -2120,6 +2121,7 @@ class _HyperViewerState extends State // Use HyperRenderWidget directly (no popup menu) content = HyperRenderWidget( document: _syncDocument!, + textDirection: widget.textDirection ?? Directionality.of(context), selectable: widget.selectable, onLinkTap: _safeOnLinkTap, widgetBuilder: _effectiveWidgetBuilder, diff --git a/packages/hyper_render_core/CHANGELOG.md b/packages/hyper_render_core/CHANGELOG.md index 174b5c6..d71e9b7 100644 --- a/packages/hyper_render_core/CHANGELOG.md +++ b/packages/hyper_render_core/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog — hyper_render_core +## 1.14.0 + +- **`box-sizing`:** new `ComputedStyle.boxSizing` / `HyperBoxSizing`. `border-box` applies to `width` / `min-width` / `max-width`, including `%`, on in-flow blocks. +- **`position: relative`:** `top` / `right` / `bottom` / `left` (lengths) move blocks, inline spans and flex/grid items without affecting layout. New `ComputedStyle.insetTop` / `insetRight` / `insetBottom` / `insetLeft` and `relativeOffset()`. Hit-testing and selection follow; moved content paints above normal content, clipped to the render box. +- **Behavior changes:** a block's background and border follow its `width`; `min-width: %` applies on blocks; a first block styled only with a background or border paints it; canvas gradients paint; a `
` toggle no longer leaves an old background behind. + ## 1.13.0 - **`RenderHyperBox.getBoxesForCharRange(charStart, charEnd)`** — bounding boxes for a character range, for app-drawn highlights (search hits, annotations, read-aloud). Offsets are the selection/IME character space, local to this `RenderHyperBox`: in virtualized / `auto` mode (>10k chars) each chunk has its own box and offsets restart at 0. Glyph x-bounds come from `TextPainter` (so they follow `textScaler` and justified spacing); y-bounds span the full line. Rects of adjacent words on a line merge, except across an inline atom such as an image. Spaces inside `white-space: pre` / `pre-wrap` / `break-spaces` are kept. By [@DrkXo](https://github.com/DrkXo) ([#26](https://github.com/brewkits/hyper_render/pull/26), follow-up to [#17](https://github.com/brewkits/hyper_render/pull/17)). 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 ce6a194..fd7f0c8 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 @@ -200,6 +200,39 @@ class RenderHyperBox extends RenderBox /// Block decorations for painting border-left, backgrounds final List<_BlockDecoration> _blockDecorations = []; + /// Whether the document has any `position: relative` element, including + /// inside flex and grid containers. Documents without one skip all of the + /// relative handling. + bool _hasRelative = false; + + /// Flex and grid containers holding a `position: relative` item. Their items + /// move inside a child widget, invisible to the fragment pass, so the whole + /// container paints with the moved content. + final Set _relativeWidgetBoxes = Set.identity(); + + /// Cumulative `position: relative` shift of each node in this box's document. + final Map _relativeShifts = Map.identity(); + + /// Every fragment `position: relative` moved, with how far. Used to undo the + /// shift before the next line layout, since not every fragment gets re-placed. + final Map _displaced = Map.identity(); + + /// The moved fragments that sit on a line. They stay listed on that line for + /// document order and selection, so anything that finds a fragment by line + /// must check this list as well. + final List _displacedOnLines = []; + + /// Whether any painted thing moved, which turns on the clipped paint pass. + bool _hasMovedContent = false; + + /// Clips the moved-content paint pass to this box. See [_paint]. + final LayerHandle _relativeClipLayer = + LayerHandle(); + + /// Whether `position: relative` moved any content in the last layout. + @visibleForTesting + bool get debugHasDisplacedContent => _hasMovedContent; + /// Runtime animation state for every node currently carrying a resolvable /// `animation-name` (canvas-painted block animation — see /// render_hyper_box_animation.dart). Rebuilt on every [performLayout]. @@ -726,6 +759,7 @@ class RenderHyperBox extends RenderBox @override void dispose() { + _relativeClipLayer.layer = null; // Cancel pending shimmer frame callback to prevent callbacks on a disposed // RenderObject. SchedulerBinding.cancelFrameCallbackWithId is safe to call // with an id that has already fired (it's a no-op in that case). @@ -1281,6 +1315,9 @@ class RenderHyperBox extends RenderBox // Step 4: Position fragments within lines (baseline alignment) _positionFragments(); + // Step 4.5: Apply position: relative, once line positions are final. + _applyRelativeOffsets(); + // Step 5: Build inline decorations _buildInlineDecorations(); @@ -1296,6 +1333,7 @@ class RenderHyperBox extends RenderBox // text content and constraint width are both unchanged. _performLineLayout(); _positionFragments(); + _applyRelativeOffsets(); _buildInlineDecorations(); _buildCharacterMapping(); } @@ -1326,7 +1364,9 @@ class RenderHyperBox extends RenderBox final offset = fragment.offset; final measured = fragment.measuredSize; if (offset != null && measured != null) { - final bottom = offset.dy + measured.height + 4; // +4 = block margin + // In-flow position: `position: relative` must not change the height. + final flowY = offset.dy - (_displaced[fragment]?.dy ?? 0); + final bottom = flowY + measured.height + 4; // +4 = block margin if (bottom > height) height = bottom; } } @@ -1429,43 +1469,31 @@ class RenderHyperBox extends RenderBox void _paint(PaintingContext context, Offset offset) { try { - final canvas = context.canvas; - - // -1. Animated blocks (CSS animation-name): each block's own decoration - // + text painted as one composited, transformed/faded unit. Populates - // _paintedThisFrameByAnimation so steps 0 and 4 below skip repainting - // the same content at its static style. - _paintAnimatedBlocks(canvas, offset); - - // CSS Stacking Order: - // 0. Block decorations (border-left for blockquote, etc.) - _paintBlockDecorations(canvas, offset); - - // 1. Background and borders (inline decorations) - _paintInlineDecorations(canvas, offset); - - // 2. Float elements (painted before inline content) - _paintFloatImages(canvas, offset); - - // 3. Selection highlight (behind text) - if (_selection != null && - _selection!.isValid && - !_selection!.isCollapsed) { - _paintSelection(canvas, offset); + _paintedThisFrameByAnimation.clear(); + _paintPass(context, offset, moved: false); + + // `position: relative` content goes last, which stacks it above normal + // content as in CSS, and in its own clip. It can be moved anywhere and the + // sanitizer doesn't filter CSS properties, so untrusted HTML must not be + // able to draw over the host app. Content outside this box isn't + // hit-testable either, so what's visible stays what's tappable. Normal + // content stays unclipped: one moved span mustn't crop a box-shadow + // elsewhere in the document. + if (_hasMovedContent) { + _relativeClipLayer.layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + (context, offset) => _paintPass(context, offset, moved: true), + oldLayer: _relativeClipLayer.layer, + ); + } else { + _relativeClipLayer.layer = null; } - // 4. Inline content (text fragments) - _paintTextFragments(canvas, offset); - - // 5. Inline images (non-float) - _paintInlineImages(canvas, offset); - - // 6. Child render boxes (tables, positioned elements) - defaultPaint(context, offset); - - // 7. Debug bounds overlay (only when debugShowBounds is true) + // Debug bounds overlay (only when debugShowBounds is true) if (debugShowBounds) { - _paintDebugBounds(canvas, offset); + _paintDebugBounds(context.canvas, offset); } } catch (e, stack) { // Error boundary: prevent full app crash, but report to Flutter framework 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 9877d79..c772fb0 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 @@ -27,6 +27,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox { if (_document == null) return; _fragments = []; + _relativeWidgetBoxes.clear(); + _hasRelative = _scanForRelative(_document!); _lastBlockMarginBottom = 0; _fragmentsVersion++; // signal that line layout must be redone // Reset list item counters so ordered-list numbering restarts from 1 @@ -219,12 +221,20 @@ extension _RenderHyperBoxLayout on RenderHyperBox { style.widthPercent != null || style.maxWidth != null || style.maxWidthPercent != null || - style.minWidth != null; + style.minWidth != null || + style.minWidthPercent != null; final hasPadding = style.padding != EdgeInsets.zero; + // The block-start fragment also carries the decoration, so a first block + // styled only with a background or border must keep it too. Mirrors the + // `hasBackground || hasBorderLeft` test in _performLineLayout. + final hasDecoration = style.backgroundColor != null || + style.backgroundGradient != null || + (style.borderColor != null && style.borderWidth.left > 0); if (effectiveMarginTop > 0 || _fragments.isNotEmpty || hasWidthConstraint || hasPadding || + hasDecoration || style.textOverflow == TextOverflow.ellipsis) { _fragments.add(_BlockStartFragment( sourceNode: node, @@ -758,6 +768,17 @@ extension _RenderHyperBoxLayout on RenderHyperBox { _lines.clear(); _leftFloats.clear(); _rightFloats.clear(); + // Built by this pass, so a re-run (width change, details toggle, intrinsic + // query) must start empty or the previous pass's rects keep painting. + _blockDecorations.clear(); + // Undo earlier shifts first: a fragment line layout doesn't re-place, such + // as one it splits or replaces, would otherwise keep gaining the shift. + _displaced.forEach((fragment, shift) { + final offset = fragment.offset; + if (offset != null) fragment.offset = offset - shift; + }); + _displaced.clear(); + _displacedOnLines.clear(); // Reset layout caches _cachedAvailableWidth = null; @@ -1002,34 +1023,58 @@ extension _RenderHyperBoxLayout on RenderHyperBox { // CSS — is `_maxWidth − parentLeftInset − parentRightInset`, i.e. the // stack tops BEFORE this block's own padding was added. final st = fragment.style; + // Right edge of this block's painted box, when `width` & co. narrow it. + // Null keeps the default: the full width of the containing block. + double? constrainedBoxRight; if (st.width != null || st.widthPercent != null || st.maxWidth != null || st.maxWidthPercent != null || - st.minWidth != null) { + st.minWidth != null || + st.minWidthPercent != null) { // Floored at 0: cumulative padding can exceed the viewport width // (e.g. `padding:300px` inside a 200px container), which would make // this negative and turn a `%` width into a negative target. final containerContent = math.max( 0.0, _maxWidth - leftPaddingStack.last - rightPaddingStack.last); + // Flex and grid containers draw their own padding and border in a + // child widget, so neither applies to this canvas-side box. + final isWidgetBox = + st.display == DisplayType.flex || st.display == DisplayType.grid; + final borderSum = + isWidgetBox ? 0.0 : st.borderWidth.left + st.borderWidth.right; + // `border-box` widths include padding and border; the line breaker + // needs the content width that leaves. + final boxExtras = st.boxSizing == HyperBoxSizing.borderBox + ? fragment.paddingLeft + fragment.paddingRight + borderSum + : 0.0; + double toContent(double specified) => + math.max(0.0, specified - boxExtras); + // Default target: this block's own content width (full available). var target = _maxWidth - newLeftPadding - newRightPadding; // Explicit width (absolute or %) sets the target outright. if (st.width != null) { - target = st.width!; + target = toContent(st.width!); } else if (st.widthPercent != null) { - target = containerContent * st.widthPercent!; + target = toContent(containerContent * st.widthPercent!); } // max-width caps it (absolute wins over % if both somehow set). final maxW = st.maxWidth ?? (st.maxWidthPercent != null ? containerContent * st.maxWidthPercent! : null); - if (maxW != null && target > maxW) target = maxW; + if (maxW != null && target > toContent(maxW)) { + target = toContent(maxW); + } // min-width raises it — and wins over max-width per CSS. - if (st.minWidth != null && target < st.minWidth!) { - target = st.minWidth!; + final minW = st.minWidth ?? + (st.minWidthPercent != null + ? containerContent * st.minWidthPercent! + : null); + if (minW != null && target < toContent(minW)) { + target = toContent(minW); } // Clamp into the available content box: this single-column flow model // has no per-block horizontal overflow, so a target wider than the @@ -1043,6 +1088,16 @@ extension _RenderHyperBoxLayout on RenderHyperBox { final avail = math.max(0.0, _maxWidth - newLeftPadding - newRightPadding); target = target.clamp(0.0, avail); + + // Borders don't reserve line-breaker space yet, so they are added + // here only for the painted box. Capped so a box never paints past + // its containing block. + if (!isWidgetBox) { + constrainedBoxRight = math.min( + _maxWidth - rightPaddingStack.last, + newLeftPadding + target + fragment.paddingRight + borderSum, + ); + } newRightPadding = _maxWidth - newLeftPadding - target; } @@ -1067,9 +1122,10 @@ extension _RenderHyperBoxLayout on RenderHyperBox { final blockLeftX = leftPaddingStack.length > 1 ? leftPaddingStack[leftPaddingStack.length - 2] : 0.0; - final blockRightX = rightPaddingStack.length > 1 - ? _maxWidth - rightPaddingStack[rightPaddingStack.length - 2] - : _maxWidth; + final blockRightX = constrainedBoxRight ?? + (rightPaddingStack.length > 1 + ? _maxWidth - rightPaddingStack[rightPaddingStack.length - 2] + : _maxWidth); // startY is BEFORE padding (current position includes padding, so subtract it) final blockStartY = currentY - fragment.paddingTop; activeBlocks.add((fragment, blockStartY, blockLeftX, blockRightX)); @@ -2502,6 +2558,73 @@ extension _RenderHyperBoxLayout on RenderHyperBox { /// Step 4: Position fragments within lines (baseline alignment) /// Handles both LTR (left-to-right) and RTL (right-to-left) text directions + /// Whether [node]'s subtree has a `position: relative` element, recording + /// which flex and grid containers hold one. + bool _scanForRelative(UDTNode node) { + if (node.style.display == DisplayType.none) return false; + var found = node.style.position == 'relative'; + final isWidgetBox = node.style.display == DisplayType.flex || + node.style.display == DisplayType.grid; + for (final child in node.children) { + if (_scanForRelative(child)) found = true; + } + if (found && isWidgetBox) _relativeWidgetBoxes.add(node); + return found; + } + + /// Records each node's shift: its own offset plus its relative ancestors'. + /// + /// Walks down from this box's document rather than up `node.parent`: building + /// a nested box's document re-parents the shared nodes, so a walk up can stop + /// short of a relative ancestor. + void _collectRelativeShifts(UDTNode node, Offset inherited) { + final shift = inherited + node.style.relativeOffset(rtl: isRTL); + _relativeShifts[node] = shift; + for (final child in node.children) { + _collectRelativeShifts(child, shift); + } + } + + /// Applies `position: relative`: moves each element's fragments and block + /// decoration by its shift. + /// + /// Runs after line layout and positioning, so sibling layout never sees the + /// shift. Lines, floats' wrap areas and anchors keep their in-flow values. + /// Child widgets follow, since [_layoutChildren] reads `fragment.offset`. + void _applyRelativeOffsets() { + _relativeShifts.clear(); + _hasMovedContent = _relativeWidgetBoxes.isNotEmpty; + if (!_hasRelative || _document == null) return; + _collectRelativeShifts(_document!, Offset.zero); + + bool move(Fragment fragment) { + final offset = fragment.offset; + if (offset == null || _displaced.containsKey(fragment)) return false; + final shift = _relativeShifts[fragment.sourceNode] ?? Offset.zero; + if (shift == Offset.zero) return false; + fragment.offset = offset + shift; + _displaced[fragment] = shift; + return true; + } + + // Lines hold exactly the text that gets drawn. + for (final line in _lines) { + for (final fragment in line.fragments) { + if (move(fragment)) _displacedOnLines.add(fragment); + } + } + // `_fragments` adds what isn't on a line, such as list markers and floats. + _fragments.forEach(move); + for (final decoration in _blockDecorations) { + final shift = _relativeShifts[decoration.node] ?? Offset.zero; + if (shift == Offset.zero) continue; + decoration.rect = decoration.rect.shift(shift); + decoration.moved = true; + _hasMovedContent = true; + } + if (_displaced.isNotEmpty) _hasMovedContent = true; + } + void _positionFragments() { // Tracks blocks whose first line has already consumed their `text-indent`, // so only the FIRST line of each block is indented (CSS semantics). Lines @@ -2826,16 +2949,26 @@ extension _RenderHyperBoxLayout on RenderHyperBox { if (visualRect == null) continue; + // Back to in-flow coordinates. A decoration moves by its own node's + // shift, not its content's: an outer span's background stays on the + // line when a relative child inside it moves away. + final fragmentShift = _displaced[fragment]; + final flowRect = fragmentShift == null + ? visualRect + : visualRect.shift(-fragmentShift); + for (final decoratedNode in decoratedNodes) { final rangeEntry = decoratedRanges[decoratedNode]; if (rangeEntry == null) continue; final (style, _, _) = rangeEntry; final padding = style.padding; + final nodeRect = + flowRect.shift(_relativeShifts[decoratedNode] ?? Offset.zero); final expandedRect = Rect.fromLTWH( - visualRect.left - padding.left, - visualRect.top - padding.top, - visualRect.width + padding.left + padding.right, - visualRect.height + padding.top + padding.bottom, + nodeRect.left - padding.left, + nodeRect.top - padding.top, + nodeRect.width + padding.left + padding.right, + nodeRect.height + padding.top + padding.bottom, ); final list = rectsMap[decoratedNode]!; @@ -2861,9 +2994,12 @@ extension _RenderHyperBoxLayout on RenderHyperBox { final (style, _, _) = entry.value; final rects = rectsMap[node] ?? []; if (rects.isNotEmpty) { + final moved = (_relativeShifts[node] ?? Offset.zero) != Offset.zero; + if (moved) _hasMovedContent = true; _inlineDecorations.add(_InlineDecoration( node: node, rects: rects, + moved: moved, style: _InlineDecorationStyle( backgroundColor: style.backgroundColor, backgroundGradient: style.backgroundGradient, diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_paint.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_paint.dart index a9e35f0..32e9be9 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box_paint.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_paint.dart @@ -29,8 +29,11 @@ extension _RenderHyperBoxPaint on RenderHyperBox { /// covering it. Avoid combining `animation-name` on a block with inline /// decorations on its descendants until a future pass folds inline /// decorations into this same composited layer. - void _paintAnimatedBlocks(Canvas canvas, Offset offset) { - _paintedThisFrameByAnimation.clear(); + void _paintAnimatedBlocks( + Canvas canvas, + Offset offset, { + required bool moved, + }) { if (_blockAnimations.isEmpty) return; final now = SchedulerBinding.instance.currentFrameTimeStamp; @@ -48,6 +51,9 @@ extension _RenderHyperBoxPaint on RenderHyperBox { _paintedThisFrameByAnimation.add(node); + // One pass per block: the moved pass paints it if anything in it moved. + if (_animatedBlockHasMoved(node) != moved) continue; + final paintRect = rect.shift(offset); final opacity = (keyframe.opacity ?? 1.0).clamp(0.0, 1.0); final needsOpacityLayer = opacity < 1.0; @@ -130,8 +136,88 @@ extension _RenderHyperBoxPaint on RenderHyperBox { return bounds.inflate(2.0); } - void _paintBlockDecorations(Canvas canvas, Offset offset) { + /// Paints everything that `position: relative` moved ([moved] true), or + /// everything it didn't, in CSS stacking order. See [_paint]. + void _paintPass( + PaintingContext context, + Offset offset, { + required bool moved, + }) { + final canvas = context.canvas; + + // -1. Animated blocks (CSS animation-name): each block's own decoration + // + text painted as one composited, transformed/faded unit. Populates + // _paintedThisFrameByAnimation so steps 0 and 4 below skip repainting + // the same content at its static style. + _paintAnimatedBlocks(canvas, offset, moved: moved); + + // CSS Stacking Order: + // 0. Block decorations (border-left for blockquote, etc.) + _paintBlockDecorations(canvas, offset, moved: moved); + + // 1. Background and borders (inline decorations) + _paintInlineDecorations(canvas, offset, moved: moved); + + // 2. Float elements (painted before inline content) + _paintFloatImages(canvas, offset, moved: moved); + + // 3. Selection highlight (behind text) + if (_selection != null && _selection!.isValid && !_selection!.isCollapsed) { + _paintSelection(canvas, offset, moved: moved); + } + + // 4. Inline content (text fragments) + _paintTextFragments(canvas, offset, moved: moved); + + // 5. Inline images (non-float) + _paintInlineImages(canvas, offset, moved: moved); + + // 6. Child render boxes (tables, positioned elements) + _paintChildren(context, offset, moved: moved); + } + + /// Whether an animated block belongs to the moved pass. + bool _animatedBlockHasMoved(UDTNode node) { + if (!_hasMovedContent) return false; + if (_animatedBlockDecorations[node]?.moved ?? false) return true; + final fragments = _animatedBlockFragments[node]; + return fragments != null && fragments.any(_displaced.containsKey); + } + + void _paintChildren( + PaintingContext context, + Offset offset, { + required bool moved, + }) { + if (!_hasMovedContent) { + defaultPaint(context, offset); + return; + } + RenderBox? child = firstChild; + while (child != null) { + final parentData = child.parentData as HyperBoxParentData; + if (_childHasMoved(parentData) == moved) { + context.paintChild(child, parentData.offset + offset); + } + child = parentData.nextSibling; + } + } + + bool _childHasMoved(HyperBoxParentData parentData) { + final node = parentData.sourceNode; + if (node != null && _relativeWidgetBoxes.contains(node)) return true; + final fragment = parentData.fragment ?? + (parentData.isFloat ? _findFloatFragmentForNode(node) : null); + return fragment != null && _displaced.containsKey(fragment); + } + + void _paintBlockDecorations( + Canvas canvas, + Offset offset, { + required bool moved, + }) { for (final decoration in _blockDecorations) { + if (decoration.moved != moved) continue; // Animated blocks are painted (with their interpolated keyframe // overrides) by _paintAnimatedBlocks instead — skip here so they're // not drawn twice at their static style. @@ -218,8 +304,9 @@ extension _RenderHyperBoxPaint on RenderHyperBox { backgroundColorOverride == null) { _fillPaint.shader = decoration.backgroundGradient!.createShader(adjustedRect); - _fillPaint.color = - const Color(0x00000000); // reset color (unused when shader set) + // Opaque: a shader is multiplied by the paint's alpha, so a transparent + // colour here paints nothing at all. + _fillPaint.color = const Color(0xFF000000); } else { _fillPaint.shader = null; _fillPaint.color = backgroundColor!; @@ -274,8 +361,13 @@ extension _RenderHyperBoxPaint on RenderHyperBox { } } - void _paintInlineDecorations(Canvas canvas, Offset offset) { + void _paintInlineDecorations( + Canvas canvas, + Offset offset, { + required bool moved, + }) { for (final decoration in _inlineDecorations) { + if (decoration.moved != moved) continue; for (final rect in decoration.rects) { final rawRect = rect.shift(offset); final adjustedRect = Rect.fromLTRB( @@ -331,7 +423,7 @@ extension _RenderHyperBoxPaint on RenderHyperBox { if (decoration.style.backgroundGradient != null) { _fillPaint.shader = decoration.style.backgroundGradient!.createShader(adjustedRect); - _fillPaint.color = const Color(0x00000000); + _fillPaint.color = const Color(0xFF000000); // see block path above } else { _fillPaint.shader = null; _fillPaint.color = decoration.style.backgroundColor!; @@ -555,7 +647,11 @@ extension _RenderHyperBoxPaint on RenderHyperBox { } } - void _paintSelection(Canvas canvas, Offset offset) { + void _paintSelection( + Canvas canvas, + Offset offset, { + required bool moved, + }) { // Determine selection color: // 1. Explicit color passed from HyperViewer // 2. Adaptive default based on platform @@ -581,6 +677,7 @@ extension _RenderHyperBoxPaint on RenderHyperBox { Path? linePath; for (final fragment in line.fragments) { + if (_displaced.containsKey(fragment) != moved) continue; if (fragment.type == FragmentType.text && fragment.text != null) { final fragmentStart = fragment.globalOffset; final fragmentEnd = fragmentStart + fragment.text!.length; @@ -681,7 +778,11 @@ extension _RenderHyperBoxPaint on RenderHyperBox { } } - void _paintTextFragments(Canvas canvas, Offset offset) { + void _paintTextFragments( + Canvas canvas, + Offset offset, { + required bool moved, + }) { // Get visible area in local coordinates to perform line-level culling. // shift(-offset) converts the global clip bounds to the RenderBox's local // coordinate space so we can compare directly against line.bounds. @@ -689,7 +790,9 @@ extension _RenderHyperBoxPaint on RenderHyperBox { // First paint list markers for (final fragment in _fragments) { - if (fragment is _ListMarkerFragment && fragment.offset != null) { + if (fragment is _ListMarkerFragment && + fragment.offset != null && + _displaced.containsKey(fragment) == moved) { // Simple culling for markers: only paint if the line Y is visible. final markerY = fragment.offset!.dy; if (markerY + fragment.height >= visibleRect.top && @@ -699,6 +802,17 @@ extension _RenderHyperBoxPaint on RenderHyperBox { } } + // Moved text: its line says nothing about where it is on screen, so it is + // culled by its own rect. + if (moved) { + for (final fragment in _displacedOnLines) { + final rect = fragment.rect; + if (rect == null || !rect.overlaps(visibleRect)) continue; + _paintLineFragment(canvas, offset, fragment); + } + return; + } + // Then paint line content for (final line in _lines) { // CULLING: Only paint lines that intersect with the visible clip bounds. @@ -710,25 +824,30 @@ extension _RenderHyperBoxPaint on RenderHyperBox { } for (final fragment in line.fragments) { - // Animated blocks are painted (with interpolated color/opacity/ - // transform) by _paintAnimatedBlocks instead — skip here so they're - // not drawn twice at their static style. List markers are - // deliberately NOT skipped: block-level canvas animation covers text - // content only for now, so a marker stays static even inside an - // animating block (documented v1 scope limit). - final owner = _nearestAnimatedAncestor(fragment.sourceNode); - if (owner != null && _paintedThisFrameByAnimation.contains(owner)) { - continue; - } - if (fragment.type == FragmentType.text && fragment.text != null) { - _paintTextFragment(canvas, offset, fragment); - } else if (fragment.type == FragmentType.ruby) { - _paintRubyFragment(canvas, offset, fragment); - } + if (_displaced.containsKey(fragment)) continue; + _paintLineFragment(canvas, offset, fragment); } } } + void _paintLineFragment(Canvas canvas, Offset offset, Fragment fragment) { + // Animated blocks are painted (with interpolated color/opacity/ + // transform) by _paintAnimatedBlocks instead — skip here so they're + // not drawn twice at their static style. List markers are + // deliberately NOT skipped: block-level canvas animation covers text + // content only for now, so a marker stays static even inside an + // animating block (documented v1 scope limit). + final owner = _nearestAnimatedAncestor(fragment.sourceNode); + if (owner != null && _paintedThisFrameByAnimation.contains(owner)) { + return; + } + if (fragment.type == FragmentType.text && fragment.text != null) { + _paintTextFragment(canvas, offset, fragment); + } else if (fragment.type == FragmentType.ruby) { + _paintRubyFragment(canvas, offset, fragment); + } + } + void _paintListMarker( Canvas canvas, Offset offset, _ListMarkerFragment fragment) { final painter = _getTextPainter(fragment.marker, fragment.style); @@ -779,10 +898,15 @@ extension _RenderHyperBoxPaint on RenderHyperBox { ); } - void _paintFloatImages(Canvas canvas, Offset offset) { + void _paintFloatImages( + Canvas canvas, + Offset offset, { + required bool moved, + }) { // Paint images from float fragments ONLY if they don't have child widgets for (final fragment in _fragments) { - if (fragment is _FloatFragment) { + if (fragment is _FloatFragment && + _displaced.containsKey(fragment) == moved) { if (_hasChildWidgetForFragment(fragment)) continue; final node = fragment.sourceNode; @@ -792,6 +916,8 @@ extension _RenderHyperBoxPaint on RenderHyperBox { } } + if (moved) return; // Carried-over floats are never moved. + // Paint carried-over float images from the previous virtualized section. // These are float images that extend past the chunk boundary — the previous // section painted the top portion, and this section paints the remainder @@ -822,10 +948,16 @@ extension _RenderHyperBoxPaint on RenderHyperBox { } } - void _paintInlineImages(Canvas canvas, Offset offset) { + void _paintInlineImages( + Canvas canvas, + Offset offset, { + required bool moved, + }) { // Paint non-float atomic images ONLY if they don't have child widgets for (final fragment in _fragments) { - if (fragment.type == FragmentType.atomic && fragment is! _FloatFragment) { + if (fragment.type == FragmentType.atomic && + fragment is! _FloatFragment && + _displaced.containsKey(fragment) == moved) { // Check if this fragment has a linked child widget - if so, skip canvas painting // The child widget (HyperImage) will handle rendering if (_hasChildWidgetForFragment(fragment)) continue; 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 86e3cc3..4bffe45 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 @@ -41,7 +41,21 @@ extension RenderHyperBoxSelection on RenderHyperBox { // ── Hit testing ─────────────────────────────────────────────────────────── + /// The topmost `position: relative` fragment painted at [position]. + /// + /// Line lookups can't find these: a moved fragment stays listed on its + /// original line, which may be nowhere near where it is painted. + Fragment? _findDisplacedFragmentAt(Offset position) { + for (final fragment in _displacedOnLines.reversed) { + final rect = fragment.rect; + if (rect != null && rect.contains(position)) return fragment; + } + return null; + } + Fragment? _findFragmentAtPosition(Offset position) { + final displaced = _findDisplacedFragmentAt(position); + if (displaced != null) return displaced; final lineIdx = _lineIndexAt(position.dy); if (lineIdx < 0) return null; for (final fragment in _lines[lineIdx].fragments) { @@ -52,6 +66,18 @@ extension RenderHyperBoxSelection on RenderHyperBox { } int _getCharacterPositionAtOffset(Offset position) { + final displaced = _findDisplacedFragmentAt(position); + if (displaced != null && + displaced.text != null && + (displaced.type == FragmentType.text || + displaced.type == FragmentType.ruby)) { + final painter = + _getTextPainter(displaced.text!, _effectiveFragmentStyle(displaced)); + final localX = position.dx - displaced.offset!.dx; + return displaced.globalOffset + + painter.getPositionForOffset(Offset(localX, 0)).offset; + } + // Selection drag is lenient — a finger that drifts above the first // line or into the margin below the last line should still extend // the selection to the nearest edge, not freeze. @@ -68,6 +94,12 @@ extension RenderHyperBoxSelection on RenderHyperBox { if ((fragment.type == FragmentType.text && fragment.text != null) || fragment.type == FragmentType.ruby) { final text = fragment.text!; + // Painted elsewhere, so its rect says nothing about this line. Keep + // counting its characters for the end-of-line fallback. + if (_displaced.containsKey(fragment)) { + lastOffset = fragment.globalOffset + text.length; + continue; + } final fragmentOffset = fragment.offset ?? Offset.zero; // On a justify line the glyphs are widened by word-spacing, so both // the hit rect and the painter must use the justified width/style. @@ -351,11 +383,16 @@ extension RenderHyperBoxSelection on RenderHyperBox { continue; } + // A moved fragment no longer sits on the line grid, so it must not + // merge with its neighbours on either side. + final shift = _displaced[fragment]; + if (shift != null) mergeGroup++; + final fragmentBoxes = _getFragmentBoxesForRange( fragment: fragment, rangeStart: charStart, rangeEnd: charEnd, - lineTop: line.top, + lineTop: line.top + (shift?.dy ?? 0), lineHeight: line.height, trimBoundarySpacesOnly: true, ); @@ -363,6 +400,8 @@ extension RenderHyperBoxSelection on RenderHyperBox { for (final box in fragmentBoxes) { currentLineRects.add((rect: box, mergeGroup: mergeGroup)); } + + if (shift != null) mergeGroup++; } if (currentLineRects.isEmpty) continue; diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_types.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_types.dart index 7b6118e..da8dc7b 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box_types.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_types.dart @@ -155,17 +155,26 @@ class _InlineDecoration { final List rects; final _InlineDecorationStyle style; + /// Whether `position: relative` moved this decoration. + final bool moved; + _InlineDecoration({ required this.node, required this.rects, required this.style, + this.moved = false, }); } /// Block decoration info for painting border-left, background on block elements class _BlockDecoration { final UDTNode node; - final Rect rect; + + /// Mutable only so `position: relative` can shift it after line layout. + Rect rect; + + /// Whether `position: relative` moved this decoration. + bool moved = false; final Color? backgroundColor; final Gradient? backgroundGradient; final Color? borderLeftColor; diff --git a/packages/hyper_render_core/lib/src/model/computed_style.dart b/packages/hyper_render_core/lib/src/model/computed_style.dart index cb4c565..3a88c7a 100644 --- a/packages/hyper_render_core/lib/src/model/computed_style.dart +++ b/packages/hyper_render_core/lib/src/model/computed_style.dart @@ -115,6 +115,16 @@ enum HyperBorderStyle { outset, } +/// CSS `box-sizing`: whether `width` / `min-width` / `max-width` size the +/// content box or also include padding and border. +enum HyperBoxSizing { + /// `content-box` (default): the width is the content width. + contentBox, + + /// `border-box`: the width includes horizontal padding and border. + borderBox, +} + /// Border model for CSS `border-collapse` property. enum HyperBorderCollapse { /// Adjacent cell borders merge into a single line. This is HyperRender's @@ -393,6 +403,9 @@ class ComputedStyle { /// CSS max-width double? maxWidth; + /// CSS box-sizing. Not inherited. + HyperBoxSizing boxSizing; + /// CSS min-height double? minHeight; @@ -582,9 +595,36 @@ class ComputedStyle { /// CSS overflow-y HyperOverflow overflowY; - /// CSS position (simplified) + /// CSS position (simplified). Only `relative` changes rendering; every other + /// value lays out as `static`. Not inherited. String position; + /// CSS `top`. Null means `auto`. Lengths only; not inherited. + double? insetTop; + + /// CSS `right`. Null means `auto`. Lengths only; not inherited. + double? insetRight; + + /// CSS `bottom`. Null means `auto`. Lengths only; not inherited. + double? insetBottom; + + /// CSS `left`. Null means `auto`. Lengths only; not inherited. + double? insetLeft; + + /// How far `position: relative` moves this element from its in-flow place. + /// + /// [Offset.zero] unless the element is `relative`. `top` wins over `bottom`. + /// `left` wins over `right`, except that in a right-to-left context ([rtl]) + /// `right` wins, per CSS 2.1 §9.4.3. + Offset relativeOffset({bool rtl = false}) { + if (position != 'relative') return Offset.zero; + final dy = insetTop ?? (insetBottom == null ? 0.0 : -insetBottom!); + final fromLeft = insetLeft; + final fromRight = insetRight == null ? null : -insetRight!; + final dx = (rtl ? (fromRight ?? fromLeft) : (fromLeft ?? fromRight)) ?? 0.0; + return Offset(dx, dy); + } + /// CSS float (left, right, none) HyperFloat float; @@ -801,6 +841,11 @@ class ComputedStyle { this.overflowX = HyperOverflow.visible, this.overflowY = HyperOverflow.visible, this.position = 'static', + this.insetTop, + this.insetRight, + this.insetBottom, + this.insetLeft, + this.boxSizing = HyperBoxSizing.contentBox, this.float = HyperFloat.none, this.clear = HyperClear.none, this.zIndex, @@ -984,6 +1029,11 @@ class ComputedStyle { HyperOverflow? overflowX, HyperOverflow? overflowY, String? position, + double? insetTop, + double? insetRight, + double? insetBottom, + double? insetLeft, + HyperBoxSizing? boxSizing, HyperFloat? float, HyperClear? clear, int? zIndex, @@ -1092,6 +1142,11 @@ class ComputedStyle { overflowX: overflowX ?? this.overflowX, overflowY: overflowY ?? this.overflowY, position: position ?? this.position, + insetTop: insetTop ?? this.insetTop, + insetRight: insetRight ?? this.insetRight, + insetBottom: insetBottom ?? this.insetBottom, + insetLeft: insetLeft ?? this.insetLeft, + boxSizing: boxSizing ?? this.boxSizing, float: float ?? this.float, clear: clear ?? this.clear, zIndex: zIndex ?? this.zIndex, diff --git a/packages/hyper_render_core/lib/src/style/resolver.dart b/packages/hyper_render_core/lib/src/style/resolver.dart index 9437b19..a7047c8 100644 --- a/packages/hyper_render_core/lib/src/style/resolver.dart +++ b/packages/hyper_render_core/lib/src/style/resolver.dart @@ -1761,6 +1761,64 @@ class StyleResolver { } break; + case 'position': + final position = value.trim().toLowerCase(); + if (const {'static', 'relative', 'absolute', 'fixed', 'sticky'} + .contains(position)) { + style.position = position; + style.markExplicitlySet('position'); + } + break; + + // Offsets for `position: relative`. `auto` clears an earlier value; `%` + // and anything else unparseable leaves it alone. + case 'top': + if (_isAutoKeyword(value)) { + style.insetTop = null; + } else { + style.insetTop = _parseOffsetLength(value, style, parentFontSize) ?? + style.insetTop; + } + break; + + case 'right': + if (_isAutoKeyword(value)) { + style.insetRight = null; + } else { + style.insetRight = _parseOffsetLength(value, style, parentFontSize) ?? + style.insetRight; + } + break; + + case 'bottom': + if (_isAutoKeyword(value)) { + style.insetBottom = null; + } else { + style.insetBottom = + _parseOffsetLength(value, style, parentFontSize) ?? + style.insetBottom; + } + break; + + case 'left': + if (_isAutoKeyword(value)) { + style.insetLeft = null; + } else { + style.insetLeft = _parseOffsetLength(value, style, parentFontSize) ?? + style.insetLeft; + } + break; + + case 'box-sizing': + final sizing = value.trim().toLowerCase(); + if (sizing == 'border-box' || sizing == 'content-box') { + style.boxSizing = sizing == 'border-box' + ? HyperBoxSizing.borderBox + : HyperBoxSizing.contentBox; + style.markExplicitlySet('box-sizing'); + } + break; + case 'text-indent': // `%` is relative to the containing block's content width and is // resolved at layout time (stored on textIndentPercent); absolute @@ -3834,6 +3892,23 @@ class StyleResolver { return false; } + bool _isAutoKeyword(String value) => value.trim().toLowerCase() == 'auto'; + + /// A `top`/`right`/`bottom`/`left` length. `em` is this element's own font + /// size: font-size is hoisted ahead of other declarations, so it is already + /// final here if the element sets it, and otherwise equals the parent's. + double? _parseOffsetLength( + String value, + ComputedStyle style, + double? parentFontSize, + ) => + _parseLengthWithContext( + value, + parentFontSize: style.isExplicitlySet('font-size') + ? style.fontSize + : parentFontSize, + ); + double? _parseLength(String value) { value = value.trim().toLowerCase(); diff --git a/packages/hyper_render_core/lib/src/widgets/hyper_render_widget.dart b/packages/hyper_render_core/lib/src/widgets/hyper_render_widget.dart index 9ab5d91..d7d39aa 100644 --- a/packages/hyper_render_core/lib/src/widgets/hyper_render_widget.dart +++ b/packages/hyper_render_core/lib/src/widgets/hyper_render_widget.dart @@ -197,7 +197,8 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { onLinkTap: onLinkTap, codeHighlighter: config.codeHighlighter, keyframeRegistry: config.keyframeRegistry, - pluginRegistry: pluginRegistry)); + pluginRegistry: pluginRegistry, + rtl: textDirection == TextDirection.rtl)); /// Build child widgets for atomic elements (images, tables, etc.) static List _buildChildren( @@ -208,6 +209,7 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { CodeHighlighter? codeHighlighter, Map keyframeRegistry = const {}, HyperPluginRegistry? pluginRegistry, + bool rtl = false, }) { final children = []; _collectAtomicChildren(document, children, widgetBuilder, @@ -215,7 +217,8 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { onLinkTap: onLinkTap, codeHighlighter: codeHighlighter, keyframeRegistry: keyframeRegistry, - pluginRegistry: pluginRegistry); + pluginRegistry: pluginRegistry, + rtl: rtl); return children; } @@ -228,6 +231,7 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { CodeHighlighter? codeHighlighter, Map keyframeRegistry = const {}, HyperPluginRegistry? pluginRegistry, + bool rtl = false, }) { Widget? childWidget; @@ -251,7 +255,7 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { if (node.style.display == DisplayType.flex) { childWidget = widgetBuilder?.call(node); childWidget ??= _buildFlexContainerWidget(node, widgetBuilder, - selectable: selectable); + selectable: selectable, rtl: rtl); if (childWidget != null) { children.add(_HyperChildWidget( node: node, @@ -264,7 +268,7 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { if (node.style.display == DisplayType.grid) { childWidget = widgetBuilder?.call(node); childWidget ??= _buildGridContainerWidget(node, widgetBuilder, - selectable: selectable); + selectable: selectable, rtl: rtl); if (childWidget != null) { children.add(_HyperChildWidget( node: node, @@ -364,7 +368,8 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { onLinkTap: onLinkTap, codeHighlighter: codeHighlighter, keyframeRegistry: keyframeRegistry, - pluginRegistry: pluginRegistry); + pluginRegistry: pluginRegistry, + rtl: rtl); } } } @@ -464,10 +469,11 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { UDTNode node, HyperWidgetBuilder? widgetBuilder, { bool selectable = false, + bool rtl = false, }) { final items = []; for (final child in node.children) { - final childWidget = _buildFlexChild(child, widgetBuilder); + final childWidget = _buildFlexChild(child, widgetBuilder, rtl: rtl); if (childWidget != null) { items.add(GridItem( child: childWidget, @@ -598,11 +604,12 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { UDTNode node, HyperWidgetBuilder? widgetBuilder, { bool selectable = false, + bool rtl = false, }) { // Recursively build children of flex container final flexChildren = []; for (final child in node.children) { - final childWidget = _buildFlexChild(child, widgetBuilder); + final childWidget = _buildFlexChild(child, widgetBuilder, rtl: rtl); if (childWidget != null) { // Always carry the item's ComputedStyle: the wrapping-flex renderer // needs `min-width`/`max-width`/`width` even on items that declare no @@ -653,8 +660,24 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { } } + /// Builds a flex or grid child, moved by its `position: relative` offset. + /// `Transform` shifts paint and hit-testing without affecting layout. static Widget? _buildFlexChild( - UDTNode node, HyperWidgetBuilder? widgetBuilder) { + UDTNode node, + HyperWidgetBuilder? widgetBuilder, { + bool rtl = false, + }) { + final child = _buildFlexChildContent(node, widgetBuilder, rtl: rtl); + final shift = node.style.relativeOffset(rtl: rtl); + if (child == null || shift == Offset.zero) return child; + return Transform.translate(offset: shift, child: child); + } + + static Widget? _buildFlexChildContent( + UDTNode node, + HyperWidgetBuilder? widgetBuilder, { + bool rtl = false, + }) { // If it's text content, convert to Text widget if (node.type == NodeType.text) { final textNode = node as TextNode; @@ -685,14 +708,14 @@ class HyperRenderWidget extends MultiChildRenderObjectWidget { // If it's a nested flex container, recursively build it if (node.type == NodeType.block && node.style.display == DisplayType.flex) { - return _buildFlexContainerWidget(node, widgetBuilder); + return _buildFlexContainerWidget(node, widgetBuilder, rtl: rtl); } // If it's a block, create a container if (node.type == NodeType.block) { final blockChildren = []; for (final child in node.children) { - final childWidget = _buildFlexChild(child, widgetBuilder); + final childWidget = _buildFlexChild(child, widgetBuilder, rtl: rtl); if (childWidget != null) { blockChildren.add(childWidget); } diff --git a/packages/hyper_render_core/pubspec.yaml b/packages/hyper_render_core/pubspec.yaml index edaffaa..997b1d8 100644 --- a/packages/hyper_render_core/pubspec.yaml +++ b/packages/hyper_render_core/pubspec.yaml @@ -1,6 +1,6 @@ name: hyper_render_core description: Core engine for HyperRender. Universal Document Tree, single-RenderObject layout with CSS float, Flexbox, Grid, CJK typography, AI streaming, and crash-free text selection. -version: 1.13.0 +version: 1.14.0 homepage: https://github.com/brewkits/hyper_render repository: https://github.com/brewkits/hyper_render/tree/main/packages/hyper_render_core issue_tracker: https://github.com/brewkits/hyper_render/issues diff --git a/pubspec.yaml b/pubspec.yaml index e7b7e8a..17c4795 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: hyper_render description: "The only Flutter HTML/Markdown renderer with CSS float layout, crash-free text selection at any document size, CJK Ruby typography, and AI/LLM token streaming." -version: 1.13.1 +version: 1.14.0 homepage: https://github.com/brewkits/hyper_render repository: https://github.com/brewkits/hyper_render issue_tracker: https://github.com/brewkits/hyper_render/issues @@ -45,7 +45,7 @@ dependencies: # hyper_render_html / _markdown / _highlight were declared but never imported # — pure install weight for every consumer. They remain separately published; # depend on them explicitly if you import them. - hyper_render_core: ^1.13.0 + hyper_render_core: ^1.14.0 # Fixed version constraints for pub.dev compliance flutter_highlight: ^0.7.0 diff --git a/test/box_sizing_test.dart b/test/box_sizing_test.dart new file mode 100644 index 0000000..d995243 --- /dev/null +++ b/test/box_sizing_test.dart @@ -0,0 +1,269 @@ +// CSS box-sizing, measured through real geometry: line capacity (the test font +// has 16px square glyphs at the default size) and painted pixels. +// +// Covers in-flow canvas blocks, which is the scope documented in +// doc/CSS_PROPERTIES_MATRIX.md and doc/LIMITATIONS.md. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +import 'support/geometry_probe.dart'; + +final _word = 'W' * 60; + +/// Characters on the first line of a 60-character word inside [style]. +Future _capacity(WidgetTester tester, String style) async { + await pumpHtml(tester, '
$_word
'); + return firstLineChars(tester); +} + +void main() { + group('line capacity', () { + testWidgets('matches the issue: 12 characters, 10 with border-box', + (tester) async { + expect(await _capacity(tester, 'width:200px;padding:20px'), 12); + expect( + await _capacity( + tester, 'width:200px;padding:20px;box-sizing:border-box'), + 10, + ); + }); + + testWidgets('content-box behaves like the default', (tester) async { + expect( + await _capacity( + tester, 'width:200px;padding:20px;box-sizing:content-box'), + 12, + ); + }); + + testWidgets('an invalid value is ignored', (tester) async { + expect( + await _capacity(tester, 'width:200px;padding:20px;box-sizing:banana'), + 12, + ); + }); + + testWidgets('a later declaration wins', (tester) async { + expect( + await _capacity( + tester, + 'box-sizing:border-box;width:200px;padding:20px;' + 'box-sizing:content-box', + ), + 12, + ); + }); + + testWidgets('border width is subtracted too', (tester) async { + // 200 − 40 padding − 20 border = 140px, which holds 8 glyphs. + expect( + await _capacity( + tester, + 'width:200px;padding:20px;border:10px solid #000;' + 'box-sizing:border-box', + ), + 8, + ); + }); + + testWidgets('percentage width', (tester) async { + expect(await _capacity(tester, 'width:50%;padding:20px'), 12); + expect( + await _capacity(tester, 'width:50%;padding:20px;box-sizing:border-box'), + 10, + ); + }); + + testWidgets('max-width, absolute and percentage', (tester) async { + const bb = 'padding:20px;box-sizing:border-box'; + expect(await _capacity(tester, 'max-width:200px;$bb'), 10); + expect(await _capacity(tester, 'max-width:50%;$bb'), 10); + }); + + testWidgets('min-width, absolute and percentage', (tester) async { + const bb = 'width:50px;padding:20px;box-sizing:border-box'; + expect(await _capacity(tester, 'min-width:200px;$bb'), 10); + expect(await _capacity(tester, 'min-width:50%;$bb'), 10); + // Percentage min-width also applies without border-box. + expect(await _capacity(tester, 'width:50px;min-width:50%'), 12); + }); + + testWidgets('min-width still wins over max-width', (tester) async { + expect( + await _capacity( + tester, + 'max-width:100px;min-width:200px;padding:20px;box-sizing:border-box', + ), + 10, + ); + }); + + testWidgets('padding wider than the width floors at 0 without throwing', + (tester) async { + await pumpHtml( + tester, + '
A
' + '

after

', + ); + expect(tester.takeException(), isNull); + expect(fragmentOf(tester, 'after'), isNotNull); + }); + + testWidgets('width:100% with padding fills the container either way', + (tester) async { + expect(await _capacity(tester, 'width:100%;padding:20px'), 22); + expect( + await _capacity( + tester, 'width:100%;padding:20px;box-sizing:border-box'), + 22, + ); + }); + }); + + group('nesting and inheritance', () { + testWidgets('a nested % resolves against the parent content box', + (tester) async { + // Outer content 300−20=280; inner 50% = 140, minus its 20 padding = 120. + await pumpHtml( + tester, + '
' + '
$_word
' + '
', + ); + expect(firstLineChars(tester), 7); + }); + + testWidgets('box-sizing is not inherited', (tester) async { + await pumpHtml( + tester, + '
' + '
$_word
', + ); + expect(firstLineChars(tester), 12); + }); + }); + + group('stylesheets', () { + testWidgets('the common reset applies through ' + '
$_word
', + ); + expect(firstLineChars(tester), 10); + }); + + testWidgets('it applies through customCss', (tester) async { + await pumpHtml( + tester, + '
$_word
', + customCss: '.box { width:200px; padding:20px; box-sizing:border-box }', + ); + expect(firstLineChars(tester), 10); + }); + }); + + group('painted box', () { + Future redOf(WidgetTester tester, String style, + {String body = 'A'}) async { + await pumpHtml( + tester, '
$body
'); + return (await capture(tester)).extent(isRed)!; + } + + testWidgets('border-box: the background is exactly the declared width', + (tester) async { + final red = + await redOf(tester, 'width:200px;padding:20px;box-sizing:border-box'); + expect(red.left, 0); + expect(red.right, 199); + }); + + testWidgets('content-box: the background adds padding to the width', + (tester) async { + final red = await redOf(tester, 'width:200px;padding:20px'); + expect(red.left, 0); + expect(red.right, 239); + }); + + testWidgets('a border is inside the declared border-box width', + (tester) async { + await pumpHtml( + tester, + '
A
', + ); + final blue = (await capture(tester)).extent(isBlue)!; + expect(blue.left, 0); + expect(blue.right, 199); + }); + + testWidgets('a border adds to a content-box width', (tester) async { + await pumpHtml( + tester, + '
A
', + ); + final blue = (await capture(tester)).extent(isBlue)!; + expect(blue.right, 259); + }); + + testWidgets('no width constraint: still the full container', + (tester) async { + final red = await redOf(tester, 'padding:20px'); + expect(red.left, 0); + expect(red.right, 399); + }); + + testWidgets('a width larger than the container is capped to it', + (tester) async { + final red = await redOf(tester, 'width:900px;padding:20px'); + expect(red.right, 399); + }); + + testWidgets('max-width that does not bind: still the full container', + (tester) async { + final red = await redOf(tester, 'max-width:900px'); + expect(red.right, 399); + }); + + testWidgets('a nested box stays inside its parent', (tester) async { + await pumpHtml( + tester, + '
' + '
A
', + ); + final red = (await capture(tester)).extent(isRed)!; + expect(red.left, 10); + expect(red.right, 189); + }); + + testWidgets('a width change recomputes the box', (tester) async { + const html = '
A
'; + await pumpHtml(tester, html, width: 400); + expect((await capture(tester)).extent(isRed)!.right, 199); + await pumpHtml(tester, html, width: 200); + expect((await capture(tester)).extent(isRed)!.right, 99); + }); + }); + + test('ComputedStyle: defaults to content-box and survives copyWith', () { + expect(ComputedStyle().boxSizing, HyperBoxSizing.contentBox); + expect( + ComputedStyle(boxSizing: HyperBoxSizing.borderBox).copyWith().boxSizing, + HyperBoxSizing.borderBox, + ); + }); + + testWidgets('no regression when width is unset: identical layout', + (tester) async { + await pumpHtml(tester, '
$_word

Z

'); + final plain = fragmentTopLeft(tester, 'Z'); + final height = tester.getSize(find.byType(HyperViewer)).height; + await pumpHtml(tester, + '
$_word

Z

'); + expect(fragmentTopLeft(tester, 'Z'), plain); + expect(tester.getSize(find.byType(HyperViewer)).height, height); + }); +} diff --git a/test/docs_matrix_sync_test.dart b/test/docs_matrix_sync_test.dart index 324e2b8..9090419 100644 --- a/test/docs_matrix_sync_test.dart +++ b/test/docs_matrix_sync_test.dart @@ -48,8 +48,8 @@ const _executionVerified = [ const _mustNotBeFull = [ 'position: absolute', 'position: fixed', - // Parsed into ComputedStyle and read by nothing in the render path; checked - // by laying out `width:200px;padding:20px` with and without border-box. + // Applied, but only in part: ⚠️ until the limits in LIMITATIONS.md go. + // Measured by test/box_sizing_test.dart and test/position_relative_test.dart. 'position: relative', 'box-sizing', 'z-index', diff --git a/test/first_block_decoration_test.dart b/test/first_block_decoration_test.dart new file mode 100644 index 0000000..a06d845 --- /dev/null +++ b/test/first_block_decoration_test.dart @@ -0,0 +1,89 @@ +// A first block styled ONLY with a background or border used to paint nothing: +// its _BlockStartFragment (which carries the decoration) was elided because the +// emit condition checked margin/padding/width/ellipsis but not decoration. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:hyper_render/hyper_render.dart'; + +import 'support/geometry_probe.dart'; + +void main() { + testWidgets('a first block with only a background paints it', (tester) async { + await pumpHtml(tester, '
AAA
'); + final red = (await capture(tester)).extent(isRed); + // The glyphs cover x 0–47, so the background shows from x=48 to the edge. + expect(red, isNotNull); + expect(red!.left, 48); + expect(red.right, 399); + }); + + testWidgets('a first block paints the same as a later block', (tester) async { + await pumpHtml(tester, '
AAA
'); + final first = (await capture(tester)).extent(isRed); + await pumpHtml( + tester, '

Z

AAA
'); + final later = (await capture(tester)).extent(isRed); + expect(first?.left, later?.left); + expect(first?.right, later?.right); + expect(first?.height, later?.height); + }); + + testWidgets('a first block with only a gradient paints it', (tester) async { + await pumpHtml( + tester, + '
' + 'AAA
'); + expect((await capture(tester)).extent(isRed), isNotNull); + }); + + testWidgets('a gradient on a later block and an inline span paints too', + (tester) async { + const gradient = 'linear-gradient(to right,#ff0000,#ff0000)'; + await pumpHtml(tester, '

Z

A
'); + expect((await capture(tester)).extent(isRed), isNotNull); + + await pumpHtml(tester, + '

ZA'); + expect((await capture(tester)).extent(isRed), isNotNull); + }); + + testWidgets('a first block with only a border paints it', (tester) async { + await pumpHtml( + tester, '

AAA
'); + final blue = (await capture(tester)).extent(isBlue); + expect(blue, isNotNull); + expect(blue!.right, greaterThan(390)); + }); + + testWidgets('emitting the decoration does not move the text', (tester) async { + await pumpHtml(tester, '
AAA

BBB

'); + final plainA = fragmentTopLeft(tester, 'AAA'); + final plainB = fragmentTopLeft(tester, 'BBB'); + final plainH = viewerHeight(tester); + + await pumpHtml( + tester, '
AAA

BBB

'); + expect(fragmentTopLeft(tester, 'AAA'), plainA); + expect(fragmentTopLeft(tester, 'BBB'), plainB); + expect(viewerHeight(tester), plainH); + }); + + testWidgets('re-running line layout leaves no stale decoration behind', + (tester) async { + const html = '
S

body

' + '
AAA
'; + await pumpHtml(tester, html); + final closed = (await capture(tester)).extent(isRed)!; + + // A details toggle re-runs line layout without invalidating the fragments, + // and the block below moves down. Its old rect must not keep painting. + final details = find.byType(HyperDetailsWidget); + await tester.tapAt(tester.getTopLeft(details) + const Offset(30, 10)); + await tester.pumpAndSettle(); + final open = (await capture(tester)).extent(isRed)!; + + expect(open.top, greaterThan(closed.top)); + expect(open.height, closed.height); + }); +} diff --git a/test/position_relative_test.dart b/test/position_relative_test.dart new file mode 100644 index 0000000..a9320da --- /dev/null +++ b/test/position_relative_test.dart @@ -0,0 +1,907 @@ +// CSS `position: relative`, measured through real geometry: fragment offsets, +// sibling positions, painted pixels, and hit-testing at the moved location. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +import 'support/geometry_probe.dart'; + +const _rel = 'position:relative;'; + +/// How far [text] moved between [plain] and [moved] (both full HTML documents). +Future _shift( + WidgetTester tester, + String plain, + String moved, + String text, +) async { + await pumpHtml(tester, plain); + final before = fragmentTopLeft(tester, text); + await pumpHtml(tester, moved); + return fragmentTopLeft(tester, text) - before; +} + +/// Shift of a block's text given its inline [style]. +Future _blockShift(WidgetTester tester, String style) => _shift( + tester, + '

Z

AAA
', + '

Z

AAA
', + 'AAA', + ); + +void main() { + group('offsets', () { + testWidgets('a block moves by top and left', (tester) async { + expect(await _blockShift(tester, '${_rel}top:30px;left:20px'), + const Offset(20, 30)); + }); + + testWidgets('an inline span moves by top and left', (tester) async { + expect( + await _shift( + tester, + '

XXAAAYY

', + '

XXAAAYY

', + 'AAA', + ), + const Offset(20, 30), + ); + }); + + testWidgets('bottom and right move the opposite way', (tester) async { + expect(await _blockShift(tester, '${_rel}bottom:10px;right:5px'), + const Offset(-5, -10)); + }); + + testWidgets('negative offsets', (tester) async { + expect(await _blockShift(tester, '${_rel}top:-10px;left:-5px'), + const Offset(-5, -10)); + }); + + testWidgets('top wins over bottom, left wins over right', (tester) async { + expect( + await _blockShift( + tester, '${_rel}top:30px;bottom:9px;left:20px;right:9px'), + const Offset(20, 30), + ); + }); + + testWidgets('em, rem and pt lengths resolve', (tester) async { + expect( + await _blockShift(tester, '${_rel}left:1rem'), const Offset(16, 0)); + expect(await _blockShift(tester, '${_rel}left:1em'), const Offset(16, 0)); + }); + + testWidgets('auto, percentages and garbage do not move it', (tester) async { + expect( + await _blockShift(tester, '${_rel}top:auto;left:auto'), Offset.zero); + expect(await _blockShift(tester, '${_rel}top:10%;left:10%'), Offset.zero); + expect(await _blockShift(tester, '${_rel}top:banana'), Offset.zero); + }); + + testWidgets('a later top:auto cancels an earlier top', (tester) async { + expect( + await _blockShift(tester, '${_rel}top:30px;top:auto'), Offset.zero); + }); + + testWidgets('it applies through a stylesheet', (tester) async { + expect( + await _shift( + tester, + '

Z

AAA
', + '' + '

Z

AAA
', + 'AAA', + ), + const Offset(0, 30), + ); + }); + + for (final position in ['static', 'absolute', 'fixed', 'sticky']) { + testWidgets('position:$position ignores the offsets', (tester) async { + expect( + await _blockShift(tester, 'position:$position;top:30px;left:20px'), + Offset.zero, + ); + }); + } + + testWidgets('offsets without position do nothing', (tester) async { + expect(await _blockShift(tester, 'top:30px;left:20px'), Offset.zero); + }); + + testWidgets('nested relative boxes add up', (tester) async { + expect( + await _shift( + tester, + '
AAA
', + '
' + '
AAA
', + 'AAA', + ), + const Offset(3, 30), + ); + }); + + testWidgets('descendants move with the box, but offsets do not inherit', + (tester) async { + expect( + await _shift( + tester, + '
AAA
', + // The inner elements declare `relative` with no offsets of their own: + // an inherited `top` would move the text twice. + '
' + '
AAA
', + 'AAA', + ), + const Offset(0, 10), + ); + }); + + testWidgets('every wrapped line of a block moves', (tester) async { + final word = 'W' * 60; + await pumpHtml(tester, '
$word
'); + final plain = lineFragments(tester); + await pumpHtml( + tester, '
$word
'); + final moved = lineFragments(tester); + expect(plain.length, greaterThan(1)); + expect(moved.length, plain.length); + for (var i = 0; i < plain.length; i++) { + expect(moved[i]['offsetX'], (plain[i]['offsetX'] as double) + 7); + expect(moved[i]['offsetY'], (plain[i]['offsetY'] as double) + 3); + } + }); + + testWidgets('a list item marker moves with its item', (tester) async { + await pumpHtml(tester, '
  • AAA
'); + final plain = await capture(tester); + final plainX = + plain.extent((r, g, b) => r < 80 && g < 80 && b < 80)!.left; + await pumpHtml(tester, '
  • AAA
'); + final moved = await capture(tester); + expect( + moved.extent((r, g, b) => r < 80 && g < 80 && b < 80)!.left, + plainX + 30, + ); + }); + + test('ComputedStyle.relativeOffset follows the writing direction', () { + final style = ComputedStyle( + position: 'relative', + insetLeft: 20, + insetRight: 5, + insetTop: 30, + insetBottom: 9, + ); + expect(style.relativeOffset(), const Offset(20, 30)); + expect(style.relativeOffset(rtl: true), const Offset(-5, 30)); + expect(ComputedStyle(insetLeft: 20).relativeOffset(), Offset.zero); + }); + }); + + group('layout is untouched', () { + Future expectSameFlow( + WidgetTester tester, + String Function(String style) html, + List siblings, + ) async { + await pumpHtml(tester, html('')); + final height = viewerHeight(tester); + final before = {for (final s in siblings) s: fragmentTopLeft(tester, s)}; + + await pumpHtml(tester, html('${_rel}top:300px;left:50px')); + expect(viewerHeight(tester), height); + for (final s in siblings) { + expect(fragmentTopLeft(tester, s), before[s], reason: s); + } + } + + testWidgets('following blocks and box height, after a block', + (tester) async { + await expectSameFlow( + tester, + (s) => '

BEFORE

AAA

AFTER

', + ['BEFORE', 'AFTER'], + ); + }); + + testWidgets('following text, after an inline span', (tester) async { + await expectSameFlow( + tester, + (s) => '

XXAAAYY

AFTER

', + ['XX', 'YY', 'AFTER'], + ); + }); + + testWidgets('a table inside the box moves without changing the height', + (tester) async { + const html = + '
cell
'; + await pumpHtml(tester, html.replaceFirst('%S', '')); + final height = viewerHeight(tester); + final table = find.byType(SmartTableWrapper); + final before = tester.getTopLeft(table); + + await pumpHtml( + tester, html.replaceFirst('%S', '${_rel}top:40px;left:9px')); + expect(viewerHeight(tester), height); + expect(tester.getTopLeft(table) - before, const Offset(9, 40)); + }); + + testWidgets('a flex container moves without changing the height', + (tester) async { + const html = '

Z

AAA
'; + await pumpHtml(tester, html.replaceFirst('%S', '')); + final height = viewerHeight(tester); + final flex = find.byType(FlexContainerWidget); + final before = tester.getTopLeft(flex); + + await pumpHtml( + tester, html.replaceFirst('%S', '${_rel}top:40px;left:9px')); + expect(viewerHeight(tester), height); + expect(tester.getTopLeft(flex) - before, const Offset(9, 40)); + }); + + testWidgets('re-running layout does not compound the shift', + (tester) async { + const html = '
S

body

' + '
' + 'AAA
'; + await pumpHtml(tester, html); + final closedText = fragmentOf(tester, 'AAA'); + final closedRed = (await capture(tester)).extent(isRed)!; + + final details = find.byType(HyperDetailsWidget); + final tapAt = tester.getTopLeft(details) + const Offset(30, 10); + await tester.tapAt(tapAt); + await tester.pumpAndSettle(); + expect(fragmentOf(tester, 'AAA')['offsetX'], closedText['offsetX']); + expect((await capture(tester)).extent(isRed)!.left, closedRed.left); + + await tester.tapAt(tapAt); + await tester.pumpAndSettle(); + expect(fragmentOf(tester, 'AAA')['offsetX'], closedText['offsetX']); + expect(fragmentOf(tester, 'AAA')['offsetY'], closedText['offsetY']); + expect((await capture(tester)).extent(isRed)!.top, closedRed.top); + }); + }); + + group('painting', () { + testWidgets('a block background moves with the box', (tester) async { + await pumpHtml( + tester, + '

Z

' + 'A

Z

Z

Z

'); + final plain = (await capture(tester)).extent(isRed)!; + await pumpHtml( + tester, + '

Z

A

Z

Z

Z

'); + final moved = (await capture(tester)).extent(isRed)!; + expect(moved.left, plain.left + 20); + expect(moved.right, plain.right + 20); + expect(moved.top, plain.top + 30); + expect(moved.width, plain.width); + }); + + testWidgets('an inline background moves with its span', (tester) async { + const span = 'background:$probeRed;padding:0 9px'; + // Trailing paragraphs keep the moved span inside the clipped box. + const tail = '

Z

Z

Z

'; + await pumpHtml(tester, '

XXA

$tail'); + final plain = (await capture(tester)).extent(isRed)!; + await pumpHtml(tester, + '

XXA

$tail'); + final moved = (await capture(tester)).extent(isRed)!; + // The right edge: the left padding sits behind "XX" until the span moves. + expect(moved.right, plain.right + 20); + expect(moved.top, plain.top + 30); + }); + + testWidgets('glyphs are painted at the moved position', (tester) async { + await pumpHtml( + tester, + '

Z

' + 'AAA

Z

Z

'); + final red = (await capture(tester)).extent(isRed)!; + expect(red.left, 20); + expect(red.right, 67); + expect(red.top, 78); + }); + + testWidgets('text from a line far below is painted where it lands', + (tester) async { + // Moved text gets its own paint pass, culled by its own rect rather than + // its line's. Note this can't prove the culling half: the box composites, + // so its canvas clip is unbounded and per-line culling doesn't bite in a + // widget test. It does fail if that separate pass is removed. + final filler = '
x
' * 60; + await pumpHtml(tester, + '$filler
AAA
'); + final red = (await capture(tester)).extent(isRed); + expect(red, isNotNull); + expect(red!.top, 160); + }); + + Future sandwich( + WidgetTester tester, + String html, { + bool settle = true, + }) async { + await tester.binding.setSurfaceSize(const Size(400, 600)); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: probeBoundaryKey, + child: SizedBox( + width: 400, + height: 600, + child: Column( + children: [ + Container(height: 100, color: Colors.white), + // Nothing below: a widget here would paint over any overflow + // and hide what the "below" test is looking for. + HyperViewer(html: html, mode: HyperRenderMode.sync), + ], + ), + ), + ), + ), + ), + )); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(const Duration(milliseconds: 100)); + } + return capture(tester); + } + + testWidgets('content cannot paint over the host app above the viewer', + (tester) async { + final pixels = await sandwich( + tester, + '
A
', + ); + // Clipped to the viewer, whose first pixel row is y=100. + final red = pixels.extent(isRed); + if (red != null) expect(red.top, greaterThanOrEqualTo(100)); + }); + + testWidgets('content cannot paint over the host app below the viewer', + (tester) async { + await pumpHtml(tester, '
A
'); + final height = viewerHeight(tester); + final pixels = await sandwich( + tester, + '
A
', + ); + final red = pixels.extent(isRed); + if (red != null) { + expect(red.bottom, lessThan(100 + height)); + } + }); + + testWidgets('a small shift stays visible inside the box', (tester) async { + final pixels = await sandwich( + tester, + '
' + '
A
', + ); + final red = pixels.extent(isRed)!; + expect(red.left, 40); + expect(red.top, 140); + }); + + for (final (name, html) in [ + ( + 'a flex item', + '
A
' + ), + ( + 'a grid item', + '
' + '
A' + '
' + ), + ( + 'an empty block', + '
' + '

x

' + ), + ( + 'a parent whose child cancels its offset', + '
' + '
A
' + ), + ( + 'an animated block', + '' + '
A

x

' + ), + ]) { + testWidgets('$name cannot paint over the host app', (tester) async { + // A running canvas animation never settles. + final pixels = + await sandwich(tester, html, settle: !html.contains('@keyframes')); + final red = pixels.extent(isRed); + if (red != null) expect(red.top, greaterThanOrEqualTo(100)); + }); + } + + testWidgets('a nearby relative span does not crop unrelated overflow', + (tester) async { + const shadowed = '
x
'; + final plain = await sandwich(tester, '

ab

$shadowed'); + final withSpan = await sandwich( + tester, + '

ab

$shadowed', + ); + expect(withSpan.extent(isRed)?.bottom, plain.extent(isRed)!.bottom); + expect(hyperBoxes(tester).first.debugHasDisplacedContent, isTrue); + }); + + testWidgets('a moved background paints above normal text', (tester) async { + await pumpHtml( + tester, + '
AAA
' + '
BBBBBBBB

Z

', + ); + final pixels = await capture(tester); + // Column 100 is inside "BBBBBBBB" and clear of the moved "AAA". + final (r, g, b) = pixels.at(100, 24); + expect(isRed(r, g, b), isTrue); + }); + + testWidgets('an outer inline background stays on its line', (tester) async { + await pumpHtml( + tester, + '

A ' + 'B C

' + '

Z

Z

Z

Z

', + ); + final red = (await capture(tester)).extent(isRed)!; + final top = fragmentOf(tester, 'A')['offsetY'] as double; + expect(red.bottom, lessThan(top + 30)); + }); + + testWidgets('a relative span with its own background moves it alone', + (tester) async { + await pumpHtml( + tester, + '

A ' + 'B C

Z

Z

Z

Z

', + ); + final red = (await capture(tester)).extent(isRed)!; + final a = fragmentOf(tester, 'A')['offsetY'] as double; + expect(red.top, greaterThanOrEqualTo(a + 60)); + }); + + testWidgets('no relative content: no clip layer is pushed', (tester) async { + // Normal documents must keep painting exactly as before. + final pixels = await sandwich(tester, '
AAA
'); + expect(pixels, isNotNull); + expect(hyperBoxes(tester).first.debugHasDisplacedContent, isFalse); + }); + }); + + group('hit-testing and selection', () { + testWidgets('a character resolves at its moved position', (tester) async { + await pumpHtml( + tester, '
AAAA
BBBB
'); + final box = hyperBoxes(tester).first; + final start = fragmentOf(tester, 'BBBB')['charStart'] as int; + final y = fragmentOf(tester, 'BBBB')['offsetY'] as double; + // Just inside the third glyph, nearest its leading edge: x = 100 + 2.25×16. + expect(box.getCharacterPositionAtOffset(Offset(136, y + 8)), start + 2); + }); + + testWidgets('moved vertically into another line band it still resolves', + (tester) async { + await pumpHtml( + tester, + '
AAAA
BBBB
' + '
CCCC
', + ); + final box = hyperBoxes(tester).first; + final c = fragmentOf(tester, 'CCCC'); + expect(c['offsetY'], 0); + expect( + box.getCharacterPositionAtOffset(const Offset(120, 8)), + (c['charStart'] as int) + 1, + ); + // AAAA, which owns that band, still resolves on its own glyphs. + final a = fragmentOf(tester, 'AAAA'); + expect( + box.getCharacterPositionAtOffset(const Offset(20, 8)), + (a['charStart'] as int) + 1, + ); + }); + + testWidgets('a link is tappable where it is painted, not where it was', + (tester) async { + final tapped = []; + await tester.binding.setSurfaceSize(const Size(400, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: probeBoundaryKey, + width: 400, + child: HyperViewer( + html: '
AAAA
BBBB
CCCC
' + '
' + 'LINK
', + mode: HyperRenderMode.sync, + onLinkTap: tapped.add, + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final origin = tester.getTopLeft(find.byType(HyperViewer)); + final link = fragmentOf(tester, 'LINK'); + expect(link['offsetY'], 0); + + await tester + .tapAt(origin + const Offset(10, 56)); // where it was laid out + await tester.pump(const Duration(milliseconds: 500)); + expect(tapped, isEmpty); + + await tester.tapAt(origin + const Offset(116, 8)); // where it is painted + await tester.pump(const Duration(milliseconds: 500)); + expect(tapped, ['https://example.com/x']); + }); + + testWidgets('getBoxesForCharRange reports the moved rect', (tester) async { + await pumpHtml(tester, '
AAAA
BBBB
'); + final plainStart = fragmentOf(tester, 'BBBB')['charStart'] as int; + final plain = hyperBoxes(tester) + .first + .getBoxesForCharRange(plainStart, plainStart + 4) + .single; + + await pumpHtml(tester, + '
AAAA
BBBB
'); + final start = fragmentOf(tester, 'BBBB')['charStart'] as int; + final moved = hyperBoxes(tester) + .first + .getBoxesForCharRange(start, start + 4) + .single; + expect(moved.topLeft - plain.topLeft, const Offset(60, 50)); + expect(moved.size, plain.size); + }); + + testWidgets('selection rects and the highlight follow the moved text', + (tester) async { + await pumpHtml( + tester, '
BBBB

Z

'); + final box = hyperBoxes(tester).first; + final start = fragmentOf(tester, 'BBBB')['charStart'] as int; + box.selection = HyperTextSelection(start: start, end: start + 4); + final rects = box.getSelectionRects(); + expect(rects, isNotEmpty); + expect(rects.first.left, 60); + expect(rects.first.top, greaterThanOrEqualTo(50)); + }); + + testWidgets('selected text keeps document order', (tester) async { + await pumpHtml( + tester, + '
AAAA
' + '
BBBB
CCCC
'); + final box = hyperBoxes(tester).first; + box.selection = const HyperTextSelection(start: 0, end: 12 + 2); + final text = box.getSelectedText()!; + expect(text.replaceAll('\n', ''), 'AAAABBBBCCCC'); + }); + }); + + group('each fragment is drawn exactly once', () { + List draws(WidgetTester tester) => + paragraphOrigins(hyperBoxes(tester).first); + + testWidgets('a trimmed trailing space leaves no left-aligned copy', + (tester) async { + await pumpHtml( + tester, + '
Hello
', + ); + expect(draws(tester), hasLength(lineFragments(tester).length)); + expect(draws(tester).every((o) => o.dx > 0), isTrue); + }); + + testWidgets('text before a wrapped word is not drawn twice', + (tester) async { + await pumpHtml( + tester, + '

Hello ${'w' * 60}

', + ); + final origins = draws(tester); + expect(origins, hasLength(lineFragments(tester).length)); + expect(origins.toSet(), hasLength(origins.length)); + }); + + testWidgets('a list marker is drawn once', (tester) async { + await pumpHtml(tester, '
  • AAA
'); + final plain = draws(tester).length; + await pumpHtml(tester, '
  • AAA
'); + expect(draws(tester), hasLength(plain)); + }); + + testWidgets('narrowing the box leaves no drifting copy', (tester) async { + const html = '

Z

' + 'short text here
'; + // Replaced first, so this render object starts fresh at 150px. + await pumpHtml(tester, '

other

', width: 150); + await pumpHtml(tester, html, width: 150); + final fresh = draws(tester).toSet(); + + await pumpHtml(tester, '

other

', width: 400); + await pumpHtml(tester, html, width: 400); + await pumpHtml(tester, html, width: 150); + expect(draws(tester).toSet(), fresh); + await pumpHtml(tester, html, width: 149); + await pumpHtml(tester, html, width: 150); + expect(draws(tester).toSet(), fresh); + }); + + testWidgets('no fragment gains the shift again on relayout', + (tester) async { + // Includes fragments replaced by their split pieces, which line layout + // doesn't re-place and which aren't drawn, so only their offset shows it. + const html = '
short text here
'; + // Wide first: the whole text gets placed, then is split when narrowed. + await pumpHtml(tester, html, width: 400); + await pumpHtml(tester, html, width: 150); + List offsets() => [ + for (final f in hyperBoxes(tester).first.debugFragments()) + f['offsetX'] as double?, + ]; + final first = offsets(); + await pumpHtml(tester, html, width: 149); + await pumpHtml(tester, html, width: 150); + expect(offsets(), first); + }); + + testWidgets('a details toggle leaves no drifting copy', (tester) async { + const html = '
S

body

' + '
${'short text here '}' + '${'short text here '}${'short text here '}
'; + await pumpHtml(tester, html); + final closed = draws(tester).length; + final details = find.byType(HyperDetailsWidget); + final tapAt = tester.getTopLeft(details) + const Offset(30, 10); + await tester.tapAt(tapAt); + await tester.pumpAndSettle(); + await tester.tapAt(tapAt); + await tester.pumpAndSettle(); + expect(draws(tester), hasLength(closed)); + }); + }); + + group('font-relative offsets', () { + testWidgets('em uses the element font size, in either order', + (tester) async { + await pumpHtml(tester, '

Z

AAA
'); + final plainX = fragmentTopLeft(tester, 'AAA').dx; + for (final style in [ + 'font-size:40px;${_rel}left:1em', + '${_rel}left:1em;font-size:40px', + ]) { + await pumpHtml(tester, '

Z

AAA
'); + expect(fragmentTopLeft(tester, 'AAA').dx - plainX, 40, reason: style); + } + }); + + testWidgets('em uses an inherited font size', (tester) async { + await pumpHtml( + tester, + '
' + 'AAA
', + ); + expect(fragmentTopLeft(tester, 'AAA').dx, 40); + }); + + testWidgets('rem stays on the root font size', (tester) async { + await pumpHtml( + tester, + '
AAA
', + ); + expect(fragmentTopLeft(tester, 'AAA').dx, 16); + }); + }); + + group('floats', () { + testWidgets('text in a floated block moves with its relative parent', + (tester) async { + const float = '
inside' + '
sibling'; + await pumpHtml(tester, '
$float
'); + final inside = fragmentTopLeft(tester, 'inside'); + final sibling = fragmentTopLeft(tester, 'sibling'); + + await pumpHtml( + tester, '
$float
'); + expect(fragmentTopLeft(tester, 'inside') - inside, const Offset(20, 30)); + expect( + fragmentTopLeft(tester, 'sibling') - sibling, const Offset(20, 30)); + }); + }); + + group('widget path', () { + testWidgets('a flex item moves and its sibling stays', (tester) async { + const html = '
' + '
AAA
BBB
'; + await pumpHtml(tester, html.replaceFirst('%S', '')); + final a = tester.getTopLeft(find.text('AAA')); + final b = tester.getTopLeft(find.text('BBB')); + final height = viewerHeight(tester); + + await pumpHtml( + tester, html.replaceFirst('%S', '${_rel}top:30px;left:20px')); + expect(tester.getTopLeft(find.text('AAA')) - a, const Offset(20, 30)); + expect(tester.getTopLeft(find.text('BBB')), b); + expect(viewerHeight(tester), height); + }); + + testWidgets('a flex item follows the writing direction', (tester) async { + Future position(String style, TextDirection direction) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: HyperViewer( + html: '
' + '
AAA
BBB
', + mode: HyperRenderMode.sync, + textDirection: direction, + ), + ), + ), + )); + await tester.pumpAndSettle(); + return tester.getTopLeft(find.text('AAA')); + } + + const both = '${_rel}left:5px;right:7px'; + for (final (direction, dx) in [ + (TextDirection.ltr, 5.0), + (TextDirection.rtl, -7.0), + ]) { + final plain = await position('', direction); + expect((await position(both, direction)).dx - plain.dx, dx); + } + }); + + testWidgets('a grid item moves and its sibling stays', (tester) async { + const html = '
' + '
AAA
BBB
'; + await pumpHtml(tester, html.replaceFirst('%S', '')); + final a = tester.getTopLeft(find.text('AAA')); + final b = tester.getTopLeft(find.text('BBB')); + + await pumpHtml( + tester, html.replaceFirst('%S', '${_rel}top:30px;left:20px')); + expect(tester.getTopLeft(find.text('AAA')) - a, const Offset(20, 30)); + expect(tester.getTopLeft(find.text('BBB')), b); + }); + }); + + group('combinations', () { + testWidgets('virtualized mode moves content inside its chunk', + (tester) async { + Future position(String style) async { + // Virtualized builds a ListView, so no scroll view around it. + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + key: probeBoundaryKey, + width: 400, + height: 600, + child: HyperViewer( + html: '

Z

AAA
' + '

Z

Z

Z

', + mode: HyperRenderMode.virtualized, + ), + ), + ), + )); + // The loading skeleton shimmers until parsing ends, so it never settles. + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + final box = hyperBoxes(tester).firstWhere( + (b) => b.debugLineFragments().any((f) => f['text'] == 'AAA')); + final f = + box.debugLineFragments().firstWhere((f) => f['text'] == 'AAA'); + return Offset(f['offsetX'] as double, f['offsetY'] as double); + } + + final plain = await position(''); + final moved = await position('${_rel}left:20px;top:10px'); + expect(moved - plain, const Offset(20, 10)); + }); + + testWidgets('a details body does not pick up the shift a second time', + (tester) async { + // The body is its own RenderHyperBox, moved once as a child widget of the + // outer box. Its own pass must count only offsets from inside it. + Future<(double, double)> measure(String outer) async { + await pumpHtml( + tester, + '
S' + '

INNER

', + ); + final box = hyperBoxes(tester).firstWhere( + (b) => b.debugLineFragments().any((f) => f['text'] == 'INNER')); + final inner = box + .debugLineFragments() + .firstWhere((f) => f['text'] == 'INNER')['offsetX'] as double; + return (inner, box.localToGlobal(Offset.zero).dx); + } + + final (plainInner, plainBox) = await measure(''); + final (movedInner, movedBox) = await measure('${_rel}left:30px'); + expect(movedInner, plainInner); + expect(movedBox - plainBox, 30); + }); + + testWidgets('a table cell ignores position, as documented', (tester) async { + const html = '
cell
'; + await pumpHtml(tester, html.replaceFirst('%S', '')); + final before = tester.getTopLeft(find.textContaining('cell')); + await pumpHtml(tester, html.replaceFirst('%S', '${_rel}left:20px')); + expect(tester.getTopLeft(find.textContaining('cell')), before); + }); + + testWidgets('an animated relative block paints and stays in place', + (tester) async { + await pumpHtml( + tester, + '

Z

' + '
AAA
', + settle: false, + ); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + // Mid-animation the red is translucent over white, so match "reddish". + final tinted = (await capture(tester)) + .extent((r, g, b) => r > 200 && r - g > 40 && r - b > 40)!; + expect(tinted.left, 30); + expect(fragmentOf(tester, 'AAA')['offsetX'], 34); + }); + + testWidgets('box-sizing and position combine', (tester) async { + await pumpHtml( + tester, + '
A
', + ); + final red = (await capture(tester)).extent(isRed)!; + expect(red.left, 30); + expect(red.right, 229); + }); + }); +} diff --git a/test/style/css_execution_guard_test.dart b/test/style/css_execution_guard_test.dart index ba8709c..4426c02 100644 --- a/test/style/css_execution_guard_test.dart +++ b/test/style/css_execution_guard_test.dart @@ -104,6 +104,24 @@ void main() { expect(half, greaterThan(base)); }); + testWidgets('box-sizing:border-box narrows the content', (tester) async { + final word = 'W' * 60; + final content = await _height( + tester, '
$word
'); + final border = await _height( + tester, + '
$word' + '
'); + expect(border, greaterThan(content)); + }); + + testWidgets('position:relative shifts the fragment', (tester) async { + final plain = await _firstTextX(tester, '
AAA
'); + final moved = await _firstTextX( + tester, '
AAA
'); + expect(moved, plain + 30); + }); + testWidgets('text-align:center shifts first fragment right', (tester) async { final left = await _firstTextX(tester, '

short

'); diff --git a/test/support/geometry_probe.dart b/test/support/geometry_probe.dart new file mode 100644 index 0000000..6e0adff --- /dev/null +++ b/test/support/geometry_probe.dart @@ -0,0 +1,165 @@ +// Measures what HyperViewer actually rendered: laid-out fragment geometry from +// RenderHyperBox.debugLineFragments and raw pixels from a RepaintBoundary. +// Shared by the box-sizing / position / first-block-decoration tests, which all +// need real geometry rather than ComputedStyle values. + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +const probeBoundaryKey = ValueKey('geometry-probe'); +const probeRed = 'rgb(255,0,0)'; + +/// Pure-colour match with room for antialiasing. +typedef PixelTest = bool Function(int r, int g, int b); + +bool isRed(int r, int g, int b) => r > 200 && g < 60 && b < 60; +bool isBlue(int r, int g, int b) => b > 200 && r < 60 && g < 60; + +/// A horizontal+vertical extent of the pixels matching a colour. +class PixelExtent { + const PixelExtent(this.left, this.right, this.top, this.bottom); + final int left, right, top, bottom; + int get width => right - left + 1; + int get height => bottom - top + 1; + @override + String toString() => 'x:[$left,$right] y:[$top,$bottom]'; +} + +class ProbePixels { + ProbePixels(this.bytes, this.width, this.height); + final ByteData bytes; + final int width, height; + + (int, int, int) at(int x, int y) { + final i = (y * width + x) * 4; + return (bytes.getUint8(i), bytes.getUint8(i + 1), bytes.getUint8(i + 2)); + } + + /// Extent of matching pixels, or null when none match. + PixelExtent? extent(PixelTest match) { + int? left, right, top, bottom; + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + final (r, g, b) = at(x, y); + if (!match(r, g, b)) continue; + if (left == null || x < left) left = x; + if (right == null || x > right) right = x; + top ??= y; + bottom = y; + } + } + return left == null ? null : PixelExtent(left, right!, top!, bottom!); + } +} + +/// Pumps [html] in a fixed 400×600 surface at 1:1 pixels. +Future pumpHtml( + WidgetTester tester, + String html, { + double width = 400, + String? customCss, + bool settle = true, +}) async { + await tester.binding.setSurfaceSize(const Size(400, 600)); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: probeBoundaryKey, + child: ColoredBox( + color: Colors.white, + child: SizedBox( + width: width, + height: 600, + child: SingleChildScrollView( + child: HyperViewer( + html: html, + customCss: customCss, + mode: HyperRenderMode.sync, + ), + ), + ), + ), + ), + ), + ), + )); + // A canvas animation keeps scheduling frames, so pumpAndSettle can't return. + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } +} + +/// Every RenderHyperBox under [root] (default: the probe boundary). +List hyperBoxes(WidgetTester tester, {RenderObject? root}) { + final found = []; + void walk(RenderObject r) { + if (r is RenderHyperBox) found.add(r); + r.visitChildren(walk); + } + + walk(root ?? tester.renderObject(find.byKey(probeBoundaryKey))); + return found; +} + +/// Laid-out fragments of the outermost RenderHyperBox. +List> lineFragments(WidgetTester tester) => + hyperBoxes(tester).first.debugLineFragments(); + +/// First fragment whose text starts with [text]. +Map fragmentOf(WidgetTester tester, String text) => + lineFragments(tester).firstWhere( + (f) => (f['text'] as String?)?.startsWith(text) ?? false, + orElse: () => throw StateError('no fragment starts with "$text"'), + ); + +Offset fragmentTopLeft(WidgetTester tester, String text) { + final f = fragmentOf(tester, text); + return Offset(f['offsetX'] as double, f['offsetY'] as double); +} + +/// Characters laid out on the first line of the first text fragment. +int firstLineChars(WidgetTester tester) => + (lineFragments(tester).first['text'] as String).length; + +double viewerHeight(WidgetTester tester) => + tester.getSize(find.byType(HyperViewer)).height; + +Future capture(WidgetTester tester) async { + final boundary = tester.renderObject(find.byKey(probeBoundaryKey)) + as RenderRepaintBoundary; + final result = await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = await image.toByteData(); + return ProbePixels(bytes!, image.width, image.height); + }); + return result!; +} + +/// Where [box] draws each paragraph, in paint order. Fragment geometry can't +/// show a fragment drawn twice or a stale one drawn at all; this can. +List paragraphOrigins(RenderHyperBox box) { + final canvas = _UnboundedRecordingCanvas(); + box.paint(TestRecordingPaintingContext(canvas), Offset.zero); + return [ + for (final call in canvas.invocations) + if (call.invocation.memberName == #drawParagraph) + call.invocation.positionalArguments[1] as Offset, + ]; +} + +class _UnboundedRecordingCanvas extends TestRecordingCanvas { + @override + Rect getLocalClipBounds() => + const Rect.fromLTWH(-100000, -100000, 200000, 200000); +}