diff --git a/.codex/skills/flutter-pub-release/SKILL.md b/.codex/skills/flutter-pub-release/SKILL.md index 55dfe846..36e087d6 100644 --- a/.codex/skills/flutter-pub-release/SKILL.md +++ b/.codex/skills/flutter-pub-release/SKILL.md @@ -67,11 +67,18 @@ python3 .codex/skills/flutter-pub-release/scripts/release_helper.py apply /CHANGELOG.md` - `packages//example/pubspec.lock` when present +- Then run dependency resolution from the package directory so lock/state stays consistent with the new version: + +```bash +cd packages/ +dart pub get +``` + 4. Validate narrowly. - Run package-scoped checks only when they are fast and relevant. - Do not expand into repo-wide validation unless the change genuinely spans packages. -5. Commit and push before drafting the release. +5. Commit and run publish dry-run before drafting the release. - Use an English commit message such as: ```text @@ -79,6 +86,13 @@ chore(release): prepare ``` - Commit the release changes directly on `main`. +- After commit, run a publish dry-run from the package directory and fix any reported errors before pushing: + +```bash +cd packages/ +dart pub publish --dry-run +``` + - Push `main`. 6. Draft the GitHub release. diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..a7a833bf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "dart": true + } +} \ No newline at end of file diff --git a/packages/common_crypto/test/aes_test.dart b/packages/common_crypto/test/aes_test.dart index 8aad4983..8aec6afd 100644 --- a/packages/common_crypto/test/aes_test.dart +++ b/packages/common_crypto/test/aes_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'package:common_crypto/common_crypto.dart'; @@ -11,30 +12,11 @@ Uint8List generateRandomBytes([int length = 32]) => Uint8List.fromList(List.generate(length, (i) => _random.nextInt(256))); void main() { - test('aseTest', () { - final source = Uint8List.fromList(utf8.encode('mixin')); - final key = generateRandomBytes(16); - final iv = generateRandomBytes(16); - final encrypted = aesEncrypt( - key: key, - data: source, - iv: iv, - ); - final decrypted = aesDecrypt( - key: key, - data: encrypted, - iv: iv, - ); - assert(listEquals(source, decrypted)); - }); - - test('benchmark', () { - final source = generateRandomBytes(1024 * 10); // 10kb data - var totalTime = Duration.zero; - for (var i = 0; i < 10000; i++) { + group('aes', () { + test('aseTest', () { + final source = Uint8List.fromList(utf8.encode('mixin')); final key = generateRandomBytes(16); final iv = generateRandomBytes(16); - final stopwatch = Stopwatch()..start(); final encrypted = aesEncrypt( key: key, data: source, @@ -45,43 +27,67 @@ void main() { data: encrypted, iv: iv, ); - totalTime += stopwatch.elapsed; - if (i % 1000 == 0) { - debugPrint('benchmark aesEncrypt/aesDecrypt: $i times'); - } assert(listEquals(source, decrypted)); - } - debugPrint('benchmark aesEncrypt/aesDecrypt: $totalTime'); - }); + }); - test('random encrypt test', () { - final random = Random.secure(); - for (var start = 0; start < 10; start++) { - debugPrint('random encrypt test: $start'); - final key = generateRandomBytes(16); - final iv = generateRandomBytes(16); + test('benchmark', () { + final source = generateRandomBytes(1024 * 10); // 10kb data + var totalTime = Duration.zero; + for (var i = 0; i < 10000; i++) { + final key = generateRandomBytes(16); + final iv = generateRandomBytes(16); + final stopwatch = Stopwatch()..start(); + final encrypted = aesEncrypt( + key: key, + data: source, + iv: iv, + ); + final decrypted = aesDecrypt( + key: key, + data: encrypted, + iv: iv, + ); + totalTime += stopwatch.elapsed; + if (i % 1000 == 0) { + debugPrint('benchmark aesEncrypt/aesDecrypt: $i times'); + } + assert(listEquals(source, decrypted)); + } + debugPrint('benchmark aesEncrypt/aesDecrypt: $totalTime'); + }); - final hMacKey = generateRandomBytes(); + test('random encrypt test', () { + final random = Random.secure(); + for (var start = 0; start < 10; start++) { + debugPrint('random encrypt test: $start'); + final key = generateRandomBytes(16); + final iv = generateRandomBytes(16); - final encryptor = AesCryptor(encrypt: true, key: key, iv: iv); - final decryptor = AesCryptor(encrypt: false, key: key, iv: iv); + final hMacKey = generateRandomBytes(); - final sourceHMac = HMacSha256(hMacKey); - final targetHMac = HMacSha256(hMacKey); + final encryptor = AesCryptor(encrypt: true, key: key, iv: iv); + final decryptor = AesCryptor(encrypt: false, key: key, iv: iv); - for (var i = 0; i < 500; i++) { - final source = generateRandomBytes(random.nextInt(1024)); - sourceHMac.update(source); - final decrypted = decryptor.update(encryptor.update(source)); - targetHMac.update(decrypted); - } + final sourceHMac = HMacSha256(hMacKey); + final targetHMac = HMacSha256(hMacKey); + + for (var i = 0; i < 500; i++) { + final source = generateRandomBytes(random.nextInt(1024)); + sourceHMac.update(source); + final decrypted = decryptor.update(encryptor.update(source)); + targetHMac.update(decrypted); + } - targetHMac.update(decryptor.update(encryptor.finalize())); - targetHMac.update(decryptor.finalize()); + targetHMac.update(decryptor.update(encryptor.finalize())); + targetHMac.update(decryptor.finalize()); - final sourceResult = sourceHMac.finalize(); - final targetResult = targetHMac.finalize(); - expect(base64Encode(sourceResult), base64Encode(targetResult)); - } - }); + final sourceResult = sourceHMac.finalize(); + final targetResult = targetHMac.finalize(); + expect(base64Encode(sourceResult), base64Encode(targetResult)); + } + }); + }, skip: _skipOnNonMacOS); } + +final Object _skipOnNonMacOS = + Platform.isMacOS ? false : 'common_crypto tests require macOS CommonCrypto'; diff --git a/packages/common_crypto/test/hmac_test.dart b/packages/common_crypto/test/hmac_test.dart index c379db20..eeed8c66 100644 --- a/packages/common_crypto/test/hmac_test.dart +++ b/packages/common_crypto/test/hmac_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; @@ -10,38 +11,44 @@ import 'package:pointycastle/export.dart'; import 'aes_test.dart'; void main() { - test('hmac 256 test', () { - final key = base64Decode('Y9hRHsqr9adyX29DYsjWhg=='); - final data = Uint8List.fromList(utf8.encode('Mixin')); - final result = HMacSha256.hmacSha256(key: key, data: data); - expect( - base64Encode(result), - equals('Zg/Z5GkXYHKPR/uPVOe4Z5ZPzSgRoDL72mrm5/TyCrQ='), - ); - }); - - test('random generate hmac', () { - final random = Random.secure(); - for (var start = 0; start < 10; start++) { - final hMacKey = generateRandomBytes(); - final commonCryptoHMac = HMacSha256(hMacKey); - final pointyHMac = HMac(SHA256Digest(), 64)..init(KeyParameter(hMacKey)); - - final data = random.nextInt(1024); - for (var i = 0; i < data; i++) { - final bytes = generateRandomBytes(random.nextInt(1024)); - commonCryptoHMac.update(bytes); - pointyHMac.update(bytes, 0, bytes.length); - } + group('hmac', () { + test('hmac 256 test', () { + final key = base64Decode('Y9hRHsqr9adyX29DYsjWhg=='); + final data = Uint8List.fromList(utf8.encode('Mixin')); + final result = HMacSha256.hmacSha256(key: key, data: data); + expect( + base64Encode(result), + equals('Zg/Z5GkXYHKPR/uPVOe4Z5ZPzSgRoDL72mrm5/TyCrQ='), + ); + }); + + test('random generate hmac', () { + final random = Random.secure(); + for (var start = 0; start < 10; start++) { + final hMacKey = generateRandomBytes(); + final commonCryptoHMac = HMacSha256(hMacKey); + final pointyHMac = HMac(SHA256Digest(), 64) + ..init(KeyParameter(hMacKey)); + + final data = random.nextInt(1024); + for (var i = 0; i < data; i++) { + final bytes = generateRandomBytes(random.nextInt(1024)); + commonCryptoHMac.update(bytes); + pointyHMac.update(bytes, 0, bytes.length); + } - final commonCryptoResult = commonCryptoHMac.finalize(); - final bytes = Uint8List(pointyHMac.macSize); - final len = pointyHMac.doFinal(bytes, 0); - final pointyResult = bytes.sublist(0, len); + final commonCryptoResult = commonCryptoHMac.finalize(); + final bytes = Uint8List(pointyHMac.macSize); + final len = pointyHMac.doFinal(bytes, 0); + final pointyResult = bytes.sublist(0, len); - debugPrint('commonCryptoResult: ${base64Encode(commonCryptoResult)} ' - 'pointyResult: ${base64Encode(pointyResult)}'); - expect(commonCryptoResult, equals(pointyResult)); - } - }); + debugPrint('commonCryptoResult: ${base64Encode(commonCryptoResult)} ' + 'pointyResult: ${base64Encode(pointyResult)}'); + expect(commonCryptoResult, equals(pointyResult)); + } + }); + }, skip: _skipOnNonMacOS); } + +final Object _skipOnNonMacOS = + Platform.isMacOS ? false : 'common_crypto tests require macOS CommonCrypto'; diff --git a/packages/mixin_markdown_widget/CHANGELOG.md b/packages/mixin_markdown_widget/CHANGELOG.md index 2dc806d0..829b6e1f 100644 --- a/packages/mixin_markdown_widget/CHANGELOG.md +++ b/packages/mixin_markdown_widget/CHANGELOG.md @@ -2,11 +2,4 @@ ## 0.1.0 -- Initial implementation of `MarkdownWidget`, `MarkdownController`, and `MarkdownThemeData` -- Added block-based document model and Markdown parser adapter -- Added renderers for common Markdown blocks and a desktop demo example -- Added plain-text serialization, copy-all actions, and streaming draft state APIs -- Added `MarkdownSelectionController` and selection-aware plain-text range serialization -- Added custom drag selection, keyboard shortcuts, block/word selection gestures, and a custom context menu for selection copy flows -- Added syntax-highlighted code blocks with character-level custom selection -- Added table cell range selection and TSV/plain-text copy support +- Initial implementation diff --git a/packages/mixin_markdown_widget/LICENSE b/packages/mixin_markdown_widget/LICENSE index ba75c69f..2d00607d 100644 --- a/packages/mixin_markdown_widget/LICENSE +++ b/packages/mixin_markdown_widget/LICENSE @@ -1 +1,21 @@ -TODO: Add your license here. +MIT License + +Copyright (c) 2026 Mixin Network + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mixin_markdown_widget/example/devtools_options.yaml b/packages/mixin_markdown_widget/example/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/packages/mixin_markdown_widget/example/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/packages/mixin_markdown_widget/example/lib/ai_chat_demo.dart b/packages/mixin_markdown_widget/example/lib/ai_chat_demo.dart new file mode 100644 index 00000000..97d05e36 --- /dev/null +++ b/packages/mixin_markdown_widget/example/lib/ai_chat_demo.dart @@ -0,0 +1,614 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:mixin_markdown_widget/mixin_markdown_widget.dart'; + +class AIChatDemoPage extends StatefulWidget { + const AIChatDemoPage({super.key}); + + @override + State createState() => _AIChatDemoPageState(); +} + +class _AIChatDemoPageState extends State { + final List _messages = []; + final TextEditingController _textController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + bool _isTyping = false; + + @override + void initState() { + super.initState(); + _prePopulateMessages(); + } + + void _prePopulateMessages() { + final random = Random(); + // Pre-populate 20 messages (20 pairs) from the rich 5 presets. + for (var i = 0; i < 20; i++) { + _messages.add(ChatMessage.static( + text: 'Detailed Inquiry regarding technical specification #${i + 1}', + isUser: true, + )); + final markdown = _presetMarkdown[random.nextInt(_presetMarkdown.length)]; + _messages.add(ChatMessage.static( + text: markdown, + isUser: false, + )); + } + } + + final List _presetMarkdown = [ + r'''# I. Distributed Systems & Consensus Protocols: A Comprehensive Analysis + +This document serves as an exhaustive reference for the architectural evolution of distributed consensus, ranging from the foundational FLP impossibility result to modern-day multi-leader Raft implementations. + +## 1. The Core Impossibility +The **FLP Impossibility** (Fischer, Lynch, and Paterson) states that in an asynchronous network where even one process might fail, no deterministic algorithm can guarantee consensus. + +> "In a purely asynchronous model, there is no way to distinguish a slow process from one that has crashed." +> +> > This leads to the requirement of either: +> > 1. **Partial Synchrony**: Making assumptions about network bounds. +> > 2. **Non-determinism**: Utilizing randomness to break symmetry. + +### 2. Comparison of Modern Consensus Algorithms + +| Algorithm | Leader Selection | Quorum Requirement | Typical Use Case | Latency Profile | +| :--- | :--- | :---: | :--- | :--- | +| **Paxos** | Proposer-based | \( (n+1)/2 \) | Low-level storage | Multi-round high latency | +| **Raft** | Heartbeat-based | \( (n+1)/2 \) | Etcd, Consul, Kubernetes | Single-round stable leader | +| **HotStuff** | Rotating Leader | \( 2f+1 \) | Blockchain (Libra/Diem) | Linear complexity \( O(n) \) | +| **Zab** | Epoch-based | Quorum-based | Apache Zookeeper | High throughput batching | + +### 3. Deep Dive into the Raft Log Structure +A Raft node's state machine depends on the strictly ordered log. Below is a representation of a log conflict resolution strategy in Rust: + +```rust +pub fn resolve_conflict(local_log: &mut Vec, leader_entries: Vec) -> Result<(), RaftError> { + for entry in leader_entries { + let index = entry.index; + if index < local_log.len() { + if local_log[index].term != entry.term { + local_log.truncate(index); + local_log.push(entry); + info!("Truncated log due to term mismatch at index {}", index); + } + } else { + local_log.push(entry); + } + } + // Update commit index based on leader's committed value + Ok(()) +} +``` + +## 4. Mathematical Foundation of Vector Clocks +To establish partial ordering, we utilize Vector Clocks. If we have \( N \) processes, each process \( P_i \) maintains a vector \( V_i \). + +$$ V_i[j] = \text{number of events in } P_j \text{ known to } P_i $$ + +The comparison rule is: +1. \( V \le V' \) iff \( \forall i, V[i] \le V'[i] \) +2. \( V < V' \) iff \( V \le V' \) and \( \exists j, V[j] < V'[j] \) + +### 5. Deployment Roadmap +- [x] Provisioning via Terraform +- [x] Initializing mTLS between peers +- [ ] Benchmarking under partitioned network conditions + - [x] 3-node cluster partition + - [ ] 5-node cluster partition (Byzantine simulation) + +[^1]: Lamport, L. (1998). "The Part-Time Parliament". +[^2]: Ongaro, D., & Ousterhout, J. (2014). "In Search of an Understandable Consensus Algorithm". +''', + r'''# II. Quantum Computing & Information Theory: The Qubit Frontier + +This treatise explores the mathematical landscape of Hilbert Spaces and the practical realization of gate-based quantum circuits. + +## 1. State Representation +A single qubit exists as a superposition of the basis states \( |0\rangle \) and \( |1\rangle \). + +$$ |\psi\rangle = \alpha |0\rangle + \beta |1\rangle $$ + +Where the complex amplitudes satisfy the normalization condition: +$$ |\alpha|^2 + |\beta|^2 = 1 $$ + +### 2. The Universal Gate Set +To perform arbitrary computations, we utilize a universal set of gates, typically consisting of: +* **Hadamard (H)**: Creates superposition. +* **CNOT**: Creates entanglement between two qubits. +* **T-gate**: Provides the necessary non-Clifford rotation. + +| Gate | Matrix Representation | Functionality | +| :--- | :---: | :--- | +| **H** | \( \frac{1}{\sqrt{2}}\begin{pmatrix} 1 & 1 \\ 1 & -1 \end{pmatrix} \) | Maps \( |0\rangle \to |+\rangle \) | +| **X** | \( \begin{pmatrix} 0 & 1 \\ 1 & 0 \end{pmatrix} \) | Bit-flip (Quantum NOT) | +| **Z** | \( \begin{pmatrix} 1 & 0 \\ 0 & -1 \end{pmatrix} \) | Phase-flip | + +### 3. Entanglement and Bell States +Quantum entanglement allows for correlations that exceed classical limits. A common Bell State is: + +$$ |\Phi^+\rangle = \frac{1}{\sqrt{2}} (|00\rangle + |11\rangle) $$ + +Implementation of Bell State generation in Python (using a hypothetical SDK): + +```python +import quantum_sim as qs + +def generate_bell_state(): + circuit = qs.Circuit(2) + # Apply Hadamard to the first qubit + circuit.h(0) + # Entangle with the second qubit + circuit.cnot(0, 1) + + # Run on a simulator + backend = qs.get_backend('statevector_simulator') + result = backend.run(circuit) + return result.get_statevector() + +# Result: [0.707, 0, 0, 0.707] +``` + +## 4. Quantum Error Correction (QEC) +Unlike classical bits, qubits are susceptible to: +1. **Bit Flips** (\( X \)-errors) +2. **Phase Flips** (\( Z \)-errors) +3. **Cross-talk** and Decoherence + +> "The Surface Code is currently the most promising architecture for fault-tolerant quantum computing due to its high error threshold (approx. 1%)." + +- [ ] Implement Steane Code (7-qubit) +- [x] Verify Shor's Code (9-qubit) +- [ ] Research Topological Protection + +*** + +*Document Status: Draft v0.9.3 | Scientific Review Required.* +''', + r'''# III. Advanced Bioinformatics: Genomic Mapping and Computational Proteomics + +An exploration of the algorithmic complexities involved in de novo genome assembly and protein folding simulations. + +## 1. Sequence Alignment Algorithms +At the heart of bioinformatics lies the comparison of DNA sequences. We distinguish between global and local alignment. + +### Needleman-Wunsch (Global) vs. Smith-Waterman (Local) +The scoring matrix \( H \) is defined as: + +$$ H_{i,j} = \max \begin{cases} H_{i-1,j-1} + S(a_i, b_j) \\ H_{i-1,j} - d \\ H_{i,j-1} - d \end{cases} $$ + +Where: +* \( S(a_i, b_j) \) is the substitution score. +* \( d \) is the gap penalty. + +### 2. Protein Folding and AlphaFold2 +The transition from 1D amino acid sequences to 3D structures is a problem of energy minimization. + +```python +class ProteinFolder: + def __init__(self, sequence: str): + self.sequence = sequence + self.amino_acids = ["A", "R", "N", "D", "C", "Q", "E", "G", "H", "I"] + + def calculate_gibbs_free_energy(self, fold: dict) -> float: + """Calculate the delta G of a specific conformation.""" + energy = 0.0 + # Complex physics-based calculation here + for bond in fold['bonds']: + energy += bond.strength * (bond.length - bond.equilibrium_length)**2 + return energy +``` + +## 3. Genomic Database Schema +A typical bio-database requires high-throughput indexing for billions of base pairs. + +| Table Name | Primary Key | Attributes | Volume | +| :--- | :--- | :--- | :--- | +| `sequences` | `seq_id` | `raw_data`, `organism_id`, `quality_score` | 50 TB | +| `annotations` | `anno_id` | `start_pos`, `end_pos`, `gene_name` | 2 TB | +| `taxonomies` | `tax_id` | `genus`, `species`, `parent_tax_id` | 10 GB | + +## 4. Deeply Nested Taxonomy Example +- **Eukaryota** + - **Metazoa** + - **Chordata** + - **Mammalia** + - **Primates** + - *Homo sapiens* + - *Pan troglodytes* (Chimpanzee) + - **Rodentia** + - *Mus musculus* (House mouse) + - **Viridiplantae** + - **Streptophyta** + - **Magnoliopsida** (Flowering plants) + +> "The advent of CRISPR-Cas9 has revolutionized our ability to perform precision genomic edits, essentially allowing us to 'code' life itself." + +[^1]: Doudna, J. A., & Charpentier, E. (2014). "The new frontier of genome engineering with CRISPR-Cas9". +''', + r'''# IV. Cybersecurity & Cryptographic Engineering: Building Hardened Protocols + +This specification details the construction of a post-quantum secure transport layer (PQ-TLS) designed for adversarial environments. + +## 1. Key Exchange via Kyber +Kyber is a Module Lattice-based Key Encapsulation Mechanism (KEM). + +$$ \text{Public Key: } b = As + e \pmod q $$ + +Where: +* \( A \) is a random matrix. +* \( s \) is the secret vector. +* \( e \) is the error distribution (Gaussian). + +### 2. Implementation of a Secure Buffer +To prevent Buffer Overflow and Side-channel attacks, we utilize constant-time comparison and zero-over-write memory management. + +```c +#include +#include + +/** + * Constant-time memory comparison to prevent timing attacks. + */ +int secure_memcmp(const void *a, const void *b, size_t n) { + const uint8_t *_a = a; + const uint8_t *_b = b; + uint8_t result = 0; + for (size_t i = 0; i < n; i++) { + result |= (_a[i] ^ _b[i]); + } + return (result != 0); +} + +void zero_sensitive_data(void *p, size_t n) { + volatile uint8_t *_p = p; + while (n--) *_p++ = 0; +} +``` + +## 3. Protocol Frame Structure (Binary Format) + +| Byte Offset | Field Name | Type | Description | +| :--- | :--- | :---: | :--- | +| 0 | `Version` | `u8` | Protocol version (current: `0x03`) | +| 1-2 | `Payload Length` | `u16` | Big-endian length of following data | +| 3 | `Frame Type` | `u8` | `0x01` (Handshake), `0x02` (Data), `0x03` (Alert) | +| 4-11 | `Nonce` | `u64` | Monotonically increasing counter | +| 12-N | `Ciphertext` | `bytes` | AEAD Encrypted payload (ChaCha20-Poly1305) | + +## 4. Threat Model Analysis +- **Attacker Capability**: MITM with capture-and-hold capabilities. +- **Risk Mitigation**: + - **Forward Secrecy**: Ephemeral keys are destroyed immediately after session termination. + - **Identity Binding**: Certificate Transparency logs required for all root anchors. + - **Rate Limiting**: IP-based throttling at the edge layer. + +> "A cryptosystem is only as strong as its weakest implementation. Avoid 'roll-your-own-crypto' at all costs." + +- [x] Implement SHA-3 (Keccak) +- [x] Verify Ed25519 signature validation +- [ ] Integrate Dilithium post-quantum signatures +- [ ] Audit zero-knowledge proof (ZKP) module + +*** +*Classified Document | For Authorized Engineering Personnel Only.* +''', + r'''# V. The Ultimate Markdown & LaTeX Stress Test Document + +This document combines every supported feature into a single, high-complexity rendering benchmark. + +## 1. Nested Blockquote Hierarchy with Inline Math +> This is a level 1 quote. +> > Level 2 quote containing a complex formula: +> > $$ \int_a^b \frac{d}{dx} \left( \sum_{i=1}^\infty \frac{x^i}{i!} \right) dx = e^b - e^a $$ +> > > Level 3 quote with a task list and code: +> > > - [x] Support deep nesting +> > > - [ ] Performance optimization for large tables +> > > ```javascript +> > > const stressTest = (iterations) => { +> > > for(let i=0; i > > console.log(`Render iteration: ${i}`); +> > > } +> > > } +> > > ``` + +### 2. The Mega Table: Mixed Content Alignment +| Category | Technical Description | Status | Formula / Code | +| :--- | :--- | :---: | :--- | +| **Parsing** | Supports GFM (GitHub Flavored Markdown) with incremental updates. | ✅ | `parser.parse(chunk)` | +| **Math** | Full LaTeX support via KaTeX-style syntax. | 🚀 | \( \sqrt{x^2 + y^2} = z \) | +| **Tables** | Cell-level formatting and varying alignments. | 🎨 | `| A | B |` | +| **Links** | Supports [inline links](https://flutter.dev) and automatic detection. | 🔗 | | + +## 3. Advanced List Structures +1. **Ordered Item with Code** + ```python + print("This code block is inside an ordered list!") + ``` +2. **Unordered Sub-lists** + * Sub-item A + * Sub-sub-item A.1 + * Sub-sub-item A.2 + * Sub-item B +3. **Definition Style Lists** + * **Term A**: Description of term A. + * **Term B**: Description of term B with a footnote[^1]. + +## 4. Complex Textual Styles +You can use **bold**, *italic*, ***bold-italic***, ~~strikethrough~~, and `inline code` all in the same sentence. +Additionally, we support `Ctrl + C` and `underlined` tags depending on the configuration. + +## 5. Media Rendering +![High Res Nature](https://picsum.photos/id/20/1200/400) +*Figure 1: High-resolution banner image testing horizontal scaling and padding.* + +*** + +## 6. Footnotes Reference +[^1]: This is the first footnote, located inside a stress test document. +[^2]: This is the second footnote, testing multiple reference points. + +**End of Stress Test.** +''' + ]; + + void _handleSubmitted(String text) { + if (text.trim().isEmpty) return; + _textController.clear(); + setState(() { + _messages.add(ChatMessage.static( + text: text, + isUser: true, + )); + _isTyping = true; + }); + _scrollToBottom(); + _simulateAIResponse(); + } + + void _simulateAIResponse() async { + final random = Random(); + final responseMarkdown = + _presetMarkdown[random.nextInt(_presetMarkdown.length)]; + + final streamController = StreamController(); + final aiMessage = ChatMessage.streaming( + isUser: false, + contentStream: streamController.stream, + ); + setState(() { + _messages.add(aiMessage); + }); + _scrollToBottom(); + + // Emit chunks to simulate LLM token streaming. + var offset = 0; + while (offset < responseMarkdown.length) { + await Future.delayed(Duration(milliseconds: random.nextInt(30) + 10)); + if (!mounted) break; + final chunkSize = random.nextInt(8) + 3; + final end = min(offset + chunkSize, responseMarkdown.length); + streamController.add(responseMarkdown.substring(offset, end)); + offset = end; + _scrollToBottom(); + } + + await streamController.close(); + if (mounted) { + setState(() { + _isTyping = false; + }); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Advanced AI Chat Demo'), + elevation: 1, + ), + body: Column( + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + itemCount: _messages.length, + itemBuilder: (context, index) { + return _MessageTile(message: _messages[index]); + }, + ), + ), + if (_isTyping) + const Padding( + padding: EdgeInsets.all(8.0), + child: Text('AI is processing massive data...', + style: TextStyle( + fontStyle: FontStyle.italic, color: Colors.grey)), + ), + const Divider(height: 1), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + color: Theme.of(context).cardColor, + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + onSubmitted: _handleSubmitted, + decoration: const InputDecoration( + hintText: + 'Ask about distributed systems, quantum physics...', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + contentPadding: + EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: () => _handleSubmitted(_textController.text), + ), + ], + ), + ), + ], + ), + ); + } +} + +class ChatMessage { + /// Creates a message whose full text is known up front (user messages and + /// pre-populated AI messages). + ChatMessage.static({required this.text, required this.isUser}) + : contentStream = null; + + /// Creates an AI message that will be populated incrementally via a stream. + ChatMessage.streaming({required this.isUser, required this.contentStream}) + : text = ''; + + final String text; + final bool isUser; + + /// Emits successive text chunks for streaming AI responses; null otherwise. + final Stream? contentStream; +} + +class _MessageTile extends StatefulWidget { + const _MessageTile({required this.message}); + + final ChatMessage message; + + @override + State<_MessageTile> createState() => _MessageTileState(); +} + +class _MessageTileState extends State<_MessageTile> { + final MarkdownController _controller = MarkdownController(); + StreamSubscription? _streamSub; + + @override + void initState() { + super.initState(); + if (!widget.message.isUser) { + final stream = widget.message.contentStream; + if (stream != null) { + // Streaming AI message: subscribe and feed chunks incrementally. + _streamSub = stream.listen( + (chunk) => _controller.appendChunk(chunk), + onDone: () => _controller.commitStream(), + ); + } else { + // Pre-populated static message: load all at once. + _controller.setData(widget.message.text); + } + } + } + + @override + void dispose() { + _streamSub?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _MessageTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message != widget.message && !widget.message.isUser) { + _streamSub?.cancel(); + _streamSub = null; + final stream = widget.message.contentStream; + if (stream != null) { + _controller.clear(); + _streamSub = stream.listen( + (chunk) => _controller.appendChunk(chunk), + onDone: () => _controller.commitStream(), + ); + } else { + _controller.setData(widget.message.text); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isUser = widget.message.isUser; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser) + const CircleAvatar( + child: Icon(Icons.terminal_rounded), + ), + if (!isUser) const SizedBox(width: 12), + Flexible( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isUser + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isUser ? 16 : 0), + bottomRight: Radius.circular(isUser ? 0 : 16), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: isUser + ? Text(widget.message.text, + style: const TextStyle(fontWeight: FontWeight.w500)) + : MarkdownWidget( + controller: _controller, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + theme: MarkdownThemeData.fallback(context).copyWith( + maxContentWidth: double.infinity, + ), + ), + ), + ), + if (isUser) const SizedBox(width: 12), + if (isUser) + const CircleAvatar( + backgroundColor: Colors.blueGrey, + child: Icon(Icons.account_circle, color: Colors.white), + ), + ], + ), + ); + } +} diff --git a/packages/mixin_markdown_widget/example/lib/main.dart b/packages/mixin_markdown_widget/example/lib/main.dart index 3e16bab3..7fd5a431 100644 --- a/packages/mixin_markdown_widget/example/lib/main.dart +++ b/packages/mixin_markdown_widget/example/lib/main.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:mixin_markdown_widget/mixin_markdown_widget.dart'; +import 'ai_chat_demo.dart'; + enum _DemoThemePreset { ocean, warm, @@ -205,8 +207,7 @@ This content was appended through `MarkdownController.appendChunk`. super.initState(); _controller = MarkdownController(data: _initialMarkdown); _selectionController = MarkdownSelectionController() - ..attachDocument(_controller.document) - ..addListener(_handleSelectionChanged); + ..attachDocument(_controller.document); _editorController = TextEditingController(text: _initialMarkdown) ..addListener(_handleEditorChanged); } @@ -216,9 +217,7 @@ This content was appended through `MarkdownController.appendChunk`. _editorController ..removeListener(_handleEditorChanged) ..dispose(); - _selectionController - ..removeListener(_handleSelectionChanged) - ..dispose(); + _selectionController.dispose(); _controller.dispose(); super.dispose(); } @@ -244,6 +243,17 @@ This content was appended through `MarkdownController.appendChunk`. appBar: AppBar( title: const Text('mixin_markdown_widget'), actions: [ + IconButton( + tooltip: 'AI Chat Demo', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AIChatDemoPage(), + ), + ); + }, + icon: const Icon(Icons.chat_outlined), + ), IconButton( key: const Key('toggle-editor-visibility'), tooltip: _layoutMode == _DemoLayoutMode.split @@ -301,12 +311,15 @@ This content was appended through `MarkdownController.appendChunk`. onPressed: _selectAllModelText, icon: const Icon(Icons.select_all_rounded), ), - IconButton( - tooltip: 'Copy selected model text', - onPressed: _selectionController.hasSelection - ? _copySelectedModelText - : null, - icon: const Icon(Icons.content_copy_outlined), + AnimatedBuilder( + animation: _selectionController, + builder: (context, _) => IconButton( + tooltip: 'Copy selected model text', + onPressed: _selectionController.hasSelection + ? _copySelectedModelText + : null, + icon: const Icon(Icons.content_copy_outlined), + ), ), IconButton( tooltip: 'Commit stream draft', @@ -347,7 +360,13 @@ This content was appended through `MarkdownController.appendChunk`. ); final previewPanel = _PaneShell( title: 'Preview', - subtitle: _previewSubtitle(), + subtitleBuilder: (context) => AnimatedBuilder( + animation: _selectionController, + builder: (context, _) => Text( + _previewSubtitle(), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), child: MarkdownWidget( key: const Key('markdown-preview'), controller: _controller, @@ -416,13 +435,6 @@ This content was appended through `MarkdownController.appendChunk`. } } - void _handleSelectionChanged() { - if (!mounted) { - return; - } - setState(() {}); - } - void _appendChunk() { if (_chunkIndex >= _streamChunks.length) { return; @@ -513,13 +525,15 @@ This content was appended through `MarkdownController.appendChunk`. class _PaneShell extends StatelessWidget { const _PaneShell({ required this.title, - required this.subtitle, required this.child, + this.subtitle, + this.subtitleBuilder, }); final String title; - final String subtitle; final Widget child; + final String? subtitle; + final WidgetBuilder? subtitleBuilder; @override Widget build(BuildContext context) { @@ -544,7 +558,13 @@ class _PaneShell extends StatelessWidget { children: [ Text(title, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 6), - Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), + if (subtitleBuilder != null) + subtitleBuilder!(context) + else if (subtitle != null) + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium, + ), const SizedBox(height: 16), Expanded(child: child), ], diff --git a/packages/mixin_markdown_widget/lib/src/render/builder/markdown_block_builder.dart b/packages/mixin_markdown_widget/lib/src/render/builder/markdown_block_builder.dart index 07014aa8..7f5fd6c1 100644 --- a/packages/mixin_markdown_widget/lib/src/render/builder/markdown_block_builder.dart +++ b/packages/mixin_markdown_widget/lib/src/render/builder/markdown_block_builder.dart @@ -20,6 +20,8 @@ import '../local_image_provider_stub.dart' if (dart.library.io) '../local_image_provider_io.dart'; class MarkdownBlockBuilder { + static const double _slowBlockBuildLogThresholdMs = 2; + MarkdownBlockBuilder({ required this.theme, required this.selectionController, @@ -37,6 +39,8 @@ class MarkdownBlockBuilder { this.onTapLink, required this.onRequestContextMenu, required Map cachedBlockRows, + required this.tableLayoutPlanCache, + required this.codeHighlightCache, }) : _cachedBlockRows = cachedBlockRows; final MarkdownThemeData theme; @@ -54,6 +58,8 @@ class MarkdownBlockBuilder { final MarkdownBulletBuilder? bulletBuilder; final MarkdownTapLinkCallback? onTapLink; final void Function(Offset) onRequestContextMenu; + final MarkdownTableLayoutPlanCache tableLayoutPlanCache; + final MarkdownCodeHighlightCache codeHighlightCache; final Map _cachedBlockRows; @@ -63,6 +69,7 @@ class MarkdownBlockBuilder { required int blockIndex, required DocumentRange? selectionRange, }) { + final buildStopwatch = Stopwatch()..start(); final cacheable = _canCacheBlockRow(block); final selectionSignature = _selectionSignatureForBlock( block: block, @@ -100,10 +107,24 @@ class MarkdownBlockBuilder { ); if (!cacheable) { + _logSlowBlockBuild( + stopwatch: buildStopwatch, + block: block, + blockIndex: blockIndex, + cacheable: cacheable, + fromCache: false, + ); _cachedBlockRows.remove(block.id); return widget; } + _logSlowBlockBuild( + stopwatch: buildStopwatch, + block: block, + blockIndex: blockIndex, + cacheable: cacheable, + fromCache: false, + ); _cachedBlockRows[block.id] = CachedBlockRow( block: block, blockIndex: blockIndex, @@ -113,6 +134,28 @@ class MarkdownBlockBuilder { return widget; } + void _logSlowBlockBuild({ + required Stopwatch stopwatch, + required BlockNode block, + required int blockIndex, + required bool cacheable, + required bool fromCache, + }) { + stopwatch.stop(); + final elapsedMs = stopwatch.elapsedMicroseconds / 1000; + if (elapsedMs < _slowBlockBuildLogThresholdMs) { + return; + } + debugPrint( + '[mixin_markdown_widget] block.build ' + 'index=$blockIndex ' + 'kind=${block.kind.name} ' + 'cacheable=$cacheable ' + 'fromCache=$fromCache ' + 'elapsed=${elapsedMs.toStringAsFixed(3)}ms', + ); + } + Widget _buildBlockView( BuildContext context, { required BlockNode block, @@ -235,7 +278,7 @@ class MarkdownBlockBuilder { case MarkdownBlockKind.quote: final quoteBlock = block as QuoteBlock; final descriptor = - descriptorExtractor.buildQuoteSelectableDescriptor(quoteBlock); + descriptorExtractor.buildSelectableDescriptorForBlock(quoteBlock); return SelectableBlockSpec( child: MarkdownQuoteBlockView( theme: theme, @@ -288,7 +331,7 @@ class MarkdownBlockBuilder { case MarkdownBlockKind.unorderedList: final listBlock = block as ListBlock; final descriptor = - descriptorExtractor.buildListSelectableDescriptor(listBlock); + descriptorExtractor.buildSelectableDescriptorForBlock(listBlock); final itemRowKeys = keysRegistry.listItemKeysFor(listBlock); final itemContentKeys = keysRegistry.listItemContentKeysFor(listBlock); return SelectableBlockSpec( @@ -409,13 +452,15 @@ class MarkdownBlockBuilder { ); case MarkdownBlockKind.codeBlock: final codeBlock = block as CodeBlock; - final codeRuns = codeSyntaxHighlighter.buildPretextRuns( + final codeHighlightPresentation = codeHighlightCache.resolve( + blockId: codeBlock.id, source: codeBlock.code, baseStyle: theme.codeBlockStyle, theme: theme, language: codeBlock.language, ); - final codeSpan = _buildCodeTextSpan(codeBlock); + final codeRuns = codeHighlightPresentation.runs; + final codeSpan = codeHighlightPresentation.span; final directTextKey = _createDirectTextKeyIfNeeded(codeRuns); final scrollController = keysRegistry.codeBlockScrollControllers .putIfAbsent(codeBlock.id, ScrollController.new); @@ -466,6 +511,8 @@ class MarkdownBlockBuilder { final cellTextKeys = keysRegistry.tableCellTextKeysFor(tableBlock); final descriptor = descriptorExtractor.buildSelectableDescriptorForBlock(tableBlock); + final scrollController = keysRegistry.tableScrollControllers + .putIfAbsent(tableBlock.id, ScrollController.new); return SelectableBlockSpec( child: _buildTable( context, @@ -474,6 +521,7 @@ class MarkdownBlockBuilder { cellKeys[rowIndex][columnIndex], cellTextKeyBuilder: (rowIndex, columnIndex) => cellTextKeys[rowIndex][columnIndex], + scrollController: scrollController, ), plainText: descriptor.plainText, selectionStructure: _matchingSelectionStructure( @@ -484,6 +532,7 @@ class MarkdownBlockBuilder { textSpan: descriptor.span, highlightBorderRadius: theme.tableBorderRadius, selectionPaintOrder: SelectableBlockSelectionPaintOrder.aboveChild, + repaintListenable: scrollController, selectionColor: theme.selectionColor, selectionRectResolver: (context, _, range) => selectionResolver.resolveNestedBlockSelectionRects( @@ -492,8 +541,8 @@ class MarkdownBlockBuilder { block: tableBlock, descriptor: descriptor, range: range, - renderObject: context.findRenderObject() as RenderBox, // Ignored - origin: Offset.zero, // Ignored + renderObject: context.findRenderObject() as RenderBox, + origin: Offset.zero, textDirection: Directionality.of(context), ), textOffsetResolver: (context, _, localPosition) => @@ -502,9 +551,9 @@ class MarkdownBlockBuilder { rootRenderObject: context.findRenderObject() as RenderBox, block: tableBlock, descriptor: descriptor, - renderObject: context.findRenderObject() as RenderBox, // Ignored + renderObject: context.findRenderObject() as RenderBox, globalPosition: (context.findRenderObject() as RenderBox) - .localToGlobal(localPosition), // Ignored + .localToGlobal(localPosition), textDirection: Directionality.of(context), ), selectionUnitRangeResolver: (_, __, ___, position) => @@ -1190,11 +1239,18 @@ class MarkdownBlockBuilder { if (codeBlockBuilder != null) { return codeBlockBuilder!(context, block.code, block.language, theme); } + final codeHighlightPresentation = codeHighlightCache.resolve( + blockId: block.id, + source: block.code, + baseStyle: theme.codeBlockStyle, + theme: theme, + language: block.language, + ); final scrollController = keysRegistry.codeBlockScrollControllers .putIfAbsent(block.id, ScrollController.new); return _buildDecoratedCodeBlock( block, - codeSpan: _buildCodeTextSpan(block), + codeSpan: codeHighlightPresentation.span, scrollController: scrollController, ); } @@ -1218,27 +1274,23 @@ class MarkdownBlockBuilder { ); } - InlineSpan _buildCodeTextSpan(CodeBlock block) { - return codeSyntaxHighlighter.buildTextSpan( - source: block.code, - baseStyle: theme.codeBlockStyle, - theme: theme, - language: block.language, - ); - } - Widget _buildTable( BuildContext context, TableBlock block, { Key? Function(int rowIndex, int columnIndex)? cellKeyBuilder, GlobalKey? Function(int rowIndex, int columnIndex)? cellTextKeyBuilder, + ScrollController? scrollController, }) { return MarkdownTableBlockView( theme: theme, block: block, + layoutPlan: tableLayoutPlanCache.planFor(block), textWidgetBuilder: _buildInlineTextWidget, cellKeyBuilder: cellKeyBuilder, cellTextKeyBuilder: cellTextKeyBuilder, + scrollController: scrollController ?? + keysRegistry.tableScrollControllers + .putIfAbsent(block.id, ScrollController.new), ); } @@ -1248,9 +1300,14 @@ class MarkdownBlockBuilder { List inlines, TextAlign textAlign, { GlobalKey? directTextKey, + bool alignInlineMathToBaseline = true, }) { return MarkdownPretextTextBlock.rich( - runs: inlineBuilder.buildPretextRuns(style, inlines), + runs: inlineBuilder.buildPretextRuns( + style, + inlines, + alignInlineMathToBaseline: alignInlineMathToBaseline, + ), fallbackStyle: style, textAlign: textAlign, intrinsicWidthSafe: true, @@ -1655,16 +1712,13 @@ class MarkdownBlockBuilder { required int blockIndex, required DocumentRange? selectionRange, }) { - if (block is TableBlock) { - return _isBlockCoveredByTextSelection(blockIndex, selectionRange) - ? 'table:text' - : 'table:none'; - } - + final highlightSignature = block is CodeBlock + ? ':${codeHighlightCache.cacheSignatureFor(block.id)}' + : ''; if (selectionRange == null || blockIndex < selectionRange.start.blockIndex || blockIndex > selectionRange.end.blockIndex) { - return 'text:none'; + return 'text:none$highlightSignature'; } final start = blockIndex == selectionRange.start.blockIndex ? selectionRange.start.textOffset @@ -1672,16 +1726,7 @@ class MarkdownBlockBuilder { final end = blockIndex == selectionRange.end.blockIndex ? selectionRange.end.textOffset : plainTextSerializer.serializeBlockText(block).length; - return 'text:$start:$end'; - } - - bool _isBlockCoveredByTextSelection( - int blockIndex, - DocumentRange? selectionRange, - ) { - return selectionRange != null && - blockIndex >= selectionRange.start.blockIndex && - blockIndex <= selectionRange.end.blockIndex; + return 'text:$start:$end$highlightSignature'; } bool _canCacheBlockRow(BlockNode block) { @@ -1690,18 +1735,17 @@ class MarkdownBlockBuilder { return !_inlinesContainLinks((block as HeadingBlock).inlines); case MarkdownBlockKind.paragraph: return !_inlinesContainLinks((block as ParagraphBlock).inlines); - case MarkdownBlockKind.definitionList: - case MarkdownBlockKind.footnoteList: - return false; case MarkdownBlockKind.codeBlock: case MarkdownBlockKind.image: case MarkdownBlockKind.table: case MarkdownBlockKind.thematicBreak: + case MarkdownBlockKind.definitionList: + case MarkdownBlockKind.footnoteList: return true; case MarkdownBlockKind.quote: case MarkdownBlockKind.orderedList: case MarkdownBlockKind.unorderedList: - return false; + return true; } } diff --git a/packages/mixin_markdown_widget/lib/src/render/builder/markdown_inline_builder.dart b/packages/mixin_markdown_widget/lib/src/render/builder/markdown_inline_builder.dart index 096a4794..088b1653 100644 --- a/packages/mixin_markdown_widget/lib/src/render/builder/markdown_inline_builder.dart +++ b/packages/mixin_markdown_widget/lib/src/render/builder/markdown_inline_builder.dart @@ -19,18 +19,21 @@ class MarkdownInlineBuilder { final MarkdownTapLinkCallback? onTapLink; List buildPretextRuns( - TextStyle baseStyle, - List inlines, - ) { + TextStyle baseStyle, List inlines, + {bool alignInlineMathToBaseline = true}) { return [ - for (final inline in inlines) ..._buildPretextRun(baseStyle, inline), + for (final inline in inlines) + ..._buildPretextRun( + baseStyle, + inline, + alignInlineMathToBaseline: alignInlineMathToBaseline, + ), ]; } List _buildPretextRun( - TextStyle baseStyle, - InlineNode inline, - ) { + TextStyle baseStyle, InlineNode inline, + {required bool alignInlineMathToBaseline}) { switch (inline.kind) { case MarkdownInlineKind.text: return [ @@ -42,27 +45,48 @@ class MarkdownInlineBuilder { case MarkdownInlineKind.emphasis: final emphasis = inline as EmphasisInline; final style = baseStyle.copyWith(fontStyle: FontStyle.italic); - return buildPretextRuns(style, emphasis.children); + return buildPretextRuns( + style, + emphasis.children, + alignInlineMathToBaseline: alignInlineMathToBaseline, + ); case MarkdownInlineKind.strong: final strong = inline as StrongInline; final style = baseStyle.copyWith(fontWeight: FontWeight.w700); - return buildPretextRuns(style, strong.children); + return buildPretextRuns( + style, + strong.children, + alignInlineMathToBaseline: alignInlineMathToBaseline, + ); case MarkdownInlineKind.strikethrough: final strike = inline as StrikethroughInline; final style = baseStyle.copyWith(decoration: TextDecoration.lineThrough); - return buildPretextRuns(style, strike.children); + return buildPretextRuns( + style, + strike.children, + alignInlineMathToBaseline: alignInlineMathToBaseline, + ); case MarkdownInlineKind.highlight: final highlight = inline as HighlightInline; - return buildPretextRuns(highlightStyle(baseStyle), highlight.children); + return buildPretextRuns( + highlightStyle(baseStyle), + highlight.children, + alignInlineMathToBaseline: alignInlineMathToBaseline, + ); case MarkdownInlineKind.subscript: final subscript = inline as SubscriptInline; - return buildPretextRuns(subscriptStyle(baseStyle), subscript.children); + return buildPretextRuns( + subscriptStyle(baseStyle), + subscript.children, + alignInlineMathToBaseline: alignInlineMathToBaseline, + ); case MarkdownInlineKind.superscript: final superscript = inline as SuperscriptInline; return buildPretextRuns( superscriptStyle(baseStyle), superscript.children, + alignInlineMathToBaseline: alignInlineMathToBaseline, ); case MarkdownInlineKind.link: final link = inline as LinkInline; @@ -73,7 +97,11 @@ class MarkdownInlineBuilder { onTapLink!(link.destination, link.title, label); }); final style = baseStyle.merge(theme.linkStyle); - return buildPretextRuns(style, link.children) + return buildPretextRuns( + style, + link.children, + alignInlineMathToBaseline: alignInlineMathToBaseline, + ) .map( (run) => MarkdownPretextInlineRun( text: run.text, @@ -96,7 +124,11 @@ class MarkdownInlineBuilder { MarkdownPretextInlineRun( text: math.tex, style: baseStyle.merge(theme.inlineCodeStyle), - renderSpan: _buildMathSpan(baseStyle, math), + renderSpan: _buildMathSpan( + baseStyle, + math, + alignToBaseline: alignInlineMathToBaseline, + ), estimatedWidth: _estimateMathWidth(baseStyle, math), estimatedLineHeight: _estimateMathLineHeight(baseStyle, math), ), @@ -232,7 +264,7 @@ class MarkdownInlineBuilder { case MarkdownInlineKind.math: final math = inline as MathInline; return [ - _buildMathSpan(baseStyle, math), + _buildMathSpan(baseStyle, math, alignToBaseline: true), ]; case MarkdownInlineKind.inlineCode: final code = inline as InlineCode; @@ -301,7 +333,11 @@ class MarkdownInlineBuilder { ); } - WidgetSpan _buildMathSpan(TextStyle baseStyle, MathInline math) { + WidgetSpan _buildMathSpan( + TextStyle baseStyle, + MathInline math, { + required bool alignToBaseline, + }) { final child = Padding( padding: EdgeInsets.symmetric( horizontal: math.displayStyle ? 0 : 2, @@ -317,11 +353,12 @@ class MarkdownInlineBuilder { ), ), ); + final useBaselineAlignment = !math.displayStyle && alignToBaseline; return WidgetSpan( - alignment: math.displayStyle - ? PlaceholderAlignment.middle - : PlaceholderAlignment.baseline, - baseline: math.displayStyle ? null : TextBaseline.alphabetic, + alignment: useBaselineAlignment + ? PlaceholderAlignment.baseline + : PlaceholderAlignment.middle, + baseline: useBaselineAlignment ? TextBaseline.alphabetic : null, child: child, ); } diff --git a/packages/mixin_markdown_widget/lib/src/render/code_syntax_highlighter.dart b/packages/mixin_markdown_widget/lib/src/render/code_syntax_highlighter.dart index 3091b168..79ef56ef 100644 --- a/packages/mixin_markdown_widget/lib/src/render/code_syntax_highlighter.dart +++ b/packages/mixin_markdown_widget/lib/src/render/code_syntax_highlighter.dart @@ -1,4 +1,8 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:re_highlight/languages/all.dart'; import 'package:re_highlight/re_highlight.dart'; @@ -8,44 +12,98 @@ import '../widgets/markdown_theme.dart'; class MarkdownCodeSyntaxHighlighter { const MarkdownCodeSyntaxHighlighter(); + static const int _autoDetectMaxChars = 4000; + static const int _backgroundIsolateMinLines = 80; + static const int _backgroundIsolateMinChars = 6000; + static final Highlight _highlight = Highlight() ..registerLanguages(builtinAllLanguages); - TextSpan buildTextSpan({ + MarkdownCodeHighlightPresentation buildPlainTextPresentation({ required String source, required TextStyle baseStyle, + }) { + final span = TextSpan(style: baseStyle, text: source); + return MarkdownCodeHighlightPresentation( + span: span, + runs: [ + MarkdownPretextInlineRun(text: source, style: baseStyle), + ], + isHighlighted: false, + ); + } + + bool shouldDegradeHighlight({ + required String source, required MarkdownThemeData theme, - String? language, }) { - if (source.isEmpty) { - return TextSpan(style: baseStyle, text: ''); + final maxLines = theme.codeHighlightMaxLines; + if (maxLines == null || maxLines <= 0) { + return false; } + return lineCountOf(source) > maxLines; + } - String? targetLanguage = language?.trim().toLowerCase(); - if (targetLanguage != null && targetLanguage.isEmpty) { - targetLanguage = null; + Future buildPresentationAsync({ + required String source, + required TextStyle baseStyle, + required MarkdownThemeData theme, + String? language, + }) async { + if (source.isEmpty || + shouldDegradeHighlight(source: source, theme: theme)) { + return buildPlainTextPresentation(source: source, baseStyle: baseStyle); } - HighlightResult result; - try { - if (targetLanguage != null && - _highlight.getLanguage(targetLanguage) != null) { - result = _highlight.highlight(code: source, language: targetLanguage); - } else { - if (source.length <= 4000) { - result = _highlight.highlightAuto(source); - } else { - result = _highlight.justTextHighlightResult(source); - } - } - } catch (_) { - result = _highlight.justTextHighlightResult(source); - } + final normalizedLanguage = _normalizeLanguage(language); + final segments = await _highlightSegmentsAsync( + source: source, + language: normalizedLanguage, + ); + return _presentationFromSegments( + segments, + source: source, + baseStyle: baseStyle, + theme: theme, + isHighlighted: segments.isNotEmpty, + ); + } - final renderer = _MarkdownHighlightRenderer( - baseStyle: baseStyle, theme: theme, highlighter: this); - result.render(renderer); - return renderer.span ?? TextSpan(style: baseStyle, text: source); + TextSpan buildTextSpan({ + required String source, + required TextStyle baseStyle, + required MarkdownThemeData theme, + String? language, + }) { + return buildPresentation( + source: source, + baseStyle: baseStyle, + theme: theme, + language: language, + ).span; + } + + MarkdownCodeHighlightPresentation buildPresentation({ + required String source, + required TextStyle baseStyle, + required MarkdownThemeData theme, + String? language, + }) { + if (source.isEmpty || + shouldDegradeHighlight(source: source, theme: theme)) { + return buildPlainTextPresentation(source: source, baseStyle: baseStyle); + } + final segments = _highlightSegmentsSync( + source: source, + language: _normalizeLanguage(language), + ); + return _presentationFromSegments( + segments, + source: source, + baseStyle: baseStyle, + theme: theme, + isHighlighted: segments.isNotEmpty, + ); } List buildPretextRuns({ @@ -54,61 +112,155 @@ class MarkdownCodeSyntaxHighlighter { required MarkdownThemeData theme, String? language, }) { - final span = buildTextSpan( + return buildPresentation( source: source, baseStyle: baseStyle, theme: theme, language: language, + ).runs; + } + + int lineCountOf(String source) { + if (source.isEmpty) { + return 0; + } + return '\n'.allMatches(source).length + 1; + } + + Future> _highlightSegmentsAsync({ + required String source, + required String? language, + }) async { + if (_shouldUseBackgroundIsolate(source: source, language: language)) { + final response = + await compute, List>>( + _highlightSegmentsInBackground, + { + 'source': source, + 'language': language, + }, + ); + return response + .map(_MarkdownHighlightSegment.fromMessage) + .toList(growable: false); + } + return Future>.value( + _highlightSegmentsSync(source: source, language: language), ); - final runs = []; - _collectPretextRuns( - span, - inheritedStyle: baseStyle, - runs: runs, + } + + bool _shouldUseBackgroundIsolate({ + required String source, + required String? language, + }) { + if (kIsWeb) { + return false; + } + if (source.length < _backgroundIsolateMinChars && + lineCountOf(source) < _backgroundIsolateMinLines) { + return false; + } + return language != null || source.length <= _autoDetectMaxChars; + } + + List<_MarkdownHighlightSegment> _highlightSegmentsSync({ + required String source, + required String? language, + }) { + if (source.isEmpty) { + return const <_MarkdownHighlightSegment>[]; + } + final result = _runHighlight( + _highlight, + source: source, + language: language, ); - return runs.isEmpty - ? [ - MarkdownPretextInlineRun(text: source, style: baseStyle), - ] - : runs; + final renderer = _MarkdownHighlightSegmentRenderer(); + result.render(renderer); + return renderer.segments; + } + + String? _normalizeLanguage(String? language) { + final normalized = language?.trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) { + return null; + } + return normalized; } - void _collectPretextRuns( - InlineSpan span, { - required TextStyle inheritedStyle, - required List runs, + HighlightResult _runHighlight( + Highlight highlighter, { + required String source, + required String? language, }) { - if (span is! TextSpan) { - return; + try { + if (language != null && highlighter.getLanguage(language) != null) { + return highlighter.highlight(code: source, language: language); + } + if (source.length <= _autoDetectMaxChars) { + return highlighter.highlightAuto(source); + } + } catch (_) { + return highlighter.justTextHighlightResult(source); } + return highlighter.justTextHighlightResult(source); + } - final effectiveStyle = inheritedStyle.merge(span.style); - final text = span.text; - if (text != null && text.isNotEmpty) { - if (runs.isNotEmpty && - runs.last.renderSpan == null && - runs.last.decoration == null && - runs.last.mouseCursor == null && - runs.last.recognizer == null && - runs.last.style == effectiveStyle) { + MarkdownCodeHighlightPresentation _presentationFromSegments( + List<_MarkdownHighlightSegment> segments, { + required String source, + required TextStyle baseStyle, + required MarkdownThemeData theme, + required bool isHighlighted, + }) { + if (segments.isEmpty) { + return buildPlainTextPresentation(source: source, baseStyle: baseStyle); + } + + final runs = []; + final children = []; + + for (final segment in segments) { + if (segment.text.isEmpty) { + continue; + } + var effectiveStyle = baseStyle; + for (final scope in segment.scopes) { + effectiveStyle = effectiveStyle + .merge(_styleFor(scope, baseStyle: effectiveStyle, theme: theme)); + } + if (runs.isNotEmpty && runs.last.style == effectiveStyle) { final last = runs.removeLast(); - runs.add(last.copyWithText(last.text + text)); + runs.add(last.copyWithText(last.text + segment.text)); } else { - runs.add(MarkdownPretextInlineRun(text: text, style: effectiveStyle)); + runs.add(MarkdownPretextInlineRun( + text: segment.text, style: effectiveStyle)); } - } - final children = span.children; - if (children == null) { - return; + if (children.isNotEmpty) { + final last = children.last; + if (last is TextSpan && + last.style == effectiveStyle && + last.children == null) { + children[children.length - 1] = TextSpan( + text: (last.text ?? '') + segment.text, + style: effectiveStyle, + ); + continue; + } + } + children.add(TextSpan(text: segment.text, style: effectiveStyle)); } - for (final child in children) { - _collectPretextRuns( - child, - inheritedStyle: effectiveStyle, - runs: runs, - ); + + if (runs.isEmpty || children.isEmpty) { + return buildPlainTextPresentation(source: source, baseStyle: baseStyle); } + + return MarkdownCodeHighlightPresentation( + span: TextSpan(style: baseStyle, children: children), + runs: runs, + isHighlighted: isHighlighted, + ); } TextStyle _styleFor( @@ -193,60 +345,283 @@ class MarkdownCodeSyntaxHighlighter { } } -class _RendererNode { - final String? scope; - final TextStyle style; - final List children = []; +class MarkdownCodeHighlightPresentation { + const MarkdownCodeHighlightPresentation({ + required this.span, + required this.runs, + required this.isHighlighted, + }); - _RendererNode({this.scope, required this.style}); + final TextSpan span; + final List runs; + final bool isHighlighted; } -class _MarkdownHighlightRenderer implements HighlightRenderer { - final TextStyle baseStyle; - final MarkdownThemeData theme; - final MarkdownCodeSyntaxHighlighter highlighter; +class MarkdownCodeHighlightCache extends ChangeNotifier { + MarkdownCodeHighlightCache({ + required MarkdownCodeSyntaxHighlighter highlighter, + }) : _highlighter = highlighter; - final List<_RendererNode> _stack = []; - final List _results = []; + final MarkdownCodeSyntaxHighlighter _highlighter; + final Map _entries = + {}; + int _nextRequestId = 0; + bool _isDisposed = false; + + MarkdownCodeHighlightPresentation resolve({ + required String blockId, + required String source, + required TextStyle baseStyle, + required MarkdownThemeData theme, + String? language, + }) { + final signature = Object.hash(source, language); + final existing = _entries[blockId]; + if (existing != null && existing.signature == signature) { + return existing.presentation; + } - _MarkdownHighlightRenderer({ - required this.baseStyle, - required this.theme, - required this.highlighter, + final plainPresentation = _highlighter.buildPlainTextPresentation( + source: source, + baseStyle: baseStyle, + ); + if (_isDisposed) { + return plainPresentation; + } + + final requestId = ++_nextRequestId; + if (_highlighter.shouldDegradeHighlight(source: source, theme: theme)) { + _entries[blockId] = _MarkdownCodeHighlightCacheEntry( + signature: signature, + requestId: requestId, + presentation: plainPresentation, + isPending: false, + ); + return plainPresentation; + } + + _entries[blockId] = _MarkdownCodeHighlightCacheEntry( + signature: signature, + requestId: requestId, + presentation: plainPresentation, + isPending: true, + ); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (_isDisposed) { + return; + } + unawaited( + _startHighlight( + blockId: blockId, + requestId: requestId, + signature: signature, + source: source, + baseStyle: baseStyle, + theme: theme, + language: language, + ), + ); + }); + return plainPresentation; + } + + String cacheSignatureFor(String blockId) { + final entry = _entries[blockId]; + if (entry == null) { + return 'highlight:unresolved'; + } + final state = entry.presentation.isHighlighted ? 'ready' : 'plain'; + final pending = entry.isPending ? 'pending' : 'stable'; + return 'highlight:$state:$pending:${entry.signature}'; + } + + Future _startHighlight({ + required String blockId, + required int requestId, + required int signature, + required String source, + required TextStyle baseStyle, + required MarkdownThemeData theme, + required String? language, + }) async { + if (_isDisposed) { + return; + } + final current = _entries[blockId]; + if (current == null || + current.requestId != requestId || + current.signature != signature || + !current.isPending) { + return; + } + + final presentation = await _highlighter.buildPresentationAsync( + source: source, + baseStyle: baseStyle, + theme: theme, + language: language, + ); + if (_isDisposed) { + return; + } + final latest = _entries[blockId]; + if (latest == null || + latest.requestId != requestId || + latest.signature != signature) { + return; + } + + _entries[blockId] = _MarkdownCodeHighlightCacheEntry( + signature: signature, + requestId: requestId, + presentation: presentation, + isPending: false, + ); + if (!_isDisposed) { + notifyListeners(); + } + } + + void clear() { + if (_entries.isEmpty) { + return; + } + _entries.clear(); + } + + void cleanup(Set validIds) { + _entries.removeWhere((key, _) => !validIds.contains(key)); + } + + @override + void dispose() { + _isDisposed = true; + _entries.clear(); + super.dispose(); + } +} + +class _MarkdownCodeHighlightCacheEntry { + const _MarkdownCodeHighlightCacheEntry({ + required this.signature, + required this.requestId, + required this.presentation, + required this.isPending, }); + final int signature; + final int requestId; + final MarkdownCodeHighlightPresentation presentation; + final bool isPending; +} + +@immutable +class _MarkdownHighlightSegment { + const _MarkdownHighlightSegment({ + required this.text, + required this.scopes, + }); + + factory _MarkdownHighlightSegment.fromMessage(Map message) { + final scopes = (message['scopes'] as List) + .map((scope) => scope! as String) + .toList(growable: false); + return _MarkdownHighlightSegment( + text: message['text']! as String, + scopes: scopes, + ); + } + + final String text; + final List scopes; + + Map toMessage() => { + 'text': text, + 'scopes': scopes, + }; +} + +class _MarkdownHighlightSegmentRenderer implements HighlightRenderer { + final List _scopeStack = []; + final List<_MarkdownHighlightSegment> _segments = + <_MarkdownHighlightSegment>[]; + + List<_MarkdownHighlightSegment> get segments => + List<_MarkdownHighlightSegment>.unmodifiable(_segments); + @override void addText(String text) { - if (_stack.isEmpty) { - _results.add(TextSpan(text: text, style: baseStyle)); - } else { - final top = _stack.last; - top.children.add(TextSpan(text: text, style: top.style)); + if (text.isEmpty) { + return; + } + if (_segments.isNotEmpty && + listEquals(_segments.last.scopes, _scopeStack)) { + final previous = _segments.removeLast(); + _segments.add( + _MarkdownHighlightSegment( + text: previous.text + text, + scopes: previous.scopes, + ), + ); + return; } + _segments.add( + _MarkdownHighlightSegment( + text: text, + scopes: List.unmodifiable(_scopeStack), + ), + ); } @override void openNode(DataNode node) { - final parentStyle = _stack.isEmpty ? baseStyle : _stack.last.style; - final style = - highlighter._styleFor(node.scope, baseStyle: parentStyle, theme: theme); - _stack.add(_RendererNode(scope: node.scope, style: style)); + final scope = node.scope; + if (scope != null && scope.isNotEmpty) { + _scopeStack.add(scope); + } } @override void closeNode(DataNode node) { - final top = _stack.removeLast(); - final span = TextSpan( - style: top.style, children: top.children.isEmpty ? null : top.children); - if (_stack.isEmpty) { - _results.add(span); - } else { - _stack.last.children.add(span); + final scope = node.scope; + if (scope != null && scope.isNotEmpty && _scopeStack.isNotEmpty) { + _scopeStack.removeLast(); } } +} + +List> _highlightSegmentsInBackground( + Map request, +) { + final highlighter = Highlight()..registerLanguages(builtinAllLanguages); + final source = request['source']! as String; + final language = request['language'] as String?; + final result = _runHighlightInBackground( + highlighter, + source: source, + language: language, + ); + final renderer = _MarkdownHighlightSegmentRenderer(); + result.render(renderer); + return renderer.segments + .map((segment) => segment.toMessage()) + .toList(growable: false); +} - TextSpan? get span { - if (_results.isEmpty) return null; - return TextSpan(style: baseStyle, children: _results); +HighlightResult _runHighlightInBackground( + Highlight highlighter, { + required String source, + required String? language, +}) { + try { + if (language != null && highlighter.getLanguage(language) != null) { + return highlighter.highlight(code: source, language: language); + } + if (source.length <= MarkdownCodeSyntaxHighlighter._autoDetectMaxChars) { + return highlighter.highlightAuto(source); + } + } catch (_) { + return highlighter.justTextHighlightResult(source); } + return highlighter.justTextHighlightResult(source); } diff --git a/packages/mixin_markdown_widget/lib/src/render/markdown_block_widgets.dart b/packages/mixin_markdown_widget/lib/src/render/markdown_block_widgets.dart index bafb0231..e86aa411 100644 --- a/packages/mixin_markdown_widget/lib/src/render/markdown_block_widgets.dart +++ b/packages/mixin_markdown_widget/lib/src/render/markdown_block_widgets.dart @@ -36,6 +36,7 @@ typedef MarkdownInlineTextWidgetBuilder = Widget Function( List inlines, TextAlign textAlign, { GlobalKey? directTextKey, + bool alignInlineMathToBaseline, }); typedef MarkdownTableWidgetBuilder = Widget Function( @@ -43,6 +44,117 @@ typedef MarkdownTableWidgetBuilder = Widget Function( TableColumnWidth defaultColumnWidth, ); +enum MarkdownTableColumnSizing { + fixed, + flex, +} + +class MarkdownTableLayoutPlanCache { + final Map _plans = + {}; + + MarkdownTableLayoutPlan planFor(TableBlock block) { + final cached = _plans[block.id]; + if (cached != null && identical(cached.block, block)) { + return cached.plan; + } + final plan = MarkdownTableLayoutPlan.fromBlock(block); + _plans[block.id] = _CachedMarkdownTableLayoutPlan( + block: block, + plan: plan, + ); + return plan; + } + + void cleanup(Set validIds) { + _plans.removeWhere((key, _) => !validIds.contains(key)); + } +} + +class _CachedMarkdownTableLayoutPlan { + const _CachedMarkdownTableLayoutPlan({ + required this.block, + required this.plan, + }); + + final TableBlock block; + final MarkdownTableLayoutPlan plan; +} + +class MarkdownTableLayoutPlan { + const MarkdownTableLayoutPlan({ + required this.columnCount, + required this.columnSizing, + required this.fixedWidths, + required this.flexFactors, + required this.minimumWidth, + }); + + factory MarkdownTableLayoutPlan.fromBlock(TableBlock block) { + final columnCount = block.rows.fold( + 0, + (maxCount, row) => + row.cells.length > maxCount ? row.cells.length : maxCount, + ); + if (columnCount == 0) { + return const MarkdownTableLayoutPlan( + columnCount: 0, + columnSizing: [], + fixedWidths: [], + flexFactors: [], + minimumWidth: 0, + ); + } + + final colMaxChars = List.filled(columnCount, 0); + for (final row in block.rows) { + for (var index = 0; + index < row.cells.length && index < columnCount; + index++) { + final textLen = _estimateInlineTextLength(row.cells[index].inlines); + if (textLen > colMaxChars[index]) { + colMaxChars[index] = textLen; + } + } + } + + final columnSizing = []; + final fixedWidths = []; + final flexFactors = []; + var minimumWidth = 0.0; + + for (final charCount in colMaxChars) { + if (charCount > 30) { + columnSizing.add(MarkdownTableColumnSizing.flex); + fixedWidths.add(0); + final factor = math.max(1, charCount).toDouble(); + flexFactors.add(factor); + minimumWidth += 160.0; + } else { + columnSizing.add(MarkdownTableColumnSizing.fixed); + final width = math.min(math.max(charCount * 10.0 + 24.0, 72.0), 160.0); + fixedWidths.add(width); + flexFactors.add(0); + minimumWidth += width; + } + } + + return MarkdownTableLayoutPlan( + columnCount: columnCount, + columnSizing: columnSizing, + fixedWidths: fixedWidths, + flexFactors: flexFactors, + minimumWidth: minimumWidth, + ); + } + + final int columnCount; + final List columnSizing; + final List fixedWidths; + final List flexFactors; + final double minimumWidth; +} + int _estimateInlineTextLength(List inlines) { int length = 0; for (final inline in inlines) { @@ -77,67 +189,46 @@ class MarkdownAdaptiveTableLayout extends StatelessWidget { const MarkdownAdaptiveTableLayout({ super.key, required this.block, + required this.layoutPlan, required this.tableBuilder, + this.scrollController, }); final TableBlock block; + final MarkdownTableLayoutPlan layoutPlan; final MarkdownTableWidgetBuilder tableBuilder; + final ScrollController? scrollController; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final availableWidth = math.max(constraints.maxWidth, 0.0); - - final columnCount = block.rows.fold( - 0, - (maxCount, row) => - row.cells.length > maxCount ? row.cells.length : maxCount, - ); + final columnCount = layoutPlan.columnCount; if (columnCount == 0) { return const SizedBox.shrink(); } - final colMaxChars = List.filled(columnCount, 0); - for (final row in block.rows) { - for (var i = 0; i < row.cells.length && i < columnCount; i++) { - final textLen = _estimateInlineTextLength(row.cells[i].inlines); - if (textLen > colMaxChars[i]) { - colMaxChars[i] = textLen; - } - } - } - final Map customWidths = {}; - double estimatedMins = 0; - bool hasFlex = false; - - for (var i = 0; i < columnCount; i++) { - if (colMaxChars[i] > 30) { - customWidths[i] = FlexColumnWidth(colMaxChars[i].toDouble()); - estimatedMins += - 80.0; // flex columns are given a generous minimum reasonable width before giving up and scrolling - hasFlex = true; - } else { - customWidths[i] = const IntrinsicColumnWidth(); - // Estimate minimum required space for intrinsic columns to avoid horizontal squashing before scroll triggers - estimatedMins += math.min(colMaxChars[i] * 10.0 + 24.0, 100.0); + for (var index = 0; index < columnCount; index++) { + switch (layoutPlan.columnSizing[index]) { + case MarkdownTableColumnSizing.fixed: + customWidths[index] = FixedColumnWidth( + layoutPlan.fixedWidths[index], + ); + case MarkdownTableColumnSizing.flex: + customWidths[index] = FlexColumnWidth( + layoutPlan.flexFactors[index], + ); } } - if (!hasFlex) { - final table = tableBuilder(null, const IntrinsicColumnWidth()); - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: table, - ); - } - - final idealWidth = math.max(availableWidth, estimatedMins); + final idealWidth = math.max(availableWidth, layoutPlan.minimumWidth); final table = tableBuilder(customWidths, const FlexColumnWidth()); return SingleChildScrollView( + controller: scrollController, scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: BoxConstraints( @@ -427,16 +518,20 @@ class MarkdownTableBlockView extends StatelessWidget { super.key, required this.theme, required this.block, + required this.layoutPlan, required this.textWidgetBuilder, this.cellKeyBuilder, this.cellTextKeyBuilder, + this.scrollController, }); final MarkdownThemeData theme; final TableBlock block; + final MarkdownTableLayoutPlan layoutPlan; final MarkdownInlineTextWidgetBuilder textWidgetBuilder; final Key? Function(int rowIndex, int columnIndex)? cellKeyBuilder; final GlobalKey? Function(int rowIndex, int columnIndex)? cellTextKeyBuilder; + final ScrollController? scrollController; @override Widget build(BuildContext context) { @@ -453,6 +548,8 @@ class MarkdownTableBlockView extends StatelessWidget { theme: theme, child: MarkdownAdaptiveTableLayout( block: block, + layoutPlan: layoutPlan, + scrollController: scrollController, tableBuilder: (columnWidths, defaultColumnWidth) => Table( columnWidths: columnWidths, defaultColumnWidth: defaultColumnWidth, @@ -510,6 +607,7 @@ class MarkdownTableBlockView extends StatelessWidget { cell.inlines, _textAlignFor(alignment), directTextKey: directTextKey, + alignInlineMathToBaseline: false, ), ), ); diff --git a/packages/mixin_markdown_widget/lib/src/render/markdown_document_view.dart b/packages/mixin_markdown_widget/lib/src/render/markdown_document_view.dart index 923d35c9..d9f76bea 100644 --- a/packages/mixin_markdown_widget/lib/src/render/markdown_document_view.dart +++ b/packages/mixin_markdown_widget/lib/src/render/markdown_document_view.dart @@ -7,6 +7,7 @@ import '../selection/selection_controller.dart'; import '../widgets/markdown_theme.dart'; import '../widgets/markdown_types.dart'; import 'code_syntax_highlighter.dart'; +import 'markdown_block_widgets.dart'; import 'builder/markdown_block_builder.dart'; import 'builder/markdown_inline_builder.dart'; @@ -58,6 +59,8 @@ class MarkdownDocumentView extends StatefulWidget { } class _MarkdownDocumentViewState extends State { + static const double _slowBuildLogThresholdMs = 8; + final List _recognizers = []; final ScrollController _fallbackScrollController = ScrollController(); final FocusNode _selectionFocusNode = @@ -69,8 +72,12 @@ class _MarkdownDocumentViewState extends State { const MarkdownPlainTextSerializer(); final MarkdownCodeSyntaxHighlighter _codeSyntaxHighlighter = const MarkdownCodeSyntaxHighlighter(); + late final MarkdownCodeHighlightCache _codeHighlightCache; final Map _cachedBlockRows = {}; + final MarkdownDescriptorCache _descriptorCache = MarkdownDescriptorCache(); + final MarkdownTableLayoutPlanCache _tableLayoutPlanCache = + MarkdownTableLayoutPlanCache(); late final MarkdownBlockKeysRegistry _keysRegistry; late MarkdownBlockBuilder _blockBuilder; @@ -82,6 +89,9 @@ class _MarkdownDocumentViewState extends State { void initState() { super.initState(); _keysRegistry = MarkdownBlockKeysRegistry(); + _codeHighlightCache = MarkdownCodeHighlightCache( + highlighter: _codeSyntaxHighlighter, + )..addListener(_handleCodeHighlightCacheChanged); } Set _collectBlockIds(List blocks) { @@ -134,24 +144,38 @@ class _MarkdownDocumentViewState extends State { final validIds = _collectBlockIds(widget.document.blocks); _keysRegistry.cleanupKeys(validIds); _cachedBlockRows.removeWhere((key, _) => !validIds.contains(key)); + _descriptorCache.cleanup(validIds); + _tableLayoutPlanCache.cleanup(validIds); + _codeHighlightCache.cleanup(validIds); if (oldWidget.theme != widget.theme || oldWidget.onTapLink != widget.onTapLink || oldWidget.imageBuilder != widget.imageBuilder || oldWidget.selectable != widget.selectable) { _cachedBlockRows.clear(); + _codeHighlightCache.clear(); } } @override void dispose() { _disposeRecognizers(); + _codeHighlightCache + ..removeListener(_handleCodeHighlightCacheChanged) + ..dispose(); _fallbackScrollController.dispose(); _selectionFocusNode.dispose(); _contextMenuController.remove(); super.dispose(); } + void _handleCodeHighlightCacheChanged() { + if (!mounted) { + return; + } + setState(() {}); + } + void _disposeRecognizers() { for (final recognizer in _recognizers) { recognizer.dispose(); @@ -287,8 +311,28 @@ class _MarkdownDocumentViewState extends State { return _keysRegistry.blockKeys[block.id]?.currentState as dynamic; } + Widget _finishBuildWithLog( + Stopwatch stopwatch, + Widget child, { + required int blockCount, + }) { + stopwatch.stop(); + final elapsedMs = stopwatch.elapsedMicroseconds / 1000; + if (elapsedMs >= _slowBuildLogThresholdMs) { + debugPrint( + '[mixin_markdown_widget] view.build ' + 'blocks=$blockCount ' + 'selectable=${widget.selectable} ' + 'useColumn=${widget.useColumn} ' + 'elapsed=${elapsedMs.toStringAsFixed(3)}ms', + ); + } + return child; + } + @override Widget build(BuildContext context) { + final buildStopwatch = Stopwatch()..start(); _disposeRecognizers(); final inlineBuilder = MarkdownInlineBuilder( @@ -302,6 +346,7 @@ class _MarkdownDocumentViewState extends State { plainTextSerializer: _plainTextSerializer, inlineBuilder: inlineBuilder, codeSyntaxHighlighter: _codeSyntaxHighlighter, + cache: _descriptorCache, ); final selectionResolver = MarkdownSelectionResolver( @@ -328,6 +373,8 @@ class _MarkdownDocumentViewState extends State { onTapLink: widget.onTapLink, onRequestContextMenu: _showToolbar, cachedBlockRows: _cachedBlockRows, + tableLayoutPlanCache: _tableLayoutPlanCache, + codeHighlightCache: _codeHighlightCache, ); final selectionRange = widget.selectionController?.normalizedRange; @@ -377,7 +424,11 @@ class _MarkdownDocumentViewState extends State { } if (!widget.selectable || widget.selectionController == null) { - return scrollable; + return _finishBuildWithLog( + buildStopwatch, + scrollable, + blockCount: widget.document.blocks.length, + ); } final gestureDetectorWrap = MarkdownSelectionGestureDetector( @@ -408,19 +459,23 @@ class _MarkdownDocumentViewState extends State { ), ); - return MarkdownShortcutsScope( - selectionController: widget.selectionController!, - document: widget.document, - onCopyPlainText: widget.onCopyPlainText, - child: Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) { - if (!_selectionFocusNode.hasFocus) { - _selectionFocusNode.requestFocus(); - } - }, - child: tapRegion, + return _finishBuildWithLog( + buildStopwatch, + MarkdownShortcutsScope( + selectionController: widget.selectionController!, + document: widget.document, + onCopyPlainText: widget.onCopyPlainText, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) { + if (!_selectionFocusNode.hasFocus) { + _selectionFocusNode.requestFocus(); + } + }, + child: tapRegion, + ), ), + blockCount: widget.document.blocks.length, ); } } diff --git a/packages/mixin_markdown_widget/lib/src/render/pretext_text_block.dart b/packages/mixin_markdown_widget/lib/src/render/pretext_text_block.dart index bbdd7504..22117e6e 100644 --- a/packages/mixin_markdown_widget/lib/src/render/pretext_text_block.dart +++ b/packages/mixin_markdown_widget/lib/src/render/pretext_text_block.dart @@ -1,5 +1,6 @@ // ignore_for_file: implementation_imports +import 'dart:collection'; import 'dart:math' as math; import 'package:flutter/gestures.dart'; @@ -196,6 +197,95 @@ class MarkdownPretextTextBlock extends StatelessWidget { } } +final LinkedHashMap<_MarkdownPretextLayoutCacheKey, MarkdownPretextLayoutResult> + _markdownPretextLayoutCache = LinkedHashMap<_MarkdownPretextLayoutCacheKey, + MarkdownPretextLayoutResult>(); + +const int _markdownPretextLayoutCacheLimit = 192; + +@immutable +class _MarkdownPretextLayoutCacheKey { + const _MarkdownPretextLayoutCacheKey({ + required this.runsSignature, + required this.fallbackStyleHash, + required this.maxWidth, + required this.textScaleFactor, + required this.textAlign, + required this.textDirection, + }); + + final int runsSignature; + final int fallbackStyleHash; + final int maxWidth; + final int textScaleFactor; + final TextAlign textAlign; + final TextDirection textDirection; + + @override + bool operator ==(Object other) { + return other is _MarkdownPretextLayoutCacheKey && + other.runsSignature == runsSignature && + other.fallbackStyleHash == fallbackStyleHash && + other.maxWidth == maxWidth && + other.textScaleFactor == textScaleFactor && + other.textAlign == textAlign && + other.textDirection == textDirection; + } + + @override + int get hashCode => Object.hash( + runsSignature, + fallbackStyleHash, + maxWidth, + textScaleFactor, + textAlign, + textDirection, + ); +} + +int _markdownPretextRunsSignature(List runs) { + return Object.hashAll( + runs.map((run) { + final decoration = run.decoration; + return Object.hash( + run.text, + run.style.hashCode, + run.mouseCursor?.hashCode, + run.allowCharacterWrap, + decoration?.backgroundColor, + decoration?.borderRadius, + decoration?.padding, + run.renderSpan?.runtimeType, + run.estimatedWidth, + run.estimatedLineHeight, + ); + }), + ); +} + +MarkdownPretextLayoutResult? _getCachedMarkdownPretextLayout( + _MarkdownPretextLayoutCacheKey key, +) { + final cached = _markdownPretextLayoutCache.remove(key); + if (cached != null) { + _markdownPretextLayoutCache[key] = cached; + } + return cached; +} + +void _storeCachedMarkdownPretextLayout( + _MarkdownPretextLayoutCacheKey key, + MarkdownPretextLayoutResult result, +) { + _markdownPretextLayoutCache[key] = result; + while ( + _markdownPretextLayoutCache.length > _markdownPretextLayoutCacheLimit) { + _markdownPretextLayoutCache.remove( + _markdownPretextLayoutCache.keys.first, + ); + } +} + bool _requiresDirectTextRichRendering(List runs) { return runs.any((run) => run.renderSpan != null); } @@ -352,6 +442,19 @@ MarkdownPretextLayoutResult computeMarkdownPretextLayoutFromRuns({ TextAlign textAlign = TextAlign.start, TextDirection textDirection = TextDirection.ltr, }) { + final cacheKey = _MarkdownPretextLayoutCacheKey( + runsSignature: _markdownPretextRunsSignature(runs), + fallbackStyleHash: fallbackStyle.hashCode, + maxWidth: (maxWidth * 100).round(), + textScaleFactor: (textScaleFactor * 1000).round(), + textAlign: textAlign, + textDirection: textDirection, + ); + final cached = _getCachedMarkdownPretextLayout(cacheKey); + if (cached != null) { + return cached; + } + final plainText = runs.map((run) => run.text).join(); final lineHeight = _measureMaxLineHeight( runs.isEmpty @@ -362,22 +465,18 @@ MarkdownPretextLayoutResult computeMarkdownPretextLayoutFromRuns({ textScaleFactor, ); if (runs.isEmpty || plainText.isEmpty) { - return MarkdownPretextLayoutResult( + final emptyResult = MarkdownPretextLayoutResult( plainText: plainText, lines: const [], lineHeight: lineHeight, textScaleFactor: textScaleFactor, ); + _storeCachedMarkdownPretextLayout(cacheKey, emptyResult); + return emptyResult; } final safeMaxWidth = maxWidth.isFinite ? math.max(maxWidth, 0.0) : 100000.0; final alignmentWidth = maxWidth.isFinite ? math.max(maxWidth, 0.0) : 0.0; - final reservedDecoratedWrapPadding = runs - .where((run) => run.decoration != null && run.allowCharacterWrap) - .fold( - 0, - (current, run) => current + run.decoration!.padding.horizontal, - ); final segmentBuilder = _MarkdownPretextSegmentBuilder( textScaleFactor: textScaleFactor, ); @@ -395,7 +494,7 @@ MarkdownPretextLayoutResult computeMarkdownPretextLayoutFromRuns({ ); final result = layoutWithLines( prepared, - math.max(safeMaxWidth - reservedDecoratedWrapPadding, 0), + safeMaxWidth, lineHeight, ); @@ -489,7 +588,81 @@ MarkdownPretextLayoutResult computeMarkdownPretextLayoutFromRuns({ originalCursor = originalEndOffset; } - return MarkdownPretextLayoutResult( + while (cursor < plainText.length) { + final fittedVisibleTextLength = + _fitVisibleTextLengthToRenderedWidthFromOffset( + segments: segments, + startOffset: cursor, + endSegmentIndex: segments.length, + visibleTextLength: plainText.length - cursor, + maxRenderedWidth: safeMaxWidth, + textScaleFactor: textScaleFactor, + ); + final lineFragments = _buildLineFragmentsFromOffset( + segments: segments, + startOffset: cursor, + endSegmentIndex: segments.length, + visibleTextLength: fittedVisibleTextLength, + maxRenderedWidth: safeMaxWidth, + textScaleFactor: textScaleFactor, + ); + if (lineFragments.isEmpty) { + break; + } + final renderedLineText = + lineFragments.map((fragment) => fragment.text).join(); + final startOffset = cursor; + final visibleEndOffset = + _consumeVisibleText(plainText, cursor, renderedLineText); + if (visibleEndOffset <= cursor) { + break; + } + var endOffset = visibleEndOffset; + while (endOffset < plainText.length && plainText[endOffset] == ' ') { + endOffset += 1; + } + if (endOffset < plainText.length && plainText[endOffset] == '\n') { + endOffset += 1; + } + final renderedWidth = math.min( + _measureRenderedFragmentWidth( + lineFragments, + textScaleFactor: textScaleFactor, + ), + safeMaxWidth, + ); + lines.add( + MarkdownPretextLayoutLine( + text: renderedLineText, + span: _buildLineSpanFromFragments( + lineFragments, + fallbackStyle: fallbackStyle, + ), + segments: _buildLineSegmentsFromFragments( + lineFragments, + textScaleFactor: textScaleFactor, + ), + width: renderedWidth, + height: _measureRenderedFragmentLineHeight( + lineFragments, + fallbackStyle: fallbackStyle, + textScaleFactor: textScaleFactor, + ), + leadingOffset: _resolveLineLeadingOffset( + lineWidth: renderedWidth, + maxWidth: alignmentWidth, + textAlign: textAlign, + textDirection: textDirection, + ), + startOffset: startOffset, + endOffset: endOffset, + visibleEndOffset: visibleEndOffset, + ), + ); + cursor = endOffset; + } + + final layoutResult = MarkdownPretextLayoutResult( plainText: plainText, lines: List.unmodifiable(lines), lineHeight: lines.fold( @@ -498,6 +671,8 @@ MarkdownPretextLayoutResult computeMarkdownPretextLayoutFromRuns({ ), textScaleFactor: textScaleFactor, ); + _storeCachedMarkdownPretextLayout(cacheKey, layoutResult); + return layoutResult; } @immutable @@ -1339,11 +1514,12 @@ double _measureRenderedFragmentLineHeight( required TextStyle fallbackStyle, required double textScaleFactor, }) { + final fallbackLineHeight = _measureLineHeight(fallbackStyle, textScaleFactor); if (fragments.isEmpty) { - return _measureLineHeight(fallbackStyle, textScaleFactor); + return fallbackLineHeight; } - var height = 0.0; + var height = fallbackLineHeight; for (final fragment in fragments) { final measured = fragment.renderSpan != null ? fragment.estimatedLineHeight ?? @@ -1787,6 +1963,35 @@ class _MarkdownPretextSegmentBuilder { break; } + final characterLength = _textCharacterLengthAt(text, index); + final codePoint = _textCodePointAt(text, index); + if (_isCjkBreakableCodePoint(codePoint)) { + final displayText = text.substring(index, index + characterLength); + segments.add( + _MarkdownPretextMeasuredSegment( + displayText: displayText, + kind: pretext_segment.SegmentKind.word, + width: _measurer.measure( + displayText, + run.style, + textScaleFactor, + ), + style: run.style, + mouseCursor: run.mouseCursor, + recognizer: run.recognizer, + renderSpan: run.renderSpan, + decoration: run.decoration, + startOffset: plainTextOffset, + endOffset: plainTextOffset + characterLength, + startsDecoratedChunk: false, + endsDecoratedChunk: false, + ), + ); + index += characterLength; + plainTextOffset += characterLength; + continue; + } + final isWhitespace = character.trim().isEmpty; final buffer = StringBuffer(); final startOffset = plainTextOffset; @@ -1795,13 +2000,22 @@ class _MarkdownPretextSegmentBuilder { if (nextCharacter == '\n') { break; } + final nextCharacterLength = _textCharacterLengthAt(text, index); + final nextCodePoint = _textCodePointAt(text, index); final nextIsWhitespace = nextCharacter.trim().isEmpty; + if (_isCjkBreakableCodePoint(nextCodePoint)) { + break; + } if (nextIsWhitespace != isWhitespace) { break; } - buffer.write(nextCharacter == '\t' ? ' ' : nextCharacter); - index += 1; - plainTextOffset += 1; + buffer.write( + nextCharacter == '\t' + ? ' ' + : text.substring(index, index + nextCharacterLength), + ); + index += nextCharacterLength; + plainTextOffset += nextCharacterLength; } final displayText = buffer.toString(); if (displayText.isEmpty) { @@ -1837,6 +2051,48 @@ class _MarkdownPretextSegmentBuilder { } } +int _textCharacterLengthAt(String text, int index) { + final codeUnit = text.codeUnitAt(index); + if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF && index + 1 < text.length) { + final nextCodeUnit = text.codeUnitAt(index + 1); + if (nextCodeUnit >= 0xDC00 && nextCodeUnit <= 0xDFFF) { + return 2; + } + } + return 1; +} + +int _textCodePointAt(String text, int index) { + final codeUnit = text.codeUnitAt(index); + if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF && index + 1 < text.length) { + final nextCodeUnit = text.codeUnitAt(index + 1); + if (nextCodeUnit >= 0xDC00 && nextCodeUnit <= 0xDFFF) { + return 0x10000 + ((codeUnit - 0xD800) << 10) + (nextCodeUnit - 0xDC00); + } + } + return codeUnit; +} + +bool _isCjkBreakableCodePoint(int codePoint) { + return (codePoint >= 0x2E80 && codePoint <= 0x2EFF) || + (codePoint >= 0x3000 && codePoint <= 0x303F) || + (codePoint >= 0x3040 && codePoint <= 0x30FF) || + (codePoint >= 0x31F0 && codePoint <= 0x31FF) || + (codePoint >= 0x3400 && codePoint <= 0x4DBF) || + (codePoint >= 0x4E00 && codePoint <= 0x9FFF) || + (codePoint >= 0xAC00 && codePoint <= 0xD7AF) || + (codePoint >= 0xF900 && codePoint <= 0xFAFF) || + (codePoint >= 0xFE10 && codePoint <= 0xFE1F) || + (codePoint >= 0xFE30 && codePoint <= 0xFE4F) || + (codePoint >= 0xFF00 && codePoint <= 0xFFEF) || + (codePoint >= 0x20000 && codePoint <= 0x2A6DF) || + (codePoint >= 0x2A700 && codePoint <= 0x2B73F) || + (codePoint >= 0x2B740 && codePoint <= 0x2B81F) || + (codePoint >= 0x2B820 && codePoint <= 0x2CEAF) || + (codePoint >= 0x2CEB0 && codePoint <= 0x2EBEF) || + (codePoint >= 0x30000 && codePoint <= 0x3134F); +} + @immutable class _MarkdownPretextMeasuredSegment { const _MarkdownPretextMeasuredSegment({ diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_descriptor_extractor.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_descriptor_extractor.dart index e2c70587..15eff7ba 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_descriptor_extractor.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_descriptor_extractor.dart @@ -95,22 +95,179 @@ class ResolvedIndexedBlockEntry { final Rect rect; } +class MarkdownDescriptorCache { + final Map _blockDescriptors = + {}; + final Map _indexedBlockDescriptors = + {}; + final Map _indexedListDescriptors = + {}; + + _CachedBlockDescriptor? _blockDescriptor(String key) => + _blockDescriptors[key]; + + void _storeBlockDescriptor( + String key, + String blockId, + BlockNode block, + int indentLevel, + SelectableTextDescriptor descriptor, + ) { + _blockDescriptors[key] = _CachedBlockDescriptor( + blockId: blockId, + block: block, + indentLevel: indentLevel, + descriptor: descriptor, + ); + } + + _CachedIndexedBlockDescriptors? _indexedBlockDescriptorsFor(String key) => + _indexedBlockDescriptors[key]; + + void _storeIndexedBlockDescriptors( + String key, + String ownerId, + List blocks, + int indentLevel, + String separator, + List descriptors, + ) { + _indexedBlockDescriptors[key] = _CachedIndexedBlockDescriptors( + ownerId: ownerId, + blocks: blocks, + indentLevel: indentLevel, + separator: separator, + descriptors: descriptors, + ); + } + + _CachedIndexedListDescriptors? _indexedListDescriptorsFor(String key) => + _indexedListDescriptors[key]; + + void _storeIndexedListDescriptors( + String key, + ListBlock block, + int indentLevel, + List descriptors, + ) { + _indexedListDescriptors[key] = _CachedIndexedListDescriptors( + block: block, + indentLevel: indentLevel, + descriptors: descriptors, + ); + } + + void cleanup(Set validIds) { + _blockDescriptors.removeWhere( + (_, entry) => !validIds.contains(entry.blockId), + ); + _indexedBlockDescriptors.removeWhere( + (_, entry) => !_ownerIdIsValid(entry.ownerId, validIds), + ); + _indexedListDescriptors.removeWhere( + (_, entry) => !validIds.contains(entry.block.id), + ); + } + + bool _ownerIdIsValid(String ownerId, Set validIds) { + for (final part in ownerId.split('|')) { + if (part.isEmpty || part == 'empty') { + continue; + } + if (!validIds.contains(part)) { + return false; + } + } + return true; + } +} + +class _CachedBlockDescriptor { + const _CachedBlockDescriptor({ + required this.blockId, + required this.block, + required this.indentLevel, + required this.descriptor, + }); + + final String blockId; + final BlockNode block; + final int indentLevel; + final SelectableTextDescriptor descriptor; +} + +class _CachedIndexedBlockDescriptors { + const _CachedIndexedBlockDescriptors({ + required this.ownerId, + required this.blocks, + required this.indentLevel, + required this.separator, + required this.descriptors, + }); + + final String ownerId; + final List blocks; + final int indentLevel; + final String separator; + final List descriptors; +} + +class _CachedIndexedListDescriptors { + const _CachedIndexedListDescriptors({ + required this.block, + required this.indentLevel, + required this.descriptors, + }); + + final ListBlock block; + final int indentLevel; + final List descriptors; +} + class MarkdownDescriptorExtractor { - const MarkdownDescriptorExtractor({ + MarkdownDescriptorExtractor({ required this.theme, required this.plainTextSerializer, required this.inlineBuilder, required this.codeSyntaxHighlighter, + required this.cache, }); final MarkdownThemeData theme; final MarkdownPlainTextSerializer plainTextSerializer; final MarkdownInlineBuilder inlineBuilder; final MarkdownCodeSyntaxHighlighter codeSyntaxHighlighter; + final MarkdownDescriptorCache cache; SelectableTextDescriptor buildSelectableDescriptorForBlock( BlockNode block, { int indentLevel = 0, + }) { + final cacheKey = '${block.id}:$indentLevel'; + final cached = cache._blockDescriptor(cacheKey); + if (cached != null && + identical(cached.block, block) && + cached.indentLevel == indentLevel) { + return cached.descriptor; + } + + final descriptor = _buildSelectableDescriptorForBlock( + block, + indentLevel: indentLevel, + ); + cache._storeBlockDescriptor( + cacheKey, + block.id, + block, + indentLevel, + descriptor, + ); + return descriptor; + } + + SelectableTextDescriptor _buildSelectableDescriptorForBlock( + BlockNode block, { + int indentLevel = 0, }) { switch (block.kind) { case MarkdownBlockKind.heading: @@ -247,6 +404,46 @@ class MarkdownDescriptorExtractor { List blocks, { int indentLevel = 0, required String separator, + String? cacheOwnerId, + }) { + if (cacheOwnerId != null) { + final cacheKey = '$cacheOwnerId:$indentLevel:$separator'; + final cached = cache._indexedBlockDescriptorsFor(cacheKey); + if (cached != null && + cached.ownerId == cacheOwnerId && + cached.indentLevel == indentLevel && + cached.separator == separator && + _sameBlockList(cached.blocks, blocks)) { + return cached.descriptors; + } + + final descriptors = _buildIndexedBlockDescriptors( + blocks, + indentLevel: indentLevel, + separator: separator, + ); + cache._storeIndexedBlockDescriptors( + cacheKey, + cacheOwnerId, + blocks, + indentLevel, + separator, + descriptors, + ); + return descriptors; + } + + return _buildIndexedBlockDescriptors( + blocks, + indentLevel: indentLevel, + separator: separator, + ); + } + + List _buildIndexedBlockDescriptors( + List blocks, { + int indentLevel = 0, + required String separator, }) { final entries = []; var offset = 0; @@ -279,6 +476,14 @@ class MarkdownDescriptorExtractor { ListBlock block, { int indentLevel = 0, }) { + final cacheKey = '${block.id}:$indentLevel'; + final cached = cache._indexedListDescriptorsFor(cacheKey); + if (cached != null && + identical(cached.block, block) && + cached.indentLevel == indentLevel) { + return cached.descriptors; + } + final entries = []; var offset = 0; for (var index = 0; index < block.items.length; index++) { @@ -300,6 +505,7 @@ class MarkdownDescriptorExtractor { entries.add(entry); offset += entry.descriptor.plainText.length; } + cache._storeIndexedListDescriptors(cacheKey, block, indentLevel, entries); return entries; } @@ -400,6 +606,7 @@ class MarkdownDescriptorExtractor { quoteBlock, indexedBlocks: buildIndexedBlockDescriptors( quoteBlock.children, + cacheOwnerId: quoteBlock.id, separator: '\n\n', ), baseOffset: baseOffset, @@ -679,6 +886,7 @@ class MarkdownDescriptorExtractor { blocks, indentLevel: indentLevel, separator: '\n', + cacheOwnerId: _childBlockOwnerId(blocks), ); if (indexedBlocks.isEmpty) { return null; @@ -717,6 +925,28 @@ class MarkdownDescriptorExtractor { return null; } + bool _sameBlockList(List left, List right) { + if (identical(left, right)) { + return true; + } + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index++) { + if (!identical(left[index], right[index])) { + return false; + } + } + return true; + } + + String _childBlockOwnerId(List blocks) { + if (blocks.isEmpty) { + return 'empty'; + } + return blocks.map((block) => block.id).join('|'); + } + bool _isLeadListTextBlock(BlockNode block) { switch (block.kind) { case MarkdownBlockKind.paragraph: diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart index e67380d4..28fc5d47 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart @@ -71,6 +71,47 @@ class _MarkdownSelectionGestureDetectorState bool _clearSelectionOnPointerUp = false; Timer? _autoScrollTimer; + List<_AutoScrollCandidate> _autoScrollCandidates() { + if (!mounted) { + return const <_AutoScrollCandidate>[]; + } + final candidates = <_AutoScrollCandidate>[]; + final seenPositions = {}; + + final explicitCandidate = _candidateForScrollable( + position: widget.scrollController.hasClients + ? widget.scrollController.position + : null, + renderObject: widget.scrollableKey.currentContext?.findRenderObject(), + depth: 0, + ); + if (explicitCandidate != null) { + seenPositions.add(explicitCandidate.position); + candidates.add(explicitCandidate); + } + + var depth = 1; + context.visitAncestorElements((element) { + if (element is StatefulElement && element.state is ScrollableState) { + final scrollableState = element.state as ScrollableState; + final position = scrollableState.position; + if (seenPositions.add(position)) { + final candidate = _candidateForScrollable( + position: position, + renderObject: scrollableState.context.findRenderObject(), + depth: depth, + ); + if (candidate != null) { + candidates.add(candidate); + } + depth += 1; + } + } + return true; + }); + return candidates; + } + @override void dispose() { _stopAutoScroll(); @@ -78,6 +119,9 @@ class _MarkdownSelectionGestureDetectorState } void _handlePointerDown(PointerDownEvent event) { + if (!mounted) { + return; + } if (!widget.isSelectable) { return; } @@ -86,13 +130,6 @@ class _MarkdownSelectionGestureDetectorState } if ((event.buttons & kSecondaryMouseButton) != 0) { - final selectionController = widget.selectionController; - if (!selectionController.hasSelection) { - final position = widget.hitTestPosition(event.position, clamp: true); - if (position != null) { - widget.selectBlockAt(position.blockIndex); - } - } widget.onRequestToolbar(event.position); return; } @@ -140,6 +177,9 @@ class _MarkdownSelectionGestureDetectorState } void _handlePointerMove(PointerMoveEvent event) { + if (!mounted) { + return; + } if (!_isDraggingSelection) { return; } @@ -159,6 +199,9 @@ class _MarkdownSelectionGestureDetectorState } void _handlePointerUp(PointerUpEvent event) { + if (!mounted) { + return; + } if (!_isDraggingSelection) { return; } @@ -177,6 +220,9 @@ class _MarkdownSelectionGestureDetectorState } void _handlePointerCancel(PointerCancelEvent event) { + if (!mounted) { + return; + } _isDraggingSelection = false; _dragBasePosition = null; _dragStartPointerPosition = null; @@ -186,7 +232,7 @@ class _MarkdownSelectionGestureDetectorState } void _updateDragSelectionAt(Offset globalPosition) { - if (_dragBasePosition == null) { + if (!mounted || _dragBasePosition == null) { return; } final position = widget.hitTestPosition(globalPosition, clamp: true); @@ -199,6 +245,10 @@ class _MarkdownSelectionGestureDetectorState } void _updateAutoScroll() { + if (!mounted) { + _stopAutoScroll(); + return; + } if (_autoScrollVelocity() == 0) { _stopAutoScroll(); return; @@ -210,21 +260,22 @@ class _MarkdownSelectionGestureDetectorState } void _handleAutoScrollTick() { - if (!_isDraggingSelection) { + if (!mounted || !_isDraggingSelection) { _stopAutoScroll(); return; } - if (!widget.scrollController.hasClients) { + final autoScroll = _resolveAutoScroll(); + if (autoScroll == null || autoScroll.velocity == 0) { _stopAutoScroll(); return; } - final velocity = _autoScrollVelocity(); - if (velocity == 0) { + + final position = autoScroll.candidate.position; + if (!position.hasPixels) { _stopAutoScroll(); return; } - - final position = widget.scrollController.position; + final velocity = autoScroll.velocity; final nextOffset = (position.pixels + velocity * _autoScrollTickInterval.inMilliseconds / 1000) .clamp(position.minScrollExtent, position.maxScrollExtent); @@ -232,7 +283,7 @@ class _MarkdownSelectionGestureDetectorState _stopAutoScroll(); return; } - widget.scrollController.jumpTo(nextOffset); + position.jumpTo(nextOffset); final globalPosition = _lastDragPointerPosition; if (globalPosition != null) { _updateDragSelectionAt(globalPosition); @@ -245,18 +296,51 @@ class _MarkdownSelectionGestureDetectorState } double _autoScrollVelocity() { + return _resolveAutoScroll()?.velocity ?? 0; + } + + _ResolvedAutoScroll? _resolveAutoScroll() { + if (!mounted) { + return null; + } final globalPosition = _lastDragPointerPosition; - final viewportRect = _scrollViewportRect; - if (globalPosition == null || - viewportRect == null || - !widget.scrollController.hasClients) { + if (globalPosition == null) { + return null; + } + + _ResolvedAutoScroll? bestMatch; + for (final candidate in _autoScrollCandidates()) { + final velocity = + _autoScrollVelocityForCandidate(candidate, globalPosition); + if (velocity == 0) { + continue; + } + if (bestMatch == null || + candidate.depth < bestMatch.candidate.depth || + (candidate.depth == bestMatch.candidate.depth && + candidate.viewportRect.size.longestSide < + bestMatch.candidate.viewportRect.size.longestSide)) { + bestMatch = + _ResolvedAutoScroll(candidate: candidate, velocity: velocity); + } + } + return bestMatch; + } + + double _autoScrollVelocityForCandidate( + _AutoScrollCandidate candidate, + Offset globalPosition, + ) { + final position = candidate.position; + if (!position.hasContentDimensions || + position.maxScrollExtent <= position.minScrollExtent) { return 0; } - final position = widget.scrollController.position; - if (position.maxScrollExtent <= position.minScrollExtent) { + if (_shouldSuppressAncestorAutoScroll(candidate, globalPosition)) { return 0; } + final viewportRect = candidate.viewportRect; if (globalPosition.dy < viewportRect.top + _autoScrollActivationZone && position.pixels > position.minScrollExtent) { final proximity = 1 - @@ -282,7 +366,58 @@ class _MarkdownSelectionGestureDetectorState return math.max(80, proximity * proximity * _autoScrollMaxSpeed); } - Rect? get _scrollViewportRect { + bool _shouldSuppressAncestorAutoScroll( + _AutoScrollCandidate candidate, + Offset globalPosition, + ) { + if (candidate.depth == 0) { + return false; + } + final markdownRect = _markdownContentRect; + if (markdownRect == null) { + return false; + } + final viewportRect = candidate.viewportRect; + const epsilon = 0.5; + final isNearTop = + globalPosition.dy < viewportRect.top + _autoScrollActivationZone; + final isNearBottom = + globalPosition.dy > viewportRect.bottom - _autoScrollActivationZone; + if (isNearBottom) { + return markdownRect.bottom <= viewportRect.bottom + epsilon; + } + if (isNearTop) { + return markdownRect.top >= viewportRect.top - epsilon; + } + return false; + } + + _AutoScrollCandidate? _candidateForScrollable({ + required ScrollPosition? position, + required RenderObject? renderObject, + required int depth, + }) { + if (position == null) { + return null; + } + if (position.axis != Axis.vertical) { + return null; + } + if (renderObject is! RenderBox || !renderObject.hasSize) { + return null; + } + final origin = renderObject.localToGlobal(Offset.zero); + return _AutoScrollCandidate( + position: position, + viewportRect: origin & renderObject.size, + depth: depth, + ); + } + + Rect? get _markdownContentRect { + if (!mounted) { + return null; + } final renderObject = widget.scrollableKey.currentContext?.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) { @@ -318,3 +453,25 @@ class _MarkdownSelectionGestureDetectorState ); } } + +class _AutoScrollCandidate { + const _AutoScrollCandidate({ + required this.position, + required this.viewportRect, + required this.depth, + }); + + final ScrollPosition position; + final Rect viewportRect; + final int depth; +} + +class _ResolvedAutoScroll { + const _ResolvedAutoScroll({ + required this.candidate, + required this.velocity, + }); + + final _AutoScrollCandidate candidate; + final double velocity; +} diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_resolver.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_resolver.dart index 3666eed7..99df55d1 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_resolver.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_resolver.dart @@ -19,6 +19,7 @@ class MarkdownBlockKeysRegistry { final Map>> tableCellKeysByBlock = {}; final Map>> tableCellTextKeysByBlock = {}; final Map>> tableBlockKeys = {}; + final Map tableScrollControllers = {}; final Map codeBlockScrollControllers = {}; final Map> imageErrorNotifiers = {}; @@ -129,6 +130,12 @@ class MarkdownBlockKeysRegistry { for (final blockId in staleCodeBlockIds) { codeBlockScrollControllers.remove(blockId)?.dispose(); } + final staleTableIds = tableScrollControllers.keys + .where((key) => !validIds.contains(key)) + .toList(growable: false); + for (final blockId in staleTableIds) { + tableScrollControllers.remove(blockId)?.dispose(); + } final staleImageNotifierIds = imageErrorNotifiers.keys .where((key) => !validIds.contains(key)) .toList(growable: false); diff --git a/packages/mixin_markdown_widget/lib/src/widgets/markdown_controller.dart b/packages/mixin_markdown_widget/lib/src/widgets/markdown_controller.dart index 097471b9..11a064d7 100644 --- a/packages/mixin_markdown_widget/lib/src/widgets/markdown_controller.dart +++ b/packages/mixin_markdown_widget/lib/src/widgets/markdown_controller.dart @@ -95,17 +95,28 @@ class MarkdownController extends ChangeNotifier { final previousData = _data; _data = data; _version += 1; + final parseStopwatch = Stopwatch()..start(); + late final String parseMode; if (allowIncrementalAppend && previousData.isNotEmpty && _data.startsWith(previousData)) { + parseMode = 'appendChunk'; _document = _parser.parseAppendingChunk( _data.substring(previousData.length), previousDocument: previousDocument, version: _version, ); } else { + parseMode = 'full'; _document = _parser.parse(_data, version: _version); } + parseStopwatch.stop(); + debugPrint( + '[mixin_markdown_widget] parse mode=$parseMode ' + 'version=$_version chars=${_data.length} ' + 'blocks=${_document.blocks.length} ' + 'elapsed=${parseStopwatch.elapsedMicroseconds / 1000}ms', + ); _syncStreamingState(); _documentVersionNotifier.value = _version; } diff --git a/packages/mixin_markdown_widget/lib/src/widgets/markdown_theme.dart b/packages/mixin_markdown_widget/lib/src/widgets/markdown_theme.dart index fc7cea75..9705d649 100644 --- a/packages/mixin_markdown_widget/lib/src/widgets/markdown_theme.dart +++ b/packages/mixin_markdown_widget/lib/src/widgets/markdown_theme.dart @@ -78,6 +78,7 @@ class MarkdownThemeData extends ThemeExtension required this.imagePlaceholderBackgroundColor, required this.showHeading1Divider, required this.showHeading2Divider, + required this.codeHighlightMaxLines, }); factory MarkdownThemeData.fallback(BuildContext context) { @@ -171,6 +172,7 @@ class MarkdownThemeData extends ThemeExtension colorScheme.surface.withValues(alpha: 0.92), showHeading1Divider: true, showHeading2Divider: true, + codeHighlightMaxLines: 120, ); } @@ -263,6 +265,7 @@ class MarkdownThemeData extends ThemeExtension colorScheme.surface.withValues(alpha: 0.92), showHeading1Divider: true, showHeading2Divider: true, + codeHighlightMaxLines: 120, ); } @@ -306,6 +309,7 @@ class MarkdownThemeData extends ThemeExtension final Color imagePlaceholderBackgroundColor; final bool showHeading1Divider; final bool showHeading2Divider; + final int? codeHighlightMaxLines; TextStyle headingStyleForLevel(int level) { switch (level) { @@ -367,6 +371,7 @@ class MarkdownThemeData extends ThemeExtension Color? imagePlaceholderBackgroundColor, bool? showHeading1Divider, bool? showHeading2Divider, + int? codeHighlightMaxLines, }) { return MarkdownThemeData( padding: padding ?? this.padding, @@ -417,6 +422,8 @@ class MarkdownThemeData extends ThemeExtension this.imagePlaceholderBackgroundColor, showHeading1Divider: showHeading1Divider ?? this.showHeading1Divider, showHeading2Divider: showHeading2Divider ?? this.showHeading2Divider, + codeHighlightMaxLines: + codeHighlightMaxLines ?? this.codeHighlightMaxLines, ); } @@ -563,6 +570,8 @@ class MarkdownThemeData extends ThemeExtension t < 0.5 ? showHeading1Divider : other.showHeading1Divider, showHeading2Divider: t < 0.5 ? showHeading2Divider : other.showHeading2Divider, + codeHighlightMaxLines: + t < 0.5 ? codeHighlightMaxLines : other.codeHighlightMaxLines, ); } @@ -574,6 +583,7 @@ class MarkdownThemeData extends ThemeExtension properties.add(DoubleProperty('maxContentWidth', maxContentWidth)); properties.add(DoubleProperty('imageCaptionSpacing', imageCaptionSpacing)); properties.add(DoubleProperty('quoteBorderWidth', quoteBorderWidth)); + properties.add(IntProperty('codeHighlightMaxLines', codeHighlightMaxLines)); properties.add(ColorProperty('selectionColor', selectionColor)); properties.add(DiagnosticsProperty('bodyStyle', bodyStyle)); properties @@ -623,6 +633,7 @@ class MarkdownThemeData extends ThemeExtension imagePlaceholderBackgroundColor, showHeading1Divider, showHeading2Divider, + codeHighlightMaxLines, ]); } @@ -672,6 +683,7 @@ class MarkdownThemeData extends ThemeExtension other.imagePlaceholderBackgroundColor == imagePlaceholderBackgroundColor && other.showHeading1Divider == showHeading1Divider && - other.showHeading2Divider == showHeading2Divider; + other.showHeading2Divider == showHeading2Divider && + other.codeHighlightMaxLines == codeHighlightMaxLines; } } diff --git a/packages/mixin_markdown_widget/test/mixin_markdown_widget_test.dart b/packages/mixin_markdown_widget/test/mixin_markdown_widget_test.dart index 480b9e7e..aaae05f5 100644 --- a/packages/mixin_markdown_widget/test/mixin_markdown_widget_test.dart +++ b/packages/mixin_markdown_widget/test/mixin_markdown_widget_test.dart @@ -1,12 +1,13 @@ import 'dart:math' as math; import 'dart:io'; -import 'dart:ui' show PointerDeviceKind; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mixin_markdown_widget/mixin_markdown_widget.dart'; +import 'package:mixin_markdown_widget/src/render/builder/markdown_inline_builder.dart'; import 'package:mixin_markdown_widget/src/render/local_image_provider_io.dart'; import 'package:mixin_markdown_widget/src/render/markdown_block_widgets.dart'; import 'package:mixin_markdown_widget/src/render/pretext_text_block.dart'; @@ -1746,6 +1747,7 @@ return value; ), ), ); + await tester.pump(); final richTextFinder = find.byWidgetPredicate( (widget) => @@ -1761,6 +1763,108 @@ return value; _countStyledDescendantSpans(rootSpan, rootSpan.style), greaterThan(0)); }); + testWidgets( + 'code blocks render plain text on the first frame, then highlight', + (tester) async { + const input = ''' +```dart +const value = 42; +return value; +``` +'''; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownWidget(data: input), + ), + ), + ); + + final richTextFinder = find.byWidgetPredicate( + (widget) => + widget is RichText && + widget.text.toPlainText().contains('const value = 42;'), + ); + expect(richTextFinder, findsOneWidget); + + var richText = tester.widget(richTextFinder); + var rootSpan = richText.text as TextSpan; + expect(_countStyledDescendantSpans(rootSpan, rootSpan.style), 0); + + await tester.pump(); + await tester.pump(); + + richText = tester.widget(richTextFinder); + rootSpan = richText.text as TextSpan; + expect( + _countStyledDescendantSpans(rootSpan, rootSpan.style), greaterThan(0)); + }); + + testWidgets('disposing markdown view ignores pending code highlight results', + (tester) async { + const input = ''' +```dart +const value = 42; +return value; +``` +'''; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownWidget(data: input), + ), + ), + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'long code blocks can degrade to plain text above the configured line limit', + (tester) async { + const input = ''' +```dart +final a = 1; +final b = 2; +final c = a + b; +``` +'''; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: MarkdownWidget( + data: input, + theme: MarkdownThemeData.fallback(context).copyWith( + codeHighlightMaxLines: 2, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final richTextFinder = find.byWidgetPredicate( + (widget) => + widget is RichText && + widget.text.toPlainText().contains('final c = a + b;'), + ); + expect(richTextFinder, findsOneWidget); + + final richText = tester.widget(richTextFinder); + final rootSpan = richText.text as TextSpan; + expect(_countStyledDescendantSpans(rootSpan, rootSpan.style), 0); + }); + testWidgets('renders inline code with rounded background and padding', ( tester, ) async { @@ -1813,6 +1917,72 @@ return value; ); }); + testWidgets('standalone inline code keeps descenders inside the line box', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownWidget(data: '`popup`'), + ), + ), + ); + + final blockBox = + tester.renderObject(find.byType(MarkdownPretextTextBlock)); + final inlineCodeBox = + tester.renderObject(_decoratedInlineTextFinder().first); + final theme = MarkdownTheme.of(tester.element(find.byType(MarkdownWidget))); + final textPainter = TextPainter( + text: TextSpan(text: 'popup', style: theme.bodyStyle), + textDirection: TextDirection.ltr, + maxLines: 1, + )..layout(maxWidth: double.infinity); + final blockRect = blockBox.localToGlobal(Offset.zero) & blockBox.size; + final inlineCodeRect = + inlineCodeBox.localToGlobal(Offset.zero) & inlineCodeBox.size; + + expect( + blockBox.size.height, + greaterThanOrEqualTo(textPainter.preferredLineHeight), + ); + expect(inlineCodeRect.top, greaterThanOrEqualTo(blockRect.top - 0.01)); + expect(inlineCodeRect.bottom, lessThanOrEqualTo(blockRect.bottom + 0.01)); + }); + + testWidgets('list inline code keeps descenders inside the line box', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownWidget(data: '- `popup`'), + ), + ), + ); + + final blockBox = tester + .renderObject(find.byType(MarkdownPretextTextBlock).first); + final inlineCodeBox = + tester.renderObject(_decoratedInlineTextFinder().first); + final theme = MarkdownTheme.of(tester.element(find.byType(MarkdownWidget))); + final textPainter = TextPainter( + text: TextSpan(text: 'popup', style: theme.bodyStyle), + textDirection: TextDirection.ltr, + maxLines: 1, + )..layout(maxWidth: double.infinity); + final blockRect = blockBox.localToGlobal(Offset.zero) & blockBox.size; + final inlineCodeRect = + inlineCodeBox.localToGlobal(Offset.zero) & inlineCodeBox.size; + + expect( + blockBox.size.height, + greaterThanOrEqualTo(textPainter.preferredLineHeight), + ); + expect(inlineCodeRect.top, greaterThanOrEqualTo(blockRect.top - 0.01)); + expect(inlineCodeRect.bottom, lessThanOrEqualTo(blockRect.bottom + 0.01)); + }); + testWidgets('code blocks prefer the default Mono font family', (tester) async { await tester.pumpWidget( @@ -2325,6 +2495,43 @@ const value = 42; expect(inlineCodeDecorationFinder, findsWidgets); }); + testWidgets('table cells render inline math without baseline alignment', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownWidget( + data: '| Formula |\n| --- |\n| value \$x^2\$ |', + ), + ), + ), + ); + + final tableFinder = find.byType(Table); + expect(tableFinder, findsOneWidget); + + final cellRichText = tester.widget( + find + .descendant( + of: tableFinder, + matching: find.byWidgetPredicate( + (widget) => + widget is RichText && + widget.text.toPlainText().contains( + String.fromCharCode(0xFFFC), + ), + ), + ) + .first, + ); + final widgetSpans = _collectWidgetSpans(cellRichText.text).toList(); + + expect(widgetSpans, hasLength(1)); + expect(widgetSpans.single.alignment, PlaceholderAlignment.middle); + expect(widgetSpans.single.baseline, isNull); + }); + testWidgets('undecorated runs render as a single direct rich text block', ( tester, ) async { @@ -2548,6 +2755,225 @@ const value = 42; } }); + test('send_sol_for_rent wrap does not create an extra clipped line', () { + const padding = EdgeInsets.symmetric(horizontal: 6, vertical: 2); + const baseStyle = TextStyle(fontSize: 14, height: 1.2); + const expected = + '宅学长发布了一份InputFragment 余额与手续费校验说明文档,整理了Mixin安卓App账户租金(rent)场景,才会显示send_sol_for_rent提示。'; + const runs = [ + MarkdownPretextInlineRun( + text: + '宅学长发布了一份InputFragment 余额与手续费校验说明文档,整理了Mixin安卓App账户租金(rent)场景,才会显示', + style: baseStyle, + ), + MarkdownPretextInlineRun( + text: 'send_sol_for_rent', + style: baseStyle, + allowCharacterWrap: true, + decoration: MarkdownPretextInlineDecoration( + backgroundColor: Color(0xFFE9EDF2), + borderRadius: BorderRadius.all(Radius.circular(6)), + padding: padding, + ), + ), + MarkdownPretextInlineRun( + text: '提示。', + style: baseStyle, + ), + ]; + + for (final width in [232, 228, 224, 220, 216, 212, 208, 204]) { + final layout = computeMarkdownPretextLayoutFromRuns( + runs: runs, + fallbackStyle: baseStyle, + maxWidth: width, + textScaleFactor: 1, + ); + + expect( + layout.lines.map((line) => line.text).join().replaceAll(' ', ''), + expected.replaceAll(' ', ''), + reason: 'width=$width', + ); + for (final line in layout.lines) { + final actualWidth = line.segments.fold( + 0, + (sum, segment) => sum + (segment.right - segment.left), + ); + expect(actualWidth, lessThanOrEqualTo(width + 0.01)); + } + final decoratedText = layout.lines + .expand((line) => line.segments) + .where((segment) => segment.decoration != null) + .map((segment) => segment.text) + .join(); + expect( + decoratedText, + 'send_sol_for_rent', + reason: 'width=$width', + ); + } + }); + + test('trailing inline code does not shrink earlier line wrapping', () { + const padding = EdgeInsets.symmetric(horizontal: 6, vertical: 2); + const baseStyle = TextStyle(fontSize: 14, height: 1.2); + const prefix = + '宅学长发布了一份InputFragment 余额与手续费校验说明文档,整理了Mixin安卓App钱包转账页面的逻辑,区分了三种场景,重点明确:只有Solana真实账户租金场景,才会显示'; + const suffix = '提示。'; + const width = 232.0; + + final plainLayout = computeMarkdownPretextLayoutFromRuns( + runs: const [ + MarkdownPretextInlineRun( + text: '$prefix send_sol_for_rent$suffix', + style: baseStyle, + ), + ], + fallbackStyle: baseStyle, + maxWidth: width, + textScaleFactor: 1, + ); + + final codeLayout = computeMarkdownPretextLayoutFromRuns( + runs: const [ + MarkdownPretextInlineRun( + text: '$prefix ', + style: baseStyle, + ), + MarkdownPretextInlineRun( + text: 'send_sol_for_rent', + style: baseStyle, + allowCharacterWrap: true, + decoration: MarkdownPretextInlineDecoration( + backgroundColor: Color(0xFFE9EDF2), + borderRadius: BorderRadius.all(Radius.circular(6)), + padding: padding, + ), + ), + MarkdownPretextInlineRun( + text: suffix, + style: baseStyle, + ), + ], + fallbackStyle: baseStyle, + maxWidth: width, + textScaleFactor: 1, + ); + + expect(plainLayout.lines, isNotEmpty); + expect(codeLayout.lines, isNotEmpty); + expect( + codeLayout.lines.first.text.replaceAll(' ', ''), + plainLayout.lines.first.text.replaceAll(' ', ''), + ); + }); + + testWidgets( + 'markdown inline code at paragraph tail does not shift the first line break', + (tester) async { + const inlineCodeMarkdown = + '某人发布了一份**《TEST 某功能校验说明》**文档,整理了某客户端转账页的逻辑,区分了三种场景,分别说明金额校验、可用余额计算、手续费校验规则,重点明确:只有某链真实租金场景,才会显示`rent_tip_token` 提示。'; + late MarkdownThemeData theme; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + theme = MarkdownThemeData.fallback(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + List layoutLinesFromMarkdown(String markdown, double width) { + final parser = MarkdownDocumentParser(); + final document = parser.parse(markdown); + final paragraph = document.blocks.single as ParagraphBlock; + final builder = MarkdownInlineBuilder( + theme: theme, + recognizers: [], + ); + final runs = builder.buildPretextRuns(theme.bodyStyle, paragraph.inlines); + final layout = computeMarkdownPretextLayoutFromRuns( + runs: runs, + fallbackStyle: theme.bodyStyle, + maxWidth: width, + textScaleFactor: 1, + ); + return layout.lines.map((line) => line.text).toList(growable: false); + } + + for (final width in [500]) { + final inlineCodeLines = + layoutLinesFromMarkdown(inlineCodeMarkdown, width); + + expect(inlineCodeLines, isNotEmpty, reason: 'width=$width'); + expect( + inlineCodeLines.first, + isNot('某人发布了一份**《TEST'), + reason: 'width=$width first=${inlineCodeLines.first}', + ); + expect( + inlineCodeLines.first, + contains('某功能'), + reason: 'width=$width first=${inlineCodeLines.first}', + ); + } + }); + + testWidgets( + 'widget rendering keeps the first list line stable with trailing inline code', + (tester) async { + const inlineCodeMarkdown = + '1. 某人发布了一份**《TEST 某功能校验说明》**文档,整理了某客户端转账页的逻辑,区分了三种场景,分别说明金额校验、可用余额计算、手续费校验规则,重点明确:只有某链真实租金场景,才会显示`rent_tip_token` 提示。'; + + Future> renderedLines(String markdown, double width) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + child: MarkdownWidget(data: markdown), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final blockFinder = find.byType(MarkdownPretextTextBlock).first; + final blockWidget = tester.widget(blockFinder); + final blockRenderBox = tester.renderObject(blockFinder); + final layout = computeMarkdownPretextLayoutFromRuns( + runs: blockWidget.runs!, + fallbackStyle: blockWidget.fallbackStyle, + maxWidth: blockRenderBox.size.width, + textScaleFactor: 1, + ); + return layout.lines.map((line) => line.text).toList(growable: false); + } + + for (final width in [500]) { + final inlineCodeLines = await renderedLines(inlineCodeMarkdown, width); + + expect(inlineCodeLines, isNotEmpty, reason: 'width=$width'); + expect( + inlineCodeLines.first, + isNot('某人发布了一份**《TEST'), + reason: 'width=$width first=${inlineCodeLines.first}', + ); + expect( + inlineCodeLines.first, + contains('某功能'), + reason: 'width=$width first=${inlineCodeLines.first}', + ); + } + }); + testWidgets('reuses cached unchanged pretext block widgets on append', ( tester, ) async { @@ -4124,12 +4550,79 @@ const veryLongValueName = 42; await selectionController.copySelectionToClipboard(); await tester.pump(); - expect(copiedText, 'Name\tValue\nrow\t42'); + expect(copiedText, 'Name\tValue\nrow\t42'); + }); + + testWidgets('triple click inside a table selects only the active cell', ( + tester, + ) async { + final selectionController = MarkdownSelectionController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownWidget( + data: ''' +| Name | Value | +| --- | --- | +| row | 42 | +''', + selectionController: selectionController, + ), + ), + ), + ); + + final target = tester.getCenter(find.text('42')); + + await tester.tapAt(target); + await tester.pump(); + await tester.tapAt(target); + await tester.pump(); + await tester.tapAt(target); + await tester.pump(); + + expect(selectionController.hasSelection, isTrue); + expect(selectionController.selectedPlainText, '42'); + }); + + testWidgets( + 'triple click inside a table cell with inline math selects the whole cell', + (tester) async { + final selectionController = MarkdownSelectionController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownWidget( + data: r''' +| Feature | Description | Status | +| :--- | :---: | ---: | +| **Math** | Full LaTeX parsing & rendering (\( \alpha^2 \)) | ✅ | +''', + selectionController: selectionController, + ), + ), + ), + ); + + final target = tester.getCenter(find.textContaining('LaTeX')); + + await _tripleTapAt(tester, target); + + expect(selectionController.hasSelection, isTrue); + expect( + selectionController.selectedPlainText, + contains('Full LaTeX parsing & rendering'), + ); + expect(selectionController.selectedPlainText, contains(r'\alpha^2')); + expect(selectionController.selectedPlainText, isNot(contains('\t'))); + expect(selectionController.selectedPlainText, isNot(contains('\n'))); }); - testWidgets('triple click inside a table selects only the active cell', ( - tester, - ) async { + testWidgets( + 'table block refreshes cached selection visuals when the selection range changes', + (tester) async { final selectionController = MarkdownSelectionController(); await tester.pumpWidget( @@ -4147,17 +4640,54 @@ const veryLongValueName = 42; ), ); - final target = tester.getCenter(find.text('42')); + final tableBlockFinder = find.byWidgetPredicate( + (widget) => + widget is SelectableMarkdownBlock && + widget.spec.plainText == 'Name\tValue\nrow\t42', + ); + expect(tableBlockFinder, findsOneWidget); - await tester.tapAt(target); - await tester.pump(); - await tester.tapAt(target); + selectionController.setSelection( + const DocumentSelection( + base: DocumentPosition( + blockIndex: 0, + path: PathInBlock([0]), + textOffset: 0, + ), + extent: DocumentPosition( + blockIndex: 0, + path: PathInBlock([0]), + textOffset: 4, + ), + ), + ); await tester.pump(); - await tester.tapAt(target); + + var tableBlock = tester.widget(tableBlockFinder); + expect(tableBlock.selectionRange, isNotNull); + expect(tableBlock.selectionRange!.start.textOffset, 0); + expect(tableBlock.selectionRange!.end.textOffset, 4); + + selectionController.setSelection( + const DocumentSelection( + base: DocumentPosition( + blockIndex: 0, + path: PathInBlock([0]), + textOffset: 15, + ), + extent: DocumentPosition( + blockIndex: 0, + path: PathInBlock([0]), + textOffset: 17, + ), + ), + ); await tester.pump(); - expect(selectionController.hasSelection, isTrue); - expect(selectionController.selectedPlainText, '42'); + tableBlock = tester.widget(tableBlockFinder); + expect(tableBlock.selectionRange, isNotNull); + expect(tableBlock.selectionRange!.start.textOffset, 15); + expect(tableBlock.selectionRange!.end.textOffset, 17); }); testWidgets('triple click inside wrapped paragraph selects the current line', @@ -5341,6 +5871,271 @@ return value; expect(selectionController.selectedPlainText, 'Inner quote'); }); + testWidgets( + 'selection auto-scroll falls back to ancestor scrollables for shrink-wrapped markdown widgets', + (tester) async { + final selectionController = MarkdownSelectionController(); + final outerScrollController = ScrollController(); + const targetLabel = 'Target message paragraph 1'; + + String buildMessage(String label) => ''' +$label + +$label continued with more content for drag selection. + +- bullet a +- bullet b +'''; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 320, + child: ListView( + controller: outerScrollController, + children: List.generate(6, (index) { + final label = index == 1 ? targetLabel : 'Message ${index + 1}'; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: MarkdownWidget( + data: buildMessage(label), + selectionController: + index == 1 ? selectionController : null, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + ), + ); + }), + ), + ), + ), + ), + ); + + final outerListFinder = find.byWidgetPredicate( + (widget) => + widget is ListView && widget.controller == outerScrollController, + ); + final start = tester.getCenter(find.text(targetLabel)); + final viewport = tester.getRect(outerListFinder); + final end = Offset(start.dx, viewport.bottom + 80); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: start); + await gesture.down(start); + await tester.pump(); + await gesture.moveTo(end); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + expect(outerScrollController.offset, greaterThan(0)); + expect(selectionController.hasSelection, isTrue); + expect(selectionController.selectedPlainText, contains(targetLabel)); + + await gesture.up(); + await tester.pump(); + }); + + testWidgets( + 'ancestor auto-scroll stops when the current shrink-wrapped markdown is already fully visible', + (tester) async { + final selectionController = MarkdownSelectionController(); + final outerScrollController = ScrollController(); + const targetLabel = 'Fully visible target'; + + String buildMessage(String label) => ''' +$label + +$label continued. +'''; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 320, + child: ListView( + controller: outerScrollController, + children: List.generate(10, (index) { + final label = index == 1 ? targetLabel : 'Message ${index + 1}'; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: MarkdownWidget( + data: buildMessage(label), + selectionController: + index == 1 ? selectionController : null, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + ), + ); + }), + ), + ), + ), + ), + ); + + final markdownFinder = find.ancestor( + of: find.text(targetLabel), + matching: find.byType(MarkdownWidget), + ); + final markdownRect = tester.getRect(markdownFinder); + final outerListFinder = find.byWidgetPredicate( + (widget) => + widget is ListView && widget.controller == outerScrollController, + ); + final viewport = tester.getRect(outerListFinder); + expect(markdownRect.bottom, lessThan(viewport.bottom)); + + final start = tester.getCenter(find.text(targetLabel)); + final end = Offset(start.dx, viewport.bottom - 8); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: start); + await gesture.down(start); + await tester.pump(); + await gesture.moveTo(end); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + expect(outerScrollController.offset, 0); + expect(selectionController.hasSelection, isTrue); + expect(selectionController.selectedPlainText, contains(targetLabel)); + + await gesture.up(); + await tester.pump(); + }); + + testWidgets( + 'ancestor auto-scroll stays idle for a fully visible chat-style markdown bubble', + (tester) async { + final outerScrollController = ScrollController(); + final selectionController = MarkdownSelectionController(); + + Widget buildAssistantBubble(String text, + {MarkdownSelectionController? controller}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CircleAvatar(child: Icon(Icons.terminal_rounded)), + const SizedBox(width: 12), + Flexible( + child: Container( + padding: const EdgeInsets.all(16), + child: MarkdownWidget( + data: text, + selectionController: controller, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + ), + ), + ), + ], + ), + ); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 420, + child: ListView.builder( + controller: outerScrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + itemCount: 8, + itemBuilder: (context, index) { + if (index == 1) { + return buildAssistantBubble( + 'Visible bubble\n\nSecond paragraph.\n\n- bullet a\n- bullet b', + controller: selectionController, + ); + } + return buildAssistantBubble('Message ${index + 1}'); + }, + ), + ), + ), + ), + ); + + final outerListFinder = find.byWidgetPredicate( + (widget) => + widget is ListView && widget.controller == outerScrollController, + ); + final markdownFinder = find.ancestor( + of: find.text('Visible bubble'), + matching: find.byType(MarkdownWidget), + ); + final markdownRect = tester.getRect(markdownFinder); + final viewportRect = tester.getRect(outerListFinder); + expect(markdownRect.bottom, lessThan(viewportRect.bottom)); + + final start = tester.getCenter(find.text('Visible bubble')); + final end = Offset(start.dx, viewportRect.bottom - 6); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: start); + await gesture.down(start); + await tester.pump(); + await gesture.moveTo(end); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + expect(outerScrollController.offset, 0); + expect(selectionController.hasSelection, isTrue); + + await gesture.up(); + await tester.pump(); + }); + + testWidgets('selection auto-scroll ignores pointer moves after unmount', ( + tester, + ) async { + final selectionController = MarkdownSelectionController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownWidget( + data: ''' +Line 1 + +Line 2 +''', + selectionController: selectionController, + ), + ), + ), + ); + + final start = tester.getCenter(find.text('Line 1')); + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: start); + await gesture.down(start); + await tester.pump(); + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pump(); + + await gesture.moveTo(start + const Offset(0, 120)); + await tester.pump(); + + expect(tester.takeException(), isNull); + + await gesture.up(); + await tester.pump(); + }); + testWidgets('dragging into a table selects the table block content', ( tester, ) async { @@ -5418,6 +6213,93 @@ Intro expect(selectionController.selectedPlainText, 'Intro\n\nName'); }); + testWidgets( + 'reverse dragging from following heading into a table keeps table selection rects visible', + (tester) async { + final selectionController = MarkdownSelectionController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownWidget( + data: r''' +## 4. Complex Tables + +Tables support varying alignments, complex cell contents, and inline styles. + +| Feature | Description | Status | +| :--- | :---: | ---: | +| **Parsing** | Fast incremental markdown parsing | ✅ | +| **Selection** | Seamless multi-block text selection | ✅ | +| **Math** | Full LaTeX parsing & rendering (\( \alpha^2 \)) | ✅ | +| **Code** | Syntax highlighting with *re_highlight* | 🚀 Built | + +## 5. Media & Links +''', + selectionController: selectionController, + ), + ), + ), + ); + + final start = tester.getCenter(find.textContaining('Media & Links')) + + const Offset(0, 4); + final end = tester.getCenter(find.text('Feature')); + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: start); + await gesture.down(start); + await tester.pump(); + await gesture.moveTo(end); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(selectionController.hasSelection, isTrue); + expect( + selectionController.selectedPlainText, + contains('Description\tStatus'), + ); + expect( + selectionController.selectedPlainText, + contains('Code\tSyntax highlighting with re_highlight\t🚀 Built'), + ); + + final tableBlockFinder = find.byWidgetPredicate( + (widget) => + widget is SelectableMarkdownBlock && + widget.spec.plainText.contains('Feature\tDescription\tStatus'), + ); + expect(tableBlockFinder, findsOneWidget); + + final tableBlock = tester.widget(tableBlockFinder); + final selectionRange = selectionController.normalizedRange!; + final blockRange = DocumentRange( + start: DocumentPosition( + blockIndex: tableBlock.blockIndex, + path: const PathInBlock([0]), + textOffset: selectionRange.start.blockIndex == tableBlock.blockIndex + ? selectionRange.start.textOffset + : 0, + ), + end: DocumentPosition( + blockIndex: tableBlock.blockIndex, + path: const PathInBlock([0]), + textOffset: selectionRange.end.blockIndex == tableBlock.blockIndex + ? selectionRange.end.textOffset + : tableBlock.spec.plainText.length, + ), + ); + + final renderBox = tester.renderObject(tableBlockFinder); + final element = tester.element(tableBlockFinder); + final selectionRects = tableBlock.spec.selectionRectResolver!( + element, + renderBox.size, + blockRange, + ); + expect(selectionRects, isNotEmpty); + }); + testWidgets('footnote backreference markers are not rendered', (tester) async { await tester.pumpWidget(