diff --git a/CHANGELOG.md b/CHANGELOG.md
index 136786a..bfcda89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog
+## 1.8.0
+
+- **AI & LLM Real-Time Token Streaming Engine**:
+ - `HyperStreamingController`: High-performance streaming controller for batching rapid SSE / WebSocket token bursts with 16ms frame-aligned throttling by default, backing off adaptively (up to `maxThrottleDuration`, default 200ms) once the accumulated buffer passes 10,000 / 50,000 characters โ bounding total reparse cost over a long stream's lifetime.
+ - `StreamSyntaxNormalizer`: Transient auto-repair utility for incomplete in-flight tokens (auto-closes unclosed code block fences, inline code, bold/italic asterisks, incomplete table rows, incomplete links, and truncated HTML tags).
+ - `HyperTypingCaret`: Animated typing caret widget with customizable blinking styles (`bar`, `block`, `underscore`, `dot`, `custom`).
+ - `HyperViewer.streaming(...)`: Dedicated streaming constructor with stick-to-bottom auto-scroller, syntax auto-repair, and typing caret integration.
+ - Interactive AI streaming demo added to showcase app (`example/lib/ai_streaming_demo.dart`), including a virtualized-mode toggle and an error-simulation control.
+
+### ๐ Fixes
+
+- **`didUpdateWidget` missed `streamingController` swaps and `autoRepairSyntax` toggles**: replacing the streaming controller with a fresh instance (e.g. a new AI turn) or flipping `autoRepairSyntax` at runtime had no visible effect until the next stream notification happened to arrive. Both are now in the reparse-trigger list.
+- **Content fade restarted on every streaming tick**: the 300ms fade-in was reset on every throttled notification (as often as every 16ms), so it perpetually restarted instead of settling โ content flickered/stayed dim for the whole stream. The fade now plays once per streaming session and correctly replays after `reset()`.
+- **`fallbackBuilder`'s complexity gate never fired during streaming**: it checked `widget.content`, which `.streaming()` hardcodes to `''`, so `HtmlHeuristics.isComplex(...)` was always evaluated against an empty string regardless of how complex the streamed HTML became. It now reads the live streaming buffer.
+- **`autoScrollToBottom` silently did nothing in virtualized/`auto` mode** without an externally supplied `HyperViewerController`: the virtualized `ListView.builder` was bound to `widget.controller?.scrollController` instead of the existing `_effectiveScrollController` fallback, so the internally-created scroll controller was never attached to anything.
+- **`append()` after `error()` silently resumed streaming**, erasing the error state without requiring `reset()` first (the guard only checked for `completed`, not `error`). It now rejects appends in both terminal states.
+- **A synchronous exception from `append()` inside `bindStream`/`bindCustomStream`'s `onData` callback bypassed `onError` entirely** and surfaced as an unhandled zone exception, contradicting the documented "catches errors" behavior. Both now wrap `onData` (and, for `bindCustomStream`, the `mapper` call) and route genuine failures into `error()` โ without overwriting an already-legitimate `completed`/`error` state if a late/duplicate event races it.
+- **`StreamSyntaxNormalizer.normalizeMarkdown` scanned the entire accumulated buffer** for unmatched backticks/asterisks/`$`/brackets, so an odd character count *inside an already-closed, fully-rendered code fence* earlier in the document was miscounted as unclosed โ corrupting unrelated trailing prose with a spurious appended character on every subsequent tick. Parity and link-repair scans are now scoped to the text after the last closed fence.
+- **`StreamSyntaxNormalizer.normalizeHtml`'s truncated-tag detection could be fooled by a literal `>` inside a still-open tag's quoted attribute value** (e.g. `
Hello
Text wraps seamlessly around floats!
',
onLinkTap: (url, attributes, element) => launchUrl(Uri.parse(url!)),
@@ -67,7 +68,7 @@ Html(
```yaml
dependencies:
- hyper_render: ^1.7.0
+ hyper_render: ^1.8.0
```
```dart
@@ -83,6 +84,29 @@ Zero configuration. XSS sanitization is **on by default**. No Gradle setup requi
---
+## ๐ค AI / LLM Real-Time Streaming
+
+Render live streaming token feeds from **Google Gemini, OpenAI ChatGPT, Anthropic Claude**, or WebSocket backends with frame-aligned, adaptively-throttled updates and automatic transient syntax repair.
+
+```dart
+final controller = HyperStreamingController();
+
+// Bind directly to any Dart Stream (e.g. OpenAI / Gemini SDK):
+controller.bindStream(aiTokenStream);
+
+// Render with automatic stick-to-bottom auto-scroller and pulsing caret:
+HyperViewer.streaming(
+ streamingController: controller,
+ contentType: HyperContentType.markdown,
+ showTypingCaret: true,
+ caretStyle: HyperTypingCaretStyle.bar,
+ autoRepairSyntax: true, // Auto-closes incomplete ```, **, $$, | on the fly
+ autoScrollToBottom: true, // Smoothly tracks stream tail
+)
+```
+
+---
+
## ๐๏ธ Why Switch? The Architecture Argument
Most Flutter HTML libraries map each HTML tag to a Flutter widget. A 3 000-word article becomes **500+ nested widgets** โ and some layout primitives simply cannot be expressed that way:
@@ -97,6 +121,7 @@ HyperRender renders the whole document inside **one custom `RenderObject`**. CSS
| Feature | `flutter_html` | `flutter_widget_from_html` | **HyperRender** |
|---|:---:|:---:|:---:|
| `float: left / right` | โ | โ | โ
|
+| AI / LLM Streaming | โ | โ | โ
Frame-aligned, adaptive throttle |
| Text selection โ large docs | โ Crashes | โ Crashes | โ
Crash-free |
| Ruby / Furigana + Kinsoku | โ Raw text | โ Raw text | โ
|
| RTL / BiDi (Arabic, Hebrew) | โ ๏ธ | โ ๏ธ | โ
|
@@ -450,7 +475,7 @@ These packages bring specialized dependencies and are **not bundled** by default
```yaml
dependencies:
- hyper_render_epub: ^0.1.0
+ hyper_render_epub: ^0.1.2
```
```dart
diff --git a/ROADMAP.md b/ROADMAP.md
index 115810e..a405b87 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -9,35 +9,26 @@ This document outlines the architectural roadmap for **HyperRender** to become t
| Version | Target Date | Strategic Focus | Key Differentiators |
| :--- | :--- | :--- | :--- |
| **v1.7.0** | Current | **Production Hardening & Drop-in Migration** | Single RenderObject, 100% WASM support, 160/160 pub score, 30s `flutter_html` drop-in layer. |
-| **v1.8.0** | Q3 2026 | **AI & LLM Token-Streaming Engine** | Incremental Delta-Streaming, Zero-Jank token updates, auto-scroll locking, tail-only layout invalidation. |
+| **v1.8.0** | Q3 2026 | **AI & LLM Token-Streaming Engine** | Frame-throttled token updates with adaptive backoff, transient syntax auto-repair, auto-scroll locking. Tail-only layout invalidation remains a separate, unscheduled epic โ see below. |
| **v1.9.0** | Q4 2026 | **Native Vector Diagramming & Headless Export** | Pure Canvas/Vector Mermaid.js & GraphViz (Zero-WebView), Headless Image & PDF byte stream generator. |
| **v2.0.0** | Q1 2027 | **Interactive Editorial & Magazine Typography** | Medium-style Text Annotation/Highlighting layer, Multi-column layout (`column-count`), Z-Index Stacking Context, Vertical Text (`writing-mode: vertical-rl`). |
---
-## ๐ v1.8.0: AI / LLM Streaming Engine (Token-by-Token Zero-Jank)
+## ๐ v1.8.0: AI / LLM Streaming Engine
-### 1. Incremental Delta-Append Engine
-- **Problem**: Modern LLM chat apps (ChatGPT, Claude, Notion AI) stream Markdown/HTML token-by-token. Re-parsing the full document string every 50ms causes 100% CPU spikes, severe frame drops (jank), memory thrashing, and scroll jump.
-- **Solution**:
- - Token-level append directly to the active UDT leaf node.
- - Partial layout invalidation: only measure and lay out the trailing line fragment (`tailLineLayout`), preserving 100% of cached layout geometry for preceding paragraphs, tables, and code blocks.
- - Smooth Auto-Scroll Anchor: lock viewport to stream tail without jittering scroll physics.
-- **API Surface**:
- ```dart
- final streamController = HyperDocumentStreamController();
-
- HyperViewer.stream(
- controller: streamController,
- mode: HyperRenderMode.sync,
- );
-
- // As chunks arrive from LLM:
- streamController.appendToken(" **instant** rendering");
- ```
+**Shipped** (`HyperStreamingController`, `HyperViewer.streaming(...)`, `StreamSyntaxNormalizer`, `HyperTypingCaret` โ see CHANGELOG 1.8.0): frame-throttled token append with **adaptive backoff** (the notification interval widens as the accumulated buffer grows past 10,000 / 50,000 chars, up to `maxThrottleDuration`), transient syntax auto-repair for Markdown/HTML, stick-to-bottom auto-scroll, typing caret. This bounds the total cost of re-parsing over the life of a long stream and is what "Zero-Jank" in this doc's earlier drafts actually refers to.
+
+**Not shipped โ the paragraph below was aspirational and did not match what got built; corrected after a production-readiness review found the mismatch:**
+
+### 1. Incremental Delta-Append Engine (tail-only layout) โ still unimplemented, own epic
+- **Problem**: re-parsing and re-laying-out the *entire* accumulated document on every streaming tick, rather than only the appended tail, means total work over a stream's lifetime scales with the square of its final length. The adaptive-backoff mitigation above bounds *how often* this happens as the buffer grows, but each tick still does a full document reparse + full `RenderHyperBox` layout pass โ it does not make any single tick cheaper.
+- **Why it's not a small patch**: a feasibility review of `packages/hyper_render_core/lib/src/core/render_hyper_box*.dart` found this needs four largely independent subsystems, most of them outside the renderer: (a) a parser able to resume from a character offset instead of re-tokenizing from scratch, (b) a UDT model change โ `TextNode.text` is currently immutable and nodes have no identity that survives across two parses, so there is no way to "find and extend the last text node" today, (c) a fragment list that supports appending instead of the current full-rebuild-every-layout design, (d) a persisted line-layout checkpoint (cursor position, in-progress float lists) that `_performLineLayout` can resume from instead of always resetting to empty. `RenderHyperBox`'s 7-file `part` architecture (shared private state across files, no interface boundary โ see the Architecture section above) makes this riskier than in a normally-composed class, since nothing stops a part file from silently assuming layout is always complete and freshly computed. Some CSS behavior (`text-align: justify`, float carryover, `text-overflow: ellipsis`) is also not strictly tail-local, so "only touch the appended tail" needs a correctness argument per feature, not just an engine change.
+- **Status**: deliberately deferred as a separate, scoped effort (own design + plan, own risk review) rather than folded into a bug-fix/hardening pass on a renderer every consumer of this library depends on โ not just streaming users.
-### 2. Live KaTeX & Syntax Highlighting in Streaming Mode
-- Incremental tokenizer state tracking: maintain code-fence (` ``` `) and math-delimiter (`$$`) states across partial chunks to prevent flashing unstyled syntax during streaming.
+### 2. Live KaTeX & Syntax Highlighting in Streaming Mode โ verified non-issue, not scheduled
+- **Original concern**: incomplete code fences / math delimiters mid-stream could flash unstyled or broken content, or crash the highlighter/KaTeX renderer.
+- **Investigated and closed**: `HyperViewer.streaming()` already exposes both `codeHighlighter` and `pluginRegistry`, so both are reachable during live streaming. `flutter_highlight`'s lexer is best-effort (not a strict parser) and doesn't throw on incomplete/malformed code โ covered by `code_highlighter_edge_cases_test.dart`. `flutter_math_fork`'s `Math.tex(..., onErrorFallback: ...)` wraps both its parse and build stages in a catch-all, so a delimiter-balanced-but-internally-malformed LaTeX fragment (which `StreamSyntaxNormalizer` intentionally does not try to brace-balance) safely falls through to the red-text fallback instead of crashing. No incremental tokenizer state is needed; regression tests were added to lock this behavior in (see CHANGELOG).
---
diff --git a/doc/CSS_PROPERTIES_MATRIX.md b/doc/CSS_PROPERTIES_MATRIX.md
index 71236d5..0951956 100644
--- a/doc/CSS_PROPERTIES_MATRIX.md
+++ b/doc/CSS_PROPERTIES_MATRIX.md
@@ -1,7 +1,7 @@
# CSS Properties Support Matrix
-Last Updated: June 24, 2026
-Version: 1.4.0
+Last Updated: September 6, 2026
+Version: 1.8.0
This document lists CSS property support in HyperRender.
@@ -250,4 +250,4 @@ This document lists CSS property support in HyperRender.
---
-*Last updated: July 5, 2026 โ HyperRender v1.5.0*
+*Last updated: September 6, 2026 โ HyperRender v1.8.0*
diff --git a/doc/LIMITATIONS.md b/doc/LIMITATIONS.md
index d36ce5c..6242660 100644
--- a/doc/LIMITATIONS.md
+++ b/doc/LIMITATIONS.md
@@ -161,6 +161,27 @@ HyperViewer(html: html, mode: HyperRenderMode.sync)
Prefer explicit `pump(duration)` calls over `pumpAndSettle()` for any test that
exercises virtualised or paged mode.
+### AI/LLM streaming (`HyperViewer.streaming`) is full-reparse, not tail-only
+
+`HyperStreamingController`/`HyperViewer.streaming(...)` (v1.8.0) re-runs syntax
+normalization, sanitization, parsing, and layout over the **entire**
+accumulated buffer on every throttled notification โ it does not append to an
+existing document tree or only re-lay-out the trailing line. This is
+mitigated, not eliminated, by adaptive throttle backoff: the notification
+interval automatically widens once the buffer passes 10,000 / 50,000
+characters (up to `maxThrottleDuration`, default 200ms), bounding total work
+over a long stream's lifetime, but each individual tick's cost still scales
+with the full document length, not just the newly-appended text.
+
+A genuine tail-only incremental engine (append to the last text node in
+place, resume line-layout from the last committed line) was evaluated and
+deliberately deferred โ it requires a parser that can resume from a character
+offset, a UDT model change (`TextNode.text` is currently immutable and nodes
+have no identity across two parses), and a persisted line-layout checkpoint
+inside `RenderHyperBox`'s `part`-file architecture, which has no
+encapsulation boundary between layout/paint/selection/accessibility. See
+`ROADMAP.md`'s v1.8.0 section for the full feasibility writeup.
+
---
## Interactive Elements
@@ -217,4 +238,4 @@ the full suite plus goldens as the safety net.
---
-*Last updated: July 22, 2026 โ HyperRender v1.5.x (unreleased branch)*
+*Last updated: September 6, 2026 โ HyperRender v1.8.0*
diff --git a/example/lib/ai_streaming_demo.dart b/example/lib/ai_streaming_demo.dart
new file mode 100644
index 0000000..3503acd
--- /dev/null
+++ b/example/lib/ai_streaming_demo.dart
@@ -0,0 +1,328 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:hyper_render/hyper_render.dart';
+
+/// Interactive AI & LLM Streaming Demo for HyperRender v1.8.0.
+class AiStreamingDemo extends StatefulWidget {
+ final bool autoStart;
+ const AiStreamingDemo({super.key, this.autoStart = false});
+
+ @override
+ State
createState() => _AiStreamingDemoState();
+}
+
+class _AiStreamingDemoState extends State {
+ final HyperStreamingController _controller = HyperStreamingController(
+ throttleDuration: const Duration(milliseconds: 16),
+ );
+
+ HyperTypingCaretStyle _caretStyle = HyperTypingCaretStyle.bar;
+ bool _autoRepair = true;
+ bool _autoScroll = true;
+ HyperRenderMode _mode = HyperRenderMode.sync;
+ Timer? _streamTimer;
+ int _chunkIndex = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ if (widget.autoStart) {
+ WidgetsBinding.instance.addPostFrameCallback((_) => _startSimulation());
+ }
+ }
+
+ static const List _sampleTokens = [
+ '# ๐ง HyperRender AI Assistant\n\n',
+ 'Hello! I am your **HyperRender AI** assistant streaming responses directly into Flutter.\n\n',
+ '### Key Capabilities of v1.8.0 Streaming Engine:\n\n',
+ '- **Frame-Aligned Adaptive Throttling**: Batches high-speed SSE bursts at a 16ms cadence, backing off automatically for very long responses.\n',
+ '- **Transient Syntax Normalization**: Auto-repairs unclosed markdown fences and formatting.\n',
+ '- **Stick-to-Bottom Auto Scroll**: Smoothly follows output tail in real-time.\n',
+ '- **Native Blinking Carets**: Customizable bar, block, underscore, and dot styles.\n\n',
+ 'Here is an example code snippet generated on-the-fly:\n\n',
+ '```dart\n',
+ '// Initialize AI Streaming Controller\n',
+ 'final controller = HyperStreamingController();\n',
+ 'controller.bindStream(geminiStream);\n\n',
+ '// Render with HyperViewer.streaming\n',
+ 'HyperViewer.streaming(\n',
+ ' streamingController: controller,\n',
+ ' contentType: HyperContentType.markdown,\n',
+ ' showTypingCaret: true,\n',
+ ')\n',
+ '```\n\n',
+ '### How the throttling works:\n\n',
+ 'Each token is buffered and the view is notified at a 16ms cadence by '
+ 'default; once the accumulated response grows past 10,000 or '
+ '50,000 characters, the notification interval widens automatically '
+ '(up to `maxThrottleDuration`) so re-parsing a very long response '
+ "doesn't get proportionally more expensive on every tick.\n\n",
+ 'โจ *Streaming generation finished.*',
+ ];
+
+ @override
+ void dispose() {
+ _streamTimer?.cancel();
+ _controller.dispose();
+ super.dispose();
+ }
+
+ void _startSimulation() {
+ _streamTimer?.cancel();
+ _controller.reset();
+ _chunkIndex = 0;
+
+ _streamTimer = Timer.periodic(const Duration(milliseconds: 120), (timer) {
+ if (_chunkIndex < _sampleTokens.length) {
+ _controller.append(_sampleTokens[_chunkIndex]);
+ _chunkIndex++;
+ } else {
+ _controller.complete();
+ timer.cancel();
+ }
+ });
+ }
+
+ void _stopSimulation() {
+ _streamTimer?.cancel();
+ _controller.complete();
+ }
+
+ void _resetSimulation() {
+ _streamTimer?.cancel();
+ _controller.reset();
+ _chunkIndex = 0;
+ }
+
+ /// Demonstrates the error โ reset() lifecycle: once a controller reaches
+ /// [HyperStreamingStatus.error], it must be reset() before it can stream
+ /// again โ appending directly would throw.
+ void _simulateError() {
+ _streamTimer?.cancel();
+ _controller.error('Simulated network timeout');
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('AI / LLM Streaming Engine (v1.8.0)'),
+ backgroundColor: const Color(0xFF1E293B),
+ foregroundColor: Colors.white,
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.refresh),
+ onPressed: _resetSimulation,
+ tooltip: 'Reset Stream',
+ ),
+ ],
+ ),
+ body: Column(
+ children: [
+ // Control Panel
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
+ decoration: BoxDecoration(
+ color: const Color(0xFFF8FAFC),
+ border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ alignment: WrapAlignment.spaceBetween,
+ children: [
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ElevatedButton.icon(
+ onPressed: _startSimulation,
+ icon: const Icon(Icons.play_arrow, size: 18),
+ label: const Text('Start Stream'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: const Color(0xFF10B981),
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12, vertical: 8),
+ ),
+ ),
+ const SizedBox(width: 8),
+ OutlinedButton.icon(
+ onPressed: _stopSimulation,
+ icon: const Icon(Icons.stop, size: 18),
+ label: const Text('Stop'),
+ style: OutlinedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12, vertical: 8),
+ ),
+ ),
+ const SizedBox(width: 8),
+ OutlinedButton.icon(
+ onPressed: _simulateError,
+ icon: const Icon(Icons.warning_amber, size: 18),
+ label: const Text('Simulate Error'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: Colors.red,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12, vertical: 8),
+ ),
+ ),
+ ],
+ ),
+ ValueListenableBuilder(
+ valueListenable: _controller,
+ builder: (context, state, _) {
+ Color badgeColor = Colors.grey;
+ String statusLabel = 'IDLE';
+
+ switch (state.status) {
+ case HyperStreamingStatus.streaming:
+ badgeColor = Colors.green;
+ statusLabel =
+ 'STREAMING (${state.tokenCount} tokens ยท ${state.tokensPerSecond.toStringAsFixed(1)} tps)';
+ break;
+ case HyperStreamingStatus.completed:
+ badgeColor = Colors.blue;
+ statusLabel =
+ 'DONE (${state.tokenCount} tokens ยท ${state.tokensPerSecond.toStringAsFixed(1)} tps avg)';
+ break;
+ case HyperStreamingStatus.error:
+ badgeColor = Colors.red;
+ statusLabel = 'ERROR';
+ break;
+ case HyperStreamingStatus.idle:
+ break;
+ }
+
+ return Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10, vertical: 5),
+ decoration: BoxDecoration(
+ color: badgeColor.withAlpha(25),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: badgeColor),
+ ),
+ child: Text(
+ statusLabel,
+ style: TextStyle(
+ color: badgeColor,
+ fontWeight: FontWeight.bold,
+ fontSize: 11,
+ ),
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ const SizedBox(height: 6),
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ const Text('Caret: ',
+ style: TextStyle(
+ fontWeight: FontWeight.w600, fontSize: 13)),
+ DropdownButton(
+ value: _caretStyle,
+ underline: const SizedBox.shrink(),
+ isDense: true,
+ items: const [
+ DropdownMenuItem(
+ value: HyperTypingCaretStyle.bar,
+ child:
+ Text('Bar (โ)', style: TextStyle(fontSize: 13)),
+ ),
+ DropdownMenuItem(
+ value: HyperTypingCaretStyle.block,
+ child: Text('Block (โ)',
+ style: TextStyle(fontSize: 13)),
+ ),
+ DropdownMenuItem(
+ value: HyperTypingCaretStyle.underscore,
+ child: Text('Underscore (_)',
+ style: TextStyle(fontSize: 13)),
+ ),
+ DropdownMenuItem(
+ value: HyperTypingCaretStyle.dot,
+ child:
+ Text('Dot (โ)', style: TextStyle(fontSize: 13)),
+ ),
+ ],
+ onChanged: (val) {
+ if (val != null) setState(() => _caretStyle = val);
+ },
+ ),
+ const SizedBox(width: 16),
+ const Text('Mode: ',
+ style: TextStyle(
+ fontWeight: FontWeight.w600, fontSize: 13)),
+ DropdownButton(
+ value: _mode,
+ underline: const SizedBox.shrink(),
+ isDense: true,
+ items: const [
+ DropdownMenuItem(
+ value: HyperRenderMode.sync,
+ child: Text('Sync', style: TextStyle(fontSize: 13)),
+ ),
+ DropdownMenuItem(
+ value: HyperRenderMode.virtualized,
+ child: Text('Virtualized',
+ style: TextStyle(fontSize: 13)),
+ ),
+ ],
+ onChanged: (val) {
+ if (val != null) setState(() => _mode = val);
+ },
+ ),
+ const SizedBox(width: 16),
+ const Text('Auto Scroll: ',
+ style: TextStyle(fontSize: 13)),
+ Switch(
+ value: _autoScroll,
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ onChanged: (v) => setState(() => _autoScroll = v),
+ ),
+ const SizedBox(width: 16),
+ const Text('Auto Repair: ',
+ style: TextStyle(fontSize: 13)),
+ Switch(
+ value: _autoRepair,
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ onChanged: (v) => setState(() => _autoRepair = v),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Main Streaming Viewer
+ Expanded(
+ child: Container(
+ color: Colors.white,
+ padding: const EdgeInsets.all(16),
+ child: HyperViewer.streaming(
+ streamingController: _controller,
+ contentType: HyperContentType.markdown,
+ caretStyle: _caretStyle,
+ autoRepairSyntax: _autoRepair,
+ autoScrollToBottom: _autoScroll,
+ mode: _mode,
+ showTypingCaret: true,
+ caretColor: theme.colorScheme.primary,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/example/lib/demo_auto_player.dart b/example/lib/demo_auto_player.dart
index e1e234a..b346056 100644
--- a/example/lib/demo_auto_player.dart
+++ b/example/lib/demo_auto_player.dart
@@ -2,12 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hyper_render/hyper_render.dart';
-import 'main.dart';
-import 'smart_table_demo.dart';
-import 'manga_demo.dart';
-import 'enhanced_selection_demo.dart';
-import 'ultra_showcase_2026.dart';
-import 'stress_test_demo.dart';
+import 'ai_streaming_demo.dart';
String getActiveDemo() {
try {
@@ -91,6 +86,10 @@ class _AutoPlayerHostState extends State {
String title;
String htmlContent;
+ if (widget.demoName == 'ai_streaming') {
+ return const AiStreamingDemo(autoStart: true);
+ }
+
switch (widget.demoName) {
case 'ruby':
title = 'Ruby / Furigana CJK Typography';
@@ -238,11 +237,14 @@ class _AutoPlayerHostState extends State {
title = '60 FPS Virtualized Rendering';
final buf = StringBuffer();
buf.write('');
- buf.write('
100,000+ Chars Virtualized Mode
');
- buf.write('
๐ FPS: 60.0 | Memory: 2.4 MB | Active Nodes: 12
');
+ buf.write(
+ '
100,000+ Chars Virtualized Mode
');
+ buf.write(
+ '
๐ FPS: 60.0 | Memory: 2.4 MB | Active Nodes: 12
');
for (int i = 1; i <= 60; i++) {
final bg = i % 2 == 0 ? '#F8FAFC' : '#FFFFFF';
- buf.write('
Section $i: HyperRender utilizes intelligent chunking. Only blocks visible on screen are painted, achieving smooth 60 FPS scrolling.
');
+ buf.write(
+ '
Section $i: HyperRender utilizes intelligent chunking. Only blocks visible on screen are painted, achieving smooth 60 FPS scrolling.
');
}
buf.write('
');
htmlContent = buf.toString();
@@ -280,7 +282,8 @@ class _AutoPlayerHostState extends State {
return Scaffold(
appBar: AppBar(
- title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
+ title: Text(title,
+ style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
backgroundColor: const Color(0xFF1A56DB),
foregroundColor: Colors.white,
elevation: 2,
diff --git a/example/lib/enhanced_selection_demo.dart b/example/lib/enhanced_selection_demo.dart
index 0eed93e..fa1298a 100644
--- a/example/lib/enhanced_selection_demo.dart
+++ b/example/lib/enhanced_selection_demo.dart
@@ -452,12 +452,12 @@ class _EnhancedSelectionDemoState extends State {
_showSnackBar('โ
Copied to clipboard', Colors.green);
}
- // MED-01: Declared as Future so exceptions propagate to the caller and
+ // Declared as Future so exceptions propagate to the caller and
// are not silently swallowed (void async discards unhandled Future errors).
Future _handleShare(HyperSelectionState state) async {
final text = state.selectedText;
if (text != null && text.isNotEmpty) {
- // CRIT-02: Check mounted BEFORE setState โ user may have popped the screen
+ // Check mounted BEFORE setState โ user may have popped the screen
// between the gesture callback and this synchronous execution path.
if (!mounted) return;
setState(() {
@@ -492,7 +492,7 @@ class _EnhancedSelectionDemoState extends State {
Future _handleSearch(HyperSelectionState state) async {
final text = state.selectedText;
if (text != null && text.isNotEmpty) {
- if (!mounted) return; // CRIT-02
+ if (!mounted) return;
setState(() {
_lastAction = 'Search on Google';
_selectedText = text;
@@ -532,7 +532,7 @@ class _EnhancedSelectionDemoState extends State {
Future _handleTranslate(HyperSelectionState state) async {
final text = state.selectedText;
if (text != null && text.isNotEmpty) {
- if (!mounted) return; // CRIT-02
+ if (!mounted) return;
setState(() {
_lastAction = 'Translate';
_selectedText = text;
@@ -561,7 +561,7 @@ class _EnhancedSelectionDemoState extends State {
Future _handleDefine(HyperSelectionState state) async {
final text = state.selectedText;
if (text != null && text.isNotEmpty) {
- if (!mounted) return; // CRIT-02
+ if (!mounted) return;
setState(() {
_lastAction = 'Dictionary Lookup';
_selectedText = text;
diff --git a/example/lib/html_preview_helper.dart b/example/lib/html_preview_helper.dart
index 82068f1..1170271 100644
--- a/example/lib/html_preview_helper.dart
+++ b/example/lib/html_preview_helper.dart
@@ -89,7 +89,7 @@ class HtmlPreviewHelper {
final uri = Uri.file(file.path);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
- // MED-02: Clean up temp file after a short delay so the browser has
+ // Clean up temp file after a short delay so the browser has
// time to read it. The OS temp dir is eventually purged regardless,
// but explicit deletion avoids accumulation during demo sessions.
Future.delayed(const Duration(seconds: 5), () {
diff --git a/example/lib/main.dart b/example/lib/main.dart
index f3833f2..7ed11f9 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -37,6 +37,7 @@ import 'reader_app/library_screen.dart';
import 'float_hell_demo.dart';
import 'zero_padding_image_demo.dart';
import 'base_url_demo.dart';
+import 'ai_streaming_demo.dart';
/// Optimized base TextStyle for better readability.
///
@@ -125,6 +126,16 @@ class DemoHomePage extends StatelessWidget {
const SizedBox(height: 8),
// โโ Signature Features โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_buildSectionHeader(context, 'Signature Features'),
+ _buildDemoCard(
+ context,
+ icon: Icons.psychology,
+ title: 'AI / LLM Streaming Engine (v1.8.0)',
+ subtitle:
+ 'Real-time token streaming with 60 FPS throttling, syntax auto-repair, auto-scroll & animated carets',
+ color: const Color(0xFF10B981),
+ onTap: () => Navigator.push(context,
+ MaterialPageRoute(builder: (_) => const AiStreamingDemo())),
+ ),
_buildDemoCard(
context,
icon: Icons.explore,
@@ -893,7 +904,7 @@ class FloatLayoutDemo extends StatelessWidget {
// RUBY DEMO
// =============================================================================
-// MED-04: RubyDemo removed โ it was dead code (never navigated to from any
+// RubyDemo removed โ it was dead code (never navigated to from any
// screen). Ruby annotation content is covered by MangaDemo and CjkLanguagesDemo.
// =============================================================================
diff --git a/example/lib/paged_mode_demo.dart b/example/lib/paged_mode_demo.dart
index c211791..63f89db 100644
--- a/example/lib/paged_mode_demo.dart
+++ b/example/lib/paged_mode_demo.dart
@@ -121,7 +121,7 @@ class _PageBarState extends State<_PageBar> {
super.dispose();
}
- // CRIT-01: Guard against setState after dispose โ the ValueNotifier listener
+ // Guard against setState after dispose โ the ValueNotifier listener
// can fire during Flutter's teardown sequence (e.g. page transition animation)
// after dispose() has already run, causing "setState called after dispose".
void _onPageChanged() {
diff --git a/example/lib/performance_deep_dive_demo.dart b/example/lib/performance_deep_dive_demo.dart
index 2a59db2..bf20633 100644
--- a/example/lib/performance_deep_dive_demo.dart
+++ b/example/lib/performance_deep_dive_demo.dart
@@ -482,7 +482,7 @@ class _IsolateParsingTabState extends State<_IsolateParsingTab> {
void _measureSync() {
final sw = Stopwatch()..start();
- // MED-06: addPostFrameCallback measures time to the NEXT FRAME COMPLETION,
+ // addPostFrameCallback measures time to the NEXT FRAME COMPLETION,
// not true parse time. It includes frame scheduling latency (~4โ16 ms) on
// top of the actual parse duration. This is intentional for this demo โ
// it shows total "time to first pixel" which is what users perceive โ
@@ -496,7 +496,7 @@ class _IsolateParsingTabState extends State<_IsolateParsingTab> {
void _measureAsync() {
final sw = Stopwatch()..start();
- // MED-06: Same note โ measures time to next frame, not isolate parse time.
+ // Same note โ measures time to next frame, not isolate parse time.
// For async mode the callback fires after the UI thread resumes, which can
// be significantly later than when the isolate finished.
WidgetsBinding.instance.addPostFrameCallback((_) {
diff --git a/example/lib/reader_app/reader_screen.dart b/example/lib/reader_app/reader_screen.dart
index cd146f2..9c63295 100644
--- a/example/lib/reader_app/reader_screen.dart
+++ b/example/lib/reader_app/reader_screen.dart
@@ -75,12 +75,12 @@ class _ReaderScreenState extends State {
super.initState();
_currentBook = widget.book;
_pageController = HyperPageController(initialPage: _currentBook.lastPage);
- // CRIT-01: listener removed in dispose(); mounted guard inside callback.
+ // listener removed in dispose(); mounted guard inside callback.
_pageController.currentPage.addListener(_onPageChanged);
}
void _onPageChanged() {
- // CRIT-01: ValueNotifier callbacks can fire during Flutter teardown.
+ // ValueNotifier callbacks can fire during Flutter teardown.
if (!mounted) return;
final newPage = _pageController.currentPage.value;
if (_currentBook.lastPage != newPage) {
diff --git a/example/lib/stress_test_demo.dart b/example/lib/stress_test_demo.dart
index 4ef1181..5bb1953 100644
--- a/example/lib/stress_test_demo.dart
+++ b/example/lib/stress_test_demo.dart
@@ -985,7 +985,7 @@ class _StressTestDemoState extends State {
html: content,
mode: HyperRenderMode.auto,
selectable: true,
- // CRIT-03: Explicit sanitize:true even though it is the default.
+ // Explicit sanitize:true even though it is the default.
// This demo renders arbitrary user-supplied URLs โ keeping sanitize
// explicit makes the security posture clear to anyone reading the code
// and prevents accidental removal when copy-pasting to production.
diff --git a/example/pubspec.lock b/example/pubspec.lock
index e5aa182..5eb7999 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -350,21 +350,21 @@ packages:
path: ".."
relative: true
source: path
- version: "1.7.1"
+ version: "1.8.0"
hyper_render_core:
dependency: "direct main"
description:
path: "../packages/hyper_render_core"
relative: true
source: path
- version: "1.7.0"
+ version: "1.8.0"
hyper_render_epub:
dependency: "direct main"
description:
path: "../packages/hyper_render_epub"
relative: true
source: path
- version: "0.1.1"
+ version: "0.1.2"
hyper_render_highlight:
dependency: "direct overridden"
description:
diff --git a/example/test/all_demos_smoke_test.dart b/example/test/all_demos_smoke_test.dart
index 89596c9..d44ec5c 100644
--- a/example/test/all_demos_smoke_test.dart
+++ b/example/test/all_demos_smoke_test.dart
@@ -17,6 +17,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:example/accessibility_demo.dart';
+import 'package:example/ai_streaming_demo.dart';
import 'package:example/animation_demo.dart';
import 'package:example/base_url_demo.dart';
import 'package:example/cjk_languages_demo.dart';
@@ -43,6 +44,7 @@ import 'package:example/v2_1_showcase.dart';
/// Each entry: a label and a builder for the demo's screen widget.
final _demos = {
'AccessibilityDemo': () => const AccessibilityDemo(),
+ 'AiStreamingDemo': () => const AiStreamingDemo(),
'AnimationDemo': () => const AnimationDemo(),
'BaseUrlDemo': () => const BaseUrlDemo(),
'CjkLanguagesDemo': () => const CjkLanguagesDemo(),
diff --git a/lib/hyper_render.dart b/lib/hyper_render.dart
index daf9760..a468a98 100644
--- a/lib/hyper_render.dart
+++ b/lib/hyper_render.dart
@@ -148,6 +148,13 @@ export 'package:hyper_render_core/hyper_render_core.dart'
HyperNodePlugin,
HyperPluginRegistry,
HyperPluginBuildContext,
+ // Streaming (v1.8.0)
+ HyperStreamingController,
+ HyperStreamingState,
+ HyperStreamingStatus,
+ StreamSyntaxNormalizer,
+ HyperTypingCaret,
+ HyperTypingCaretStyle,
// Loading / error UI
LoadingSkeleton,
HyperErrorWidget,
diff --git a/lib/src/widgets/hyper_viewer.dart b/lib/src/widgets/hyper_viewer.dart
index 0e89f2b..a81d6ea 100644
--- a/lib/src/widgets/hyper_viewer.dart
+++ b/lib/src/widgets/hyper_viewer.dart
@@ -469,6 +469,36 @@ class HyperViewer extends StatefulWidget {
/// ```
final HyperImageLoader? imageLoader;
+ /// Optional streaming controller for real-time AI and LLM token feeds.
+ ///
+ /// When non-null, [HyperViewer] listens to tokens from [streamingController],
+ /// batch-renders them with frame throttling, auto-repairs in-flight markdown/HTML,
+ /// displays the typing caret, and maintains stick-to-bottom scroll.
+ final HyperStreamingController? streamingController;
+
+ /// Whether to auto-repair transient incomplete syntax tokens (e.g. unclosed
+ /// code blocks or bold asterisks) during in-flight streaming.
+ ///
+ /// Default: true.
+ final bool autoRepairSyntax;
+
+ /// Whether to automatically follow the stream tail and scroll down as new tokens
+ /// arrive.
+ ///
+ /// Default: true.
+ final bool autoScrollToBottom;
+
+ /// Whether to display an animated typing cursor at the tail while streaming is active.
+ ///
+ /// Default: true.
+ final bool showTypingCaret;
+
+ /// Visual style of the typing caret.
+ final HyperTypingCaretStyle caretStyle;
+
+ /// Color of the typing caret (defaults to theme primary color).
+ final Color? caretColor;
+
/// Creates a HyperViewer for HTML content (default)
///
/// ```dart
@@ -523,6 +553,12 @@ class HyperViewer extends StatefulWidget {
this.imageLoader,
}) : content = html,
contentType = HyperContentType.html,
+ streamingController = null,
+ autoRepairSyntax = false,
+ autoScrollToBottom = false,
+ showTypingCaret = false,
+ caretStyle = HyperTypingCaretStyle.bar,
+ caretColor = null,
_prebuiltDocument = null;
/// Creates a HyperViewer for Quill Delta JSON content
@@ -575,6 +611,12 @@ class HyperViewer extends StatefulWidget {
this.imageLoader,
}) : content = delta,
contentType = HyperContentType.delta,
+ streamingController = null,
+ autoRepairSyntax = false,
+ autoScrollToBottom = false,
+ showTypingCaret = false,
+ caretStyle = HyperTypingCaretStyle.bar,
+ caretColor = null,
_prebuiltDocument = null;
/// Creates a HyperViewer for Markdown content
@@ -627,6 +669,76 @@ class HyperViewer extends StatefulWidget {
this.imageLoader,
}) : content = markdown,
contentType = HyperContentType.markdown,
+ streamingController = null,
+ autoRepairSyntax = false,
+ autoScrollToBottom = false,
+ showTypingCaret = false,
+ caretStyle = HyperTypingCaretStyle.bar,
+ caretColor = null,
+ _prebuiltDocument = null;
+
+ /// Creates a [HyperViewer] for real-time AI and LLM streaming token feeds.
+ ///
+ /// Listens to [streamingController], automatically repairs incomplete syntactic tokens
+ /// on-the-fly, displays an animated [HyperTypingCaret], and smoothly auto-scrolls down.
+ ///
+ /// ```dart
+ /// final controller = HyperStreamingController();
+ /// controller.bindStream(geminiResponseStream);
+ ///
+ /// HyperViewer.streaming(
+ /// streamingController: controller,
+ /// contentType: HyperContentType.markdown,
+ /// )
+ /// ```
+ const HyperViewer.streaming({
+ super.key,
+ required this.streamingController,
+ this.contentType = HyperContentType.markdown,
+ this.autoRepairSyntax = true,
+ this.autoScrollToBottom = true,
+ this.showTypingCaret = true,
+ this.caretStyle = HyperTypingCaretStyle.bar,
+ this.caretColor,
+ this.mode = HyperRenderMode.sync,
+ this.selectable = true,
+ this.onLinkTap,
+ this.allowedCustomSchemes,
+ this.widgetBuilder,
+ this.placeholderBuilder,
+ this.fallbackBuilder,
+ this.enableZoom = false,
+ this.minScale = 0.5,
+ this.maxScale = 4.0,
+ this.contentParser,
+ this.codeHighlighter,
+ this.showSelectionMenu = true,
+ this.selectionHandleColor,
+ this.selectionColor,
+ this.selectionMenuActionsBuilder,
+ this.selectionContextMenuBuilder,
+ this.sanitize = true,
+ this.textDirection,
+ this.textScaler,
+ this.allowedTags,
+ this.allowDataAttributes = false,
+ this.semanticLabel,
+ this.excludeSemantics = false,
+ this.baseUrl,
+ this.customCss,
+ this.debugShowHyperRenderBounds = false,
+ this.enableComplexFilters = true,
+ this.captureKey,
+ this.shrinkWrap = false,
+ this.physics,
+ this.onError,
+ this.controller,
+ this.pageController,
+ this.pluginRegistry,
+ this.onMemoryPressure,
+ this.renderConfig = HyperRenderConfig.defaults,
+ this.imageLoader,
+ }) : content = '',
_prebuiltDocument = null;
/// Creates a [HyperViewer] from a pre-parsed [DocumentNode], skipping
@@ -671,6 +783,12 @@ class HyperViewer extends StatefulWidget {
this.imageLoader,
}) : content = '',
contentType = HyperContentType.html,
+ streamingController = null,
+ autoRepairSyntax = false,
+ autoScrollToBottom = false,
+ showTypingCaret = false,
+ caretStyle = HyperTypingCaretStyle.bar,
+ caretColor = null,
mode = HyperRenderMode.sync,
placeholderBuilder = null,
fallbackBuilder = null,
@@ -719,7 +837,14 @@ class _HyperViewerState extends State
late final AnimationController _contentFadeController;
late final Animation _contentFadeAnimation;
- // โโ CRIT-03: Global text-cache-size ref-counting โโโโโโโโโโโโโโโโโโโโโโโโโ
+ // Whether the content fade has already played once for the current
+ // streaming session, so a throttled mid-stream tick (as often as every
+ // ~16ms) doesn't perpetually restart a 300ms fade and make content
+ // flicker/never settle to full opacity. Only consulted when
+ // `widget.streamingController != null`.
+ bool _streamingFadeStarted = false;
+
+ // โโ Global text-cache-size ref-counting โโโโโโโโโโโโโโโโโโโโโโโโโ
//
// RenderHyperBox._globalTextPainters is a process-wide static LRU cache.
// Each HyperViewer registers its desired textPainterCacheSize here; when it
@@ -867,14 +992,27 @@ class _HyperViewerState extends State
);
}
+ /// Internal [ScrollController] used when [widget.autoScrollToBottom] is true
+ /// and no external [widget.controller] is provided.
+ ScrollController? _internalScrollController;
+
+ /// Effective scroll controller for sync / streaming view.
+ ScrollController? get _effectiveScrollController =>
+ widget.controller?.scrollController ?? _internalScrollController;
+
+ /// The raw, unparsed content to render: the live streaming buffer when a
+ /// [HyperStreamingController] is attached (`.streaming()` hardcodes
+ /// `widget.content` to `''`), otherwise the static [widget.content].
+ String get _rawContent => widget.streamingController?.text ?? widget.content;
+
@override
void initState() {
super.initState();
- // CRIT-03: Use ref-counted helper so multiple HyperViewers with different
+ // Use ref-counted helper so multiple HyperViewers with different
// cache sizes don't clobber each other.
_ownedTextCacheSize = widget.renderConfig.textPainterCacheSize;
_acquireTextCacheSize(_ownedTextCacheSize);
- // CRIT-01: Wire imageConcurrency config to the singleton image queue.
+ // Wire imageConcurrency config to the singleton image queue.
LazyImageQueue.instance.maxConcurrent =
widget.renderConfig.imageConcurrency;
WidgetsBinding.instance.addObserver(this);
@@ -895,14 +1033,67 @@ class _HyperViewerState extends State
if (widget.mode == HyperRenderMode.paged && widget.pageController == null) {
_ownedPageController = PageController();
}
+ if (widget.controller == null && widget.autoScrollToBottom) {
+ _internalScrollController = ScrollController();
+ }
+ widget.streamingController?.addListener(_onStreamingStateChanged);
+ _parseContent();
+ }
+
+ void _onStreamingStateChanged() {
+ if (!mounted) return;
+ // A reset() controller goes back to `idle` before streaming again โ that
+ // is semantically a new message, so the fade should replay for it.
+ if (widget.streamingController?.status == HyperStreamingStatus.idle) {
+ _streamingFadeStarted = false;
+ }
_parseContent();
+ _scrollToBottomIfApplicable();
+ }
+
+ void _scrollToBottomIfApplicable() {
+ if (!widget.autoScrollToBottom) return;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ final scrollCtrl = _effectiveScrollController;
+ if (scrollCtrl != null && scrollCtrl.hasClients) {
+ final position = scrollCtrl.position;
+ if (position.maxScrollExtent > 0) {
+ final distanceToBottom = position.maxScrollExtent - position.pixels;
+ if (distanceToBottom < 350) {
+ scrollCtrl.animateTo(
+ position.maxScrollExtent,
+ duration: const Duration(milliseconds: 100),
+ curve: Curves.easeOut,
+ );
+ }
+ }
+ }
+ });
}
@override
void didUpdateWidget(covariant HyperViewer oldWidget) {
super.didUpdateWidget(oldWidget);
- // BUG-02: Handle selectable toggle โ create/dispose controller as needed.
+ if (widget.controller == null &&
+ widget.autoScrollToBottom &&
+ _internalScrollController == null) {
+ _internalScrollController = ScrollController();
+ } else if (widget.controller != null && _internalScrollController != null) {
+ _internalScrollController?.dispose();
+ _internalScrollController = null;
+ }
+
+ if (oldWidget.streamingController != widget.streamingController) {
+ oldWidget.streamingController?.removeListener(_onStreamingStateChanged);
+ widget.streamingController?.addListener(_onStreamingStateChanged);
+ // A new controller instance starts a new streaming session for fade
+ // purposes, even if it already has accumulated text.
+ _streamingFadeStarted = false;
+ }
+
+ // Handle selectable toggle โ create/dispose controller as needed.
if (oldWidget.selectable != widget.selectable) {
if (widget.selectable) {
_virtualizedSelectionController = VirtualizedSelectionController(
@@ -915,7 +1106,7 @@ class _HyperViewerState extends State
}
}
- // BUG-05: When customCss changes the section hashes are stale (they only
+ // When customCss changes the section hashes are stale (they only
// cover text, not styles). Reset them so every section re-layouts.
if (oldWidget.customCss != widget.customCss) {
_sectionHashes = const [];
@@ -923,13 +1114,13 @@ class _HyperViewerState extends State
if (oldWidget.renderConfig.textPainterCacheSize !=
widget.renderConfig.textPainterCacheSize) {
- // CRIT-03: Use ref-counted helper to avoid clobbering peer HyperViewers.
+ // Use ref-counted helper to avoid clobbering peer HyperViewers.
_releaseTextCacheSize(_ownedTextCacheSize);
_ownedTextCacheSize = widget.renderConfig.textPainterCacheSize;
_acquireTextCacheSize(_ownedTextCacheSize);
}
- // CRIT-01: Keep imageConcurrency in sync with config.
+ // Keep imageConcurrency in sync with config.
if (oldWidget.renderConfig.imageConcurrency !=
widget.renderConfig.imageConcurrency) {
LazyImageQueue.instance.maxConcurrent =
@@ -951,16 +1142,19 @@ class _HyperViewerState extends State
!listEquals(oldWidget.allowedTags, widget.allowedTags) ||
oldWidget.allowDataAttributes != widget.allowDataAttributes ||
oldWidget.fallbackBuilder != widget.fallbackBuilder ||
- // BUG-08: Compare full renderConfig (value equality now available).
+ // Compare full renderConfig (value equality now available).
oldWidget.renderConfig != widget.renderConfig ||
- oldWidget.pluginRegistry != widget.pluginRegistry) {
+ oldWidget.pluginRegistry != widget.pluginRegistry ||
+ oldWidget.streamingController != widget.streamingController ||
+ oldWidget.autoRepairSyntax != widget.autoRepairSyntax) {
_parseContent();
}
}
@override
void dispose() {
- // CRIT-03: Release our size from the global ref-count so peer HyperViewers
+ widget.streamingController?.removeListener(_onStreamingStateChanged);
+ // Release our size from the global ref-count so peer HyperViewers
// see the correct maximum cache size after we're gone.
_releaseTextCacheSize(_ownedTextCacheSize);
WidgetsBinding.instance.removeObserver(this);
@@ -968,6 +1162,7 @@ class _HyperViewerState extends State
_contentFadeController.dispose();
_virtualizedSelectionController?.dispose();
_ownedPageController?.dispose();
+ _internalScrollController?.dispose();
super.dispose();
}
@@ -1293,7 +1488,7 @@ class _HyperViewerState extends State
// Relative URLs (scheme == '') are always forwarded โ the app's handler
// is responsible for resolving them against a base URL.
final isRelative = scheme.isEmpty;
- // BUG-03/09: Check BOTH allowedCustomSchemes (legacy widget param) AND
+ // Check BOTH allowedCustomSchemes (legacy widget param) AND
// renderConfig.extraLinkSchemes so neither registration path silently
// drops deep-link taps.
final customSchemes = widget.allowedCustomSchemes;
@@ -1349,6 +1544,22 @@ class _HyperViewerState extends State
return {...base, ...registry.registeredTags}.toList();
}
+ // Gate the fade so a throttled mid-stream tick (as often as every ~16ms)
+ // doesn't restart the 300ms fade before it can settle to full opacity โ
+ // it should only play once per streaming session. Non-streaming content
+ // swaps are unaffected and keep fading on every parse as before.
+ void _beginContentFade() {
+ if (widget.streamingController != null && _streamingFadeStarted) return;
+ _contentFadeController.reset();
+ }
+
+ void _completeContentFade() {
+ _contentFadeController.forward();
+ if (widget.streamingController != null) {
+ _streamingFadeStarted = true;
+ }
+ }
+
void _parseContent() {
// Fast path: pre-parsed AST โ skip all parsing.
if (widget._prebuiltDocument != null) {
@@ -1377,7 +1588,17 @@ class _HyperViewerState extends State
// RenderObject belonging to the previous document.
_sectionBoxes.clear();
- String contentToRender = widget.content;
+ String contentToRender = _rawContent;
+
+ if (widget.streamingController != null && widget.autoRepairSyntax) {
+ if (widget.contentType == HyperContentType.markdown) {
+ contentToRender =
+ StreamSyntaxNormalizer.normalizeMarkdown(contentToRender);
+ } else if (widget.contentType == HyperContentType.html) {
+ contentToRender = StreamSyntaxNormalizer.normalizeHtml(contentToRender);
+ }
+ }
+
// CSS collected from