Skip to content

feat: v1.1.2 — binary search selection, devtools v1.0.0, 3-pipeline CI#2

Merged
vietnguyentuan2019 merged 26 commits intomainfrom
refactor/improve
Mar 25, 2026
Merged

feat: v1.1.2 — binary search selection, devtools v1.0.0, 3-pipeline CI#2
vietnguyentuan2019 merged 26 commits intomainfrom
refactor/improve

Conversation

@vietnguyentuan2019
Copy link
Contributor

Summary

  • O(log N) binary search for text selection hit-testing — instant response on 1,000-line documents via _lineStartOffsets[] precomputed at layout
  • Ruby clipboard format base(ふりがな) for fully-selected ruby fragments; 5 ruby selection pipeline bugs fixed
  • hyper_render_devtools v1.0.0 first full release — UDT Tree inspector, Computed Style panel, Float region visualizer, demo mode (no live app required)
  • 3-pipeline CI architecture — Pre-flight (< 2 min) · Core Validation (per-package selective on PR, full matrix on push) · Visual/Performance gates
  • Layout regression CI guard — 6 HTML fixtures with hard 16 ms (60 FPS) budgets, fails PR on any regression
  • 9 new golden tests — Float layout, RTL/BiDi, CJK+Ruby, all pinned to ubuntu-22.04 + Noto fonts
  • Documentation — README updated (CI badges, devtools table, architecture), ROADMAP synced, .gitignore fixed

Changes by area

Core engine (hyper_render_core)

  • render_hyper_box.dart_lineStartOffsets[] field, cleared on layout invalidation
  • render_hyper_box_layout.dart_buildCharacterMapping() populates per-line start offsets
  • render_hyper_box_selection.dart_lineIndexAt() binary search, O(log N) _findFragmentAtPosition / _getCharacterPositionAtOffset, ruby clipboard format

DevTools (hyper_render_devtools)

  • devtools_ui/lib/main.dart — demo mode with sample UDT tree, fragments, style data
  • Added README.md, CHANGELOG.md, LICENSE, example/example.dart

CI/CD (.github/workflows/)

  • analyze.yml — rewritten as Pre-flight: dorny/paths-filter, pub cache, format + analyze, skip on docs-only
  • test.yml — rewritten: test-pr (ubuntu-22.04, selective) + test-matrix (3-OS × 2-channel on push)
  • coverage.yml — push-to-main only, pinned Flutter 3.29.2
  • golden.yml — pinned ubuntu-22.04 + Noto fonts, update-goldens dispatch job
  • benchmark.yml — layout regression guard + weekly full-benchmark

Tests & Docs

  • test/golden/critical_layouts_test.dart — 9 new cases: float left/right/clear, RTL Arabic/Hebrew/mixed, CJK ruby/kinsoku/float+CJK
  • benchmark/layout_regression.dart — new file: 6 fixtures, 3 warmup + 10 measured runs, hard expect() thresholds
  • README.md — CI badges, devtools row, O(log N) + layout CI guard in architecture, roadmap updated
  • .gitignore.metadata, .flutter-plugins, benchmark/results/, analyze_report.txt, pubspec_publish_ready.yaml

Test plan

  • CI Pre-flight passes (format + analyze, 0 issues)
  • test-pr passes on ubuntu-22.04
  • Golden tests pass (all 9 new fixtures generate reference PNGs)
  • Layout regression benchmark passes (all 6 fixtures ≤ their ms budget)
  • DevTools demo mode loads without a live app
  • scripts/publish.sh dry-run passes for all 6 packages

Nguyễn Tuấn Việt and others added 25 commits March 23, 2026 15:12
… parentDataDirty assertion

assembleSemanticsNode() was calling _buildSemanticNodes() which created
brand-new SemanticsNode() objects on every call.  Newly created nodes are
not attached to the SemanticsOwner, so node.updateWith() calls _adoptChild()
on them → parentDataDirty = true.  PipelineOwner.flushSemantics() then walks
the full tree in debug mode and fires:

  '!semantics.parentDataDirty': is not true

Fix: simplify assembleSemanticsNode() to only forward the children already
provided by Flutter's pipeline (child RenderObjects such as HyperDetailsWidget,
HyperTable, CodeBlockWidget).  The full text label set in
describeSemanticsConfiguration() remains readable for TalkBack / VoiceOver.

Also removed the now-dead _buildSemanticNodes(), _buildNodeRectCache(),
_nodeRectCache, and related helpers to keep the codebase clean.
- computeMinIntrinsicWidth: reuse two shared TextPainter instances (LTR
  and RTL) across all fragments instead of allocating one per fragment,
  then dispose both after the loop.  Reduces native object churn on large
  tables that call this method per cell.

- didHaveMemoryPressure: extend cleanup to also drain LazyImageQueue's
  pending (not-yet-started) load queue and clear Flutter's own decoded-
  image cache (PaintingBinding.imageCache).  Previously only RenderHyperBox
  caches were cleared, leaving pending network loads and GPU textures
  uncollected on low-memory devices.

- LazyImageQueue.clearPending(): new public method that drains the pending
  queue without touching in-flight loads.  Called by didHaveMemoryPressure
  and available to callers that need finer-grained memory control.

- _evaluateCalcExpr: emit a debugPrint (debug builds only, via assert) when
  a calc() expression contains a % unit that cannot be resolved at parse
  time, so developers can detect which expressions fall back silently.
Previously the devtools package existed but was entirely non-functional:
renderers were never registered (registry always empty), the UI showed
hardcoded placeholder data, and the devtools_extensions SDK was commented
out.

This commit wires all the pieces together:

HyperRenderDebugHooks (new, in hyper_render_core):
  Static callback slots that RenderHyperBox calls at attach/detach/
  performLayout. Avoids circular dependency — hyper_render_core never
  imports hyper_render_devtools. Zero overhead in release builds (all
  guards are behind kDebugMode / null checks).

RenderHyperBox auto-registration:
  - Stable _debugId per instance (identityHashCode-based)
  - attach()  → HyperRenderDebugHooks.onRendererAttached
  - detach()  → HyperRenderDebugHooks.onRendererDetached  (new override)
  - performLayout end → HyperRenderDebugHooks.onLayoutComplete (lazy
    getFragments/getLines getters — serialised only when DevTools reads)

service_extensions.dart:
  - register() now injects all three hooks automatically — no per-widget
    setup required from the user
  - Added ext.hyperRender.getFragments — returns fragment list + line
    list from the last layout pass
  - Added ext.hyperRender.getPerformance — returns fragment/line counts
    as baseline; pluggable via HyperRenderDebugHooks.getPerformanceData
    for full PerformanceMonitor timing
  - Switched from manual JSON string building to dart:convert jsonEncode
  - Registry stores lastFragments/lastLines per renderer

devtools_ui/main.dart (full rewrite):
  - Wraps app in DevToolsExtension widget (devtools_extensions SDK)
  - All three tabs call real service extensions via
    serviceManager.callServiceExtensionOnMainIsolate
  - Renderer dropdown — select among multiple active renderers
  - UDT Tree: clicking a node calls getNodeStyle and switches to Style tab
  - Style: shows actual computed style from the running app
  - Layout: shows real fragment list (capped at 200 rows) + line list +
    performance summary

devtools_ui/pubspec.yaml:
  - Added devtools_extensions: ^0.2.0
HyperRender is read-only — it does not support <form>, <input>,
<select>, <textarea>, or submit buttons. This is a frequent BA gap
when requirements include "an article with an embedded survey at the
bottom."

Changes:
- Library-level doc block: explains the read-only constraint and
  three recommended decision patterns:
    A. WebView fallback for the whole screen
    B. Native Flutter Form below HyperRender (preferred for new work)
    C. Strip form tags via HtmlSanitizer when form is cosmetic only
- New HtmlHeuristics.hasForms(html): dedicated boolean check for
  form/input/select/textarea/<button type="submit">. Lets BA-driven
  routing logic express intent clearly rather than relying on the
  broader isComplex() gate.
- hasUnsupportedElements() now delegates form detection to hasForms()
  for a single source of truth.
RenderHyperBox._imageCache was a plain Map<String, CachedImage> with no
eviction policy. On a document with 100 high-res images, all decoded
ui.Image GPU textures were held for the entire lifetime of the widget,
causing unbounded GPU memory growth and OOM crashes on low-RAM Android
devices.

Fix: replace Map with the existing _LruCache<String, CachedImage> pattern,
mirroring how _textPainters already works.

- hyper_render_config.dart:
    New imageCacheSize field (default 30, low-end 10, tablet 60+).
    Added device-tier docs with recommended values.

- render_hyper_box.dart:
    _imageCache is now _LruCache<String, CachedImage> with
    onEvict: (ci) => ci.image?.dispose().
    LRU evicts the oldest-untouched image and frees its GPU texture.
    _disposeImages() simplified: _LruCache.clear() calls onEvict on
    every entry — the manual dispose loop is no longer needed.
    All _imageCache[src] = ... writes changed to _imageCache.put().

- render_hyper_box_paint.dart:
    _paintImage now calls _imageCache.get(src) instead of _imageCache[src].
    get() promotes the entry to most-recently-used, so images that are
    actively being painted are never evicted mid-session.
    On a null return (cache miss after eviction): show shimmer and
    schedule _loadImage(src) via addPostFrameCallback so state is never
    mutated inside paint(). The re-fetch deduplicates via LazyImageQueue.
## Problem 1 — StyleResolver: 43 inline RegExp instantiations

Every call to _parseColor, _parseGradient, _calculateSpecificity,
_matchesSelector, _extractPseudoClasses, etc. created a new RegExp
object. Dart compiles regex to a DFA on first instantiation; re-creating
the same pattern in a hot path allocates a new object and re-compiles
every call. With 5000+ styled nodes this produced tens of thousands of
short-lived RegExp objects and measurable GC pressure.

Fix: new _Re abstract final class holding 33 static final compiled
patterns grouped by purpose (selectors, specificity, combinators,
pseudo-classes, value functions, colors, layout, filters). All 43
inline RegExp(...) calls replaced with _Re.xxx references. Zero
functional change — patterns are identical.

## Problem 2 — computeMinIntrinsicWidth: O(totalWords) → O(fragments)

The previous implementation split every fragment's text on whitespace
and called TextPainter.layout() for each individual word. For a 3000-
word article this means ~3000 synchronous layout calls on the main
thread, causing 200–400 ms jank when the widget is wrapped in
IntrinsicWidth or DataTable.

Fix: for each text fragment, find the single longest word by character
count (an O(W) scan with no layout) and measure only that one word.
The longest measured word is almost always the longest-character word;
the only edge case is short wide-glyph strings vs long narrow-glyph
strings, which is negligible for real prose. TextPainter calls drop
from O(totalWords) to O(fragmentCount) — one call per fragment.

Added _kWhitespaceSplitter library-level final to avoid re-compiling
the \s+ regex on every computeMinIntrinsicWidth call.
… depth

- Replace hardcoded tapThreshold=8.0 with computeHitSlop(event.kind,
  GestureBinding.instance.gestureSettings) so tap detection matches
  platform gesture physics (mouse: 1 px, touch: ~18 px).

- Add HyperRenderConfig.extraLinkSchemes (Set<String>) so apps can
  permit their own deep-link schemes (e.g. 'myapp', 'shopee') without
  bypassing the built-in safe set (http/https/mailto/tel).

- Cap _evaluateCalcInValue loop with _kMaxCalcDepth=8 to prevent an
  adversarially crafted calc(calc(calc(...))) with 1000 nesting levels
  from looping indefinitely; add a no-progress early-exit guard.
…sureFragments on details toggle

Accessibility (WCAG 2.1 AA):
- assembleSemanticsNode now builds individual SemanticsNodes for h1–h6
  heading blocks (isHeader: true) and <a href> links (isLink: true +
  onTap). TalkBack/VoiceOver users can now navigate headings by swipe
  and activate links by double-tap.
- Regular paragraph text continues to be announced via the flat `label`
  on the container node — no change in linear reading behaviour.
- Semantic nodes are pooled in _cachedSemanticAnchorNodes (same pattern
  as Flutter's RenderParagraph) to avoid recreating SemanticsNode objects
  on every assembleSemanticsNode call, which would trigger the
  parentDataDirty assertion in flushSemantics() in debug mode.
- Caps at _kMaxSemanticAnchors = 200 to guard against adversarially
  large documents filling the accessibility tree.

Performance (details relayout):
- Split the single needsLineLayout condition into two branches:
  fragmentsOrWidthChanged (full rebuild including _measureFragments) and
  hasDetailsFragments-only (skip _measureFragments, re-run line layout).
- Each frame of a <details> expand/collapse animation previously called
  _measureFragments() — the most expensive step (TextPainter layout).
  Text content and constraint width are unchanged during animation, so
  TextPainter output is identical frame-to-frame and measurement can be
  safely skipped.
…xt splitting

- lazy_image_queue: replace URL-based cancelAll with per-subscriber int tokens;
  add _inFlight set to deduplicate concurrent loads; distribute ui.Image.clone()
  per subscriber and dispose original to prevent GPU leak
- render_hyper_box: track tokens in _imageTokens set; cancel all on dispose
- render_table: clamp colspan/rowspan to _kMaxSpan=1000 to prevent OOM;
  remove double LayoutBuilder wrapping so nested tables answer intrinsic height queries
- render_hyper_box_layout: snap breakIndex off UTF-16 low surrogates (0xDC00–0xDFFF)
  in _splitTextFragment and _forceSplitTextFragment to avoid invalid lone-surrogate
  strings crashing TextPainter; fix characterOffset double-counting trimmedLeading
…r handle drag

HoldScrollActivity.cancel() triggers goBallistic → beginActivity, which
disposes the old HoldScrollActivity synchronously. dispose() fires
onHoldCanceled = _releaseScrollHold while _scrollHold is still non-null
(the null assignment hadn't run yet), causing infinite mutual recursion.

Fix: capture the hold reference and null the field first, then cancel.
… button

GestureDetector.onTapDown entered the gesture arena and fired _handleTap
unconditionally on every pointer-down, clearing the selection and hiding
the context menu before TextButton.onPressed could fire copySelection().

Replace with a Listener (no arena participation) that guards on
_showContextMenu: when the menu is visible the pointer-down is ignored,
letting the Copy/Share buttons receive the tap and copy the text.
…emo with WCAG 2.1 AA features

- Add packages/hyper_render_core/test/p2_fixes_test.dart with 42 tests
  covering: CSS inline styles, flex layout, grid layout, heading
  anchors, HyperDetailsWidget expand/collapse, and link tap + scheme
  whitelisting.  All 680 tests pass.
- Update example/lib/accessibility_demo.dart to demo the new P2
  capabilities: heading navigation semantics (isHeader), link activation
  semantics (isLink + onTap), and extraLinkSchemes toggle that lets
  users whitelist the myapp:// deep-link scheme at runtime.
…n tests

Merge resolution (render_hyper_box.dart conflict):
- Keep token-based image cancellation from refactor/improve
- Use LRU onEvict for GPU disposal (main), removing redundant manual
  dispose loop to avoid double-free

Fix 3 pre-existing pumpAndSettle timeout failures in integration tests:
- error_recovery: measure layout time separately from network image loading
- resource_management: use pump(2s) for large-doc semantics test
- resource_management: split zoom-virtualized into two tests — one using
  sync mode to verify InteractiveViewer, one using runAsync to let the
  real isolate complete before asserting no crash
Adds VirtualizedSelectionController — a ChangeNotifier that owns a
global (chunkIndex, localOffset) selection spanning multiple independent
RenderHyperBox instances inside the virtualized ListView.

Key changes:
- RenderHyperBox: expose totalCharacterCount getter and public
  getCharacterPositionAtOffset() wrapper (previously private)
- VirtualizedSelectionController: manages CrossChunkSelection state;
  translates it into per-chunk HyperTextSelection; handles cross-chunk
  handle dragging with closest-chunk fallback; getSelectedText() falls
  back to DocumentNode.textContent for off-screen chunks
- VirtualizedChunk: thin StatefulWidget that registers/unregisters with
  the controller after first layout and wires onSelectionChanged
- VirtualizedSelectionOverlay: Stack overlay with teardrop handles and
  Copy/Select-All popup menu in the ListView coordinate space; freezes
  ancestor scroll during handle drag (same as single-chunk overlay)
- HyperViewer: instantiates the controller in initState (selectable only);
  replaces bare HyperRenderWidget in itemBuilder with VirtualizedChunk;
  wraps the virtualized path with VirtualizedSelectionOverlay when
  showSelectionMenu is true

Sync mode is completely unchanged — it still uses HyperSelectionOverlay.
- Add flutter_svg ^2.0.0 dependency
- Implement buildSvgWidget() interceptor for inline <svg>, <img src="*.svg">, and data:image/svg+xml URIs
- Wire SVG builder into HyperViewer._effectiveWidgetBuilder (chains before user's widgetBuilder)
- Export buildSvgWidget from hyper_render.dart for composability
- Update Sprint3Demo SVG tab: remove "add flutter_svg manually" note — it's now built-in
- Add 10 unit tests covering all SVG rendering paths
…aries

Issue 1 — Infinite loop risk: already fixed (lines 1512-1513 / 1589-1591
in render_hyper_box_layout.dart clamp oversized floats to container width
before the search loop, ensuring O(1) termination).

Issue 2 — Float wasted space in virtualized mode:
- Add HtmlAdapter._containsFloatChild() — detects float:left/right img nodes
- parseToSections() now skips the section split immediately after a block
  that contains a CSS-floated element, keeping the float and its successor
  in the same RenderHyperBox so text wraps around the float correctly
- Add clarifying comment in performLayout() explaining the wasted-space
  trade-off and the float.rect.bottom height extension (lines 963-967)
- Document the full FloatCarryover future work in doc/ROADMAP.md
- Add 3 unit tests covering float-guard and normal-split behaviour
Replace kinsokuStart.contains(text[i]) (O(N) linear scan + heap String
allocation per character) with Set<int> lookup tables built once at
class-load time from the canonical kinsoku strings.

Key changes:
- _startCodes / _endCodes: static final Set<int> built via _buildCodeSet()
- cannotStartLine / cannotEndLine: use codeUnitAt(0) + Set.contains (O(1))
- canBreakBetween: Set + codeUnitAt — zero String allocation
- _canBreakAt (hot-path): Set.contains(codeUnitAt) — O(1), zero alloc
- findBreakPoint: delegates to _canBreakAt, eliminating O(N²) substring
  allocations from the previous canBreakBetween(substring, substring) loop

All kinsoku characters are in the BMP (U+0000–U+FFFF), so codeUnitAt()
returns the exact Unicode code point with no surrogate handling needed.
…index lookup

Replace the Set<int> hash tables with a single 64 KB Uint8List(0x10000)
bitmask, encoding kinsoku-start (bit 0) and kinsoku-end (bit 1) categories
for every BMP code unit.

Why faster than Set<int>:
- Set.contains() computes a hash + may traverse a collision chain
- _table[codeUnit] is a single memory dereference — no hash, no branch
- 64 KB fits entirely in L2 cache; repeated layout-scan accesses are
  served from cache, not RAM
- _canBreakAt() reads the table once per boundary position instead of
  calling two separate Set.contains() — one load covers both categories

The _buildTable() builder iterates kinsokuStart/kinsokuEnd once at
class-load time; all public API signatures are unchanged.
## Table nesting depth guard (ANR prevention)
Add _TableNestingDepth InheritedWidget that propagates nesting depth
through the widget tree. HyperTable.build() checks _TableNestingDepth.of()
and returns _TableDepthExceededPlaceholder ('[table]') at depth ≥ 6.

The 2-pass layout algorithm (_RenderHyperTable.performLayout) has O(2^D)
complexity for nested tables: measuring column widths triggers each cell's
layout, doubling per level. The depth-6 cap matches browser behaviour and
prevents ANR on adversarial or poorly-authored HTML. No constructor
signatures changed — InheritedWidget propagates depth automatically.

## Cross-chunk float continuity
Implement float-state transfer between virtualised sections so text in
Chunk N+1 wraps around a float that began in Chunk N.

New types:
- FloatCarryover (in render_hyper_box_types.dart): direction, width,
  overhangHeight — describes a float whose rect.bottom exceeds the
  natural text height of its section.

New RenderHyperBox API:
- initialFloats: List<FloatCarryover> — seeds _leftFloats/_rightFloats
  at the start of _performLineLayout for the inherited floats.
- danglingFloats: getter — computes FloatCarryover from floats that
  extend past the last-line bottom after layout.
- onFloatCarryover callback — fired after each performLayout with the
  current danglingFloats list.

Threading:
- HyperRenderWidget: initialFloats + onFloatCarryover parameters
- VirtualizedChunk: initialFloats + onFloatCarryover pass-through
- HyperViewer: _floatCarryovers state list + _onFloatCarryover handler
  that triggers a minimal setState when carryover changes, causing only
  the affected next-section item to rebuild.
Parse and apply CSS vertical-align / HTML valign attribute on table cells.

## Parsing
- resolver.dart: add 'vertical-align' case to _applyProperty() — maps
  top/middle/bottom/baseline/text-top/text-bottom to HyperVerticalAlign
- resolver.dart: add _parseVerticalAlign() helper (mirrors _parseTextAlign)
- resolver.dart: read HTML 'valign' attribute before CSS rules (lower
  priority than inline styles), so valign="middle" on <tr>/<td> works

Precedence: cell inline style > CSS class on cell > valign on cell >
CSS class on row > valign on row > default (top)

## Layout
- _TableCellParentData: add verticalAlign field (default: top)
- _TableCellSlot: carry verticalAlign through applyParentData
- HyperTable.build(): resolve effective vertical-align (cell > row > top);
  uses HyperVerticalAlign.baseline as the "not explicitly set" sentinel
  since ComputedStyle.verticalAlign defaults to baseline

- _positionCells: compute slack = rowSlotHeight − cellHeight, then:
    top / baseline / text-top  → dy = 0      (existing behaviour)
    middle                     → dy = slack/2
    bottom / text-bottom       → dy = slack

Works correctly for rowspan > 1 cells: slot height is the sum of all
spanned rows plus inner borders, matching _computeRowHeights Pass 3.
- Wrap each HyperTable in Semantics(container:true, label:'Table, N rows, M columns')
  so TalkBack/VoiceOver announces the table structure on focus.
- Wrap <th> cells in Semantics(header:true) so screen readers distinguish
  column/row headers from data cells.
- Uses only stable Flutter Semantics API available since SDK 3.10,
  keeping compatibility with the declared pubspec lower-bound.
@vietnguyentuan2019 vietnguyentuan2019 merged commit 085ebfd into main Mar 25, 2026
4 of 8 checks passed
@vietnguyentuan2019 vietnguyentuan2019 deleted the refactor/improve branch March 25, 2026 03:54
…I, golden tests

Core engine:
- O(log N) binary search hit-testing for text selection (_lineStartOffsets[])
- Ruby clipboard format: base(ふりがな) for full-fragment selection
- Ruby selection pipeline: 5 bug fixes (offset sync, paint, clipboard, rects)

DevTools (hyper_render_devtools v1.0.0):
- First full release: UDT Tree inspector, Computed Style panel, Float region visualizer
- Demo mode: explore inspector without a live app
- README, CHANGELOG, LICENSE, example added

CI/CD — 3-pipeline architecture:
- Pipeline 1 (analyze.yml): Pre-flight — dart format + flutter analyze --fatal-infos,
  dorny/paths-filter for 8 path categories, skips on docs-only changes (< 2 min target)
- Pipeline 2 (test.yml): Core Validation — PR uses single ubuntu-22.04 runner with
  per-package selective testing; push to main runs full 3-OS × 2-channel matrix
- Pipeline 2 (coverage.yml): push-to-main only, pinned Flutter 3.29.2 + ubuntu-22.04
- Visual regression (golden.yml): pinned ubuntu-22.04 + Noto fonts, update-goldens job
- Performance regression (benchmark.yml): layout regression guard — 6 fixtures with
  hard 16 ms (60 FPS) budget, fails PR on any regression

Golden tests:
- 9 new test cases: Float layout (left/right/clear), RTL/BiDi (Arabic/Hebrew/mixed),
  CJK + Ruby (kinsoku, float+CJK) — all pinned pixel-stable

Documentation:
- README: CI badges, devtools in extension packages table, O(log N) + layout CI guard
  in architecture section, roadmap updated (devtools + @Keyframes shipped)
- doc/ROADMAP.md: completed items updated through v1.1.2
- .gitignore: add .metadata, .flutter-plugins, benchmark/results/, analyze_report.txt,
  pubspec_publish_ready.yaml; replace overbroad *.txt

Packages: all sub-packages bumped to 1.1.2 with issue_tracker field
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant