Skip to main content

text_document/
text_block.rs

1//! Read-only block (paragraph) handle.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use frontend::commands::{block_commands, frame_commands, list_commands};
8use frontend::common::format_runs::{FormatRun, ImageAnchor, synth_element_id};
9use frontend::common::types::EntityId;
10
11use crate::convert::to_usize;
12use crate::flow::{BlockSnapshot, FragmentContent, ListInfo, TableCellContext, TableCellRef};
13use crate::inner::TextDocumentInner;
14use crate::text_frame::TextFrame;
15use crate::text_list::TextList;
16use crate::text_table::TextTable;
17use crate::{BlockFormat, ListStyle, TextFormat};
18
19/// A lightweight, read-only handle to a single block (paragraph).
20///
21/// Holds a stable entity ID — the handle remains valid across edits
22/// that insert or remove other blocks. Each method acquires the
23/// document lock independently. For consistent reads across multiple
24/// fields, use [`snapshot()`](TextBlock::snapshot).
25#[derive(Clone)]
26pub struct TextBlock {
27    pub(crate) doc: Arc<Mutex<TextDocumentInner>>,
28    pub(crate) block_id: usize,
29}
30
31impl TextBlock {
32    // ── Content ──────────────────────────────────────────────
33
34    /// Block's plain text. O(1).
35    pub fn text(&self) -> String {
36        let inner = self.doc.lock();
37        let store = inner.ctx.db_context.get_store();
38        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
39            .ok()
40            .flatten()
41            .map(|b| {
42                let entity: common::entities::Block = b.into();
43                common::database::rope_helpers::block_content_via_store(&entity, store)
44            })
45            .unwrap_or_default()
46    }
47
48    /// Character count. O(1).
49    pub fn length(&self) -> usize {
50        let inner = self.doc.lock();
51        let store = inner.ctx.db_context.get_store();
52        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
53            .ok()
54            .flatten()
55            .map(|b| {
56                let entity: common::entities::Block = b.into();
57                to_usize(common::database::rope_helpers::block_char_length(
58                    &entity, store,
59                ))
60            })
61            .unwrap_or(0)
62    }
63
64    /// `length() == 0`. O(1).
65    pub fn is_empty(&self) -> bool {
66        let inner = self.doc.lock();
67        let store = inner.ctx.db_context.get_store();
68        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
69            .ok()
70            .flatten()
71            .map(|b| {
72                let entity: common::entities::Block = b.into();
73                common::database::rope_helpers::block_char_length(&entity, store) == 0
74            })
75            .unwrap_or(true)
76    }
77
78    /// Block entity still exists in the database. O(1).
79    pub fn is_valid(&self) -> bool {
80        let inner = self.doc.lock();
81        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
82            .ok()
83            .flatten()
84            .is_some()
85    }
86
87    // ── Identity and Position ────────────────────────────────
88
89    /// Stable entity ID (stored in the handle). O(1).
90    pub fn id(&self) -> usize {
91        self.block_id
92    }
93
94    /// Character offset of this block's start in the document. O(log n)
95    /// via the rope index for rope-clean documents; O(1) read of the
96    /// stored field for tabled documents.
97    pub fn position(&self) -> usize {
98        let inner = self.doc.lock();
99        let Some(mut dto) = block_commands::get_block(&inner.ctx, &(self.block_id as u64))
100            .ok()
101            .flatten()
102        else {
103            return 0;
104        };
105        let store = inner.ctx.db_context.get_store();
106        crate::inner::refresh_block_position(&mut dto, store);
107        to_usize(dto.document_position)
108    }
109
110    /// Global 0-indexed block number. **O(n)**: requires scanning all blocks
111    /// sorted by `document_position`. Prefer [`id()`](TextBlock::id) for
112    /// identity and [`position()`](TextBlock::position) for ordering.
113    pub fn block_number(&self) -> usize {
114        let inner = self.doc.lock();
115        compute_block_number(&inner, self.block_id as u64)
116    }
117
118    /// The next block in document order. **O(n)**.
119    /// Returns `None` if this is the last block.
120    pub fn next(&self) -> Option<TextBlock> {
121        let inner = self.doc.lock();
122        let all_blocks = block_commands::get_all_block(&inner.ctx).ok()?;
123        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
124        let store = inner.ctx.db_context.get_store();
125        crate::inner::refresh_block_positions(&mut sorted, store);
126        sorted.sort_by_key(|b| b.document_position);
127        let idx = sorted.iter().position(|b| b.id == self.block_id as u64)?;
128        sorted.get(idx + 1).map(|b| TextBlock {
129            doc: Arc::clone(&self.doc),
130            block_id: b.id as usize,
131        })
132    }
133
134    /// The previous block in document order. **O(n)**.
135    /// Returns `None` if this is the first block.
136    pub fn previous(&self) -> Option<TextBlock> {
137        let inner = self.doc.lock();
138        let all_blocks = block_commands::get_all_block(&inner.ctx).ok()?;
139        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
140        let store = inner.ctx.db_context.get_store();
141        crate::inner::refresh_block_positions(&mut sorted, store);
142        sorted.sort_by_key(|b| b.document_position);
143        let idx = sorted.iter().position(|b| b.id == self.block_id as u64)?;
144        if idx == 0 {
145            return None;
146        }
147        sorted.get(idx - 1).map(|b| TextBlock {
148            doc: Arc::clone(&self.doc),
149            block_id: b.id as usize,
150        })
151    }
152
153    // ── Structural Context ───────────────────────────────────
154
155    /// Parent frame. O(1).
156    pub fn frame(&self) -> TextFrame {
157        let inner = self.doc.lock();
158        let frame_id = find_parent_frame(&inner, self.block_id as u64);
159        TextFrame {
160            doc: Arc::clone(&self.doc),
161            frame_id: frame_id.map(|id| id as usize).unwrap_or(0),
162        }
163    }
164
165    /// If inside a table cell, returns table and cell coordinates.
166    ///
167    /// Finds the block's parent frame, then checks if any table cell
168    /// references that frame as its `cell_frame`. If so, identifies the
169    /// owning table.
170    pub fn table_cell(&self) -> Option<TableCellRef> {
171        let inner = self.doc.lock();
172        let frame_id = find_parent_frame(&inner, self.block_id as u64)?;
173
174        // Check if this frame is referenced as a cell_frame by any table cell.
175        // First try the fast path: if the frame has a `table` field, use it.
176        let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
177            .ok()
178            .flatten()?;
179
180        if let Some(table_entity_id) = frame_dto.table {
181            // This frame is a table anchor frame (not a cell frame).
182            // Anchor frames don't contain blocks directly — cell frames do.
183            // So this path shouldn't match, but check cells just in case.
184            let table_dto =
185                frontend::commands::table_commands::get_table(&inner.ctx, &{ table_entity_id })
186                    .ok()
187                    .flatten()?;
188            for &cell_id in &table_dto.cells {
189                if let Some(cell_dto) =
190                    frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{
191                        cell_id
192                    })
193                    .ok()
194                    .flatten()
195                    && cell_dto.cell_frame == Some(frame_id)
196                {
197                    return Some(TableCellRef {
198                        table: TextTable {
199                            doc: Arc::clone(&self.doc),
200                            table_id: table_entity_id as usize,
201                        },
202                        row: to_usize(cell_dto.row),
203                        column: to_usize(cell_dto.column),
204                    });
205                }
206            }
207        }
208
209        // Slow path: this frame has no `table` field (cell frames don't).
210        // Scan all tables to find if any cell references this frame.
211        let all_tables =
212            frontend::commands::table_commands::get_all_table(&inner.ctx).unwrap_or_default();
213        for table_dto in &all_tables {
214            for &cell_id in &table_dto.cells {
215                if let Some(cell_dto) =
216                    frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{
217                        cell_id
218                    })
219                    .ok()
220                    .flatten()
221                    && cell_dto.cell_frame == Some(frame_id)
222                {
223                    return Some(TableCellRef {
224                        table: TextTable {
225                            doc: Arc::clone(&self.doc),
226                            table_id: table_dto.id as usize,
227                        },
228                        row: to_usize(cell_dto.row),
229                        column: to_usize(cell_dto.column),
230                    });
231                }
232            }
233        }
234
235        None
236    }
237
238    // ── Formatting ──────────────────────────────────────────
239
240    /// Block format (alignment, margins, indent, heading level, marker, tabs). O(1).
241    pub fn block_format(&self) -> BlockFormat {
242        let inner = self.doc.lock();
243        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
244            .ok()
245            .flatten()
246            .map(|b| BlockFormat::from(&b))
247            .unwrap_or_default()
248    }
249
250    /// Character format at a block-relative character offset. **O(k)**
251    /// where k = format runs + image anchors in this block.
252    ///
253    /// Returns the [`TextFormat`] of the fragment containing the given
254    /// offset. Returns `None` if the offset is out of range or the
255    /// block has no fragments.
256    pub fn char_format_at(&self, offset: usize) -> Option<TextFormat> {
257        let inner = self.doc.lock();
258        let fragments = build_fragments(&inner, self.block_id as u64);
259        for frag in &fragments {
260            match frag {
261                FragmentContent::Text {
262                    format,
263                    offset: frag_offset,
264                    length,
265                    ..
266                } => {
267                    if offset >= *frag_offset && offset < frag_offset + length {
268                        return Some(format.clone());
269                    }
270                }
271                FragmentContent::Image {
272                    format,
273                    offset: frag_offset,
274                    ..
275                } => {
276                    if offset == *frag_offset {
277                        return Some(format.clone());
278                    }
279                }
280            }
281        }
282        None
283    }
284
285    // ── Fragments ───────────────────────────────────────────
286
287    /// Shaping-input fragments: base formatting plus any *metric-affecting*
288    /// syntax highlights (bold / italic / size / family / spacing). This is
289    /// what the layout engine shapes. **Paint-only highlights (colors,
290    /// underline decorations) are NOT merged here** — they are kept separate
291    /// in [`BlockSnapshot::paint_highlights`](crate::BlockSnapshot::paint_highlights)
292    /// as a post-shape recolor overlay, so the shaping input stays stable
293    /// across paint-only highlight changes. For the fully-merged *visual*
294    /// fragments, use [`display_fragments`](Self::display_fragments).
295    ///
296    /// O(k) where k = format runs + image anchors in this block.
297    pub fn fragments(&self) -> Vec<FragmentContent> {
298        let inner = self.doc.lock();
299        build_fragments(&inner, self.block_id as u64)
300    }
301
302    /// Fragments as they should be *displayed*: base formatting with **all**
303    /// active syntax highlights merged in, including paint-only ones. This is
304    /// the "what it looks like" view — useful for a non-optimized renderer,
305    /// for accessibility, or for tests. The optimized layout path instead uses
306    /// [`fragments`](Self::fragments) (shaping input) plus the separate
307    /// [`BlockSnapshot::paint_highlights`](crate::BlockSnapshot::paint_highlights)
308    /// overlay. Equivalent to the pre-overlay behaviour of `fragments()`.
309    pub fn display_fragments(&self) -> Vec<FragmentContent> {
310        let inner = self.doc.lock();
311        let fragments = build_raw_fragments(&inner, self.block_id as u64, None);
312        if let Some(ref hl) = inner.highlight
313            && let Some(block_hl) = hl.blocks.get(&{ self.block_id })
314            && !block_hl.spans.is_empty()
315        {
316            return crate::highlight::merge_highlight_spans(fragments, &block_hl.spans);
317        }
318        fragments
319    }
320
321    // ── List Membership ─────────────────────────────────────
322
323    /// List this block belongs to. O(1).
324    pub fn list(&self) -> Option<TextList> {
325        let inner = self.doc.lock();
326        let block_dto = block_commands::get_block(&inner.ctx, &(self.block_id as u64))
327            .ok()
328            .flatten()?;
329        let list_id = block_dto.list?;
330        Some(TextList {
331            doc: Arc::clone(&self.doc),
332            list_id: list_id as usize,
333        })
334    }
335
336    /// 0-based position within its list. **O(n)** where n = total blocks.
337    pub fn list_item_index(&self) -> Option<usize> {
338        let inner = self.doc.lock();
339        let block_dto = block_commands::get_block(&inner.ctx, &(self.block_id as u64))
340            .ok()
341            .flatten()?;
342        let list_id = block_dto.list?;
343        Some(compute_list_item_index(
344            &inner,
345            list_id,
346            self.block_id as u64,
347        ))
348    }
349
350    // ── Snapshot ─────────────────────────────────────────────
351
352    /// All layout-relevant data in one lock acquisition. O(k+n).
353    pub fn snapshot(&self) -> BlockSnapshot {
354        let inner = self.doc.lock();
355        build_block_snapshot(&inner, self.block_id as u64, inner.highlight_kind).unwrap_or_else(
356            || BlockSnapshot {
357                block_id: self.block_id,
358                position: 0,
359                length: 0,
360                text: String::new(),
361                fragments: Vec::new(),
362                block_format: BlockFormat::default(),
363                list_info: None,
364                parent_frame_id: None,
365                table_cell: None,
366                paint_highlights: Vec::new(),
367            },
368        )
369    }
370}
371
372// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
373// Internal helpers (called while lock is held)
374// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
375
376/// Find the parent frame of a block by scanning all frames.
377fn find_parent_frame(inner: &TextDocumentInner, block_id: u64) -> Option<EntityId> {
378    let all_frames = frame_commands::get_all_frame(&inner.ctx).ok()?;
379    let block_entity_id = block_id as EntityId;
380    for frame in &all_frames {
381        if frame.blocks.contains(&block_entity_id) {
382            return Some(frame.id as EntityId);
383        }
384    }
385    None
386}
387
388/// O(1) fast check used by the snapshot hot path: returns true iff the
389/// store has zero table entities. Used to skip the expensive
390/// `find_table_cell_context` walks for documents that have no tables
391/// (e.g. typical markdown documents in an editor).
392fn document_has_no_tables(inner: &TextDocumentInner) -> bool {
393    inner
394        .ctx
395        .db_context
396        .get_store()
397        .tables
398        .read()
399        .unwrap()
400        .is_empty()
401}
402
403/// Find table cell context for a block (snapshot-friendly, no live handles).
404/// Returns `None` if the block is not inside a table cell.
405fn find_table_cell_context(inner: &TextDocumentInner, block_id: u64) -> Option<TableCellContext> {
406    // Fast exit: a doc with no tables can't have any cell-bound blocks.
407    // Avoids per-block `get_all_frame` + `get_all_table` walks during
408    // snapshot_flow, which is called per editor pane on every keystroke.
409    if document_has_no_tables(inner) {
410        return None;
411    }
412    let frame_id = find_parent_frame(inner, block_id)?;
413
414    let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
415        .ok()
416        .flatten()?;
417
418    // Fast path: anchor frame with `table` field set
419    if let Some(table_entity_id) = frame_dto.table {
420        let table_dto =
421            frontend::commands::table_commands::get_table(&inner.ctx, &{ table_entity_id })
422                .ok()
423                .flatten()?;
424        for &cell_id in &table_dto.cells {
425            if let Some(cell_dto) =
426                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{ cell_id })
427                    .ok()
428                    .flatten()
429                && cell_dto.cell_frame == Some(frame_id)
430            {
431                return Some(TableCellContext {
432                    table_id: table_entity_id as usize,
433                    row: to_usize(cell_dto.row),
434                    column: to_usize(cell_dto.column),
435                });
436            }
437        }
438    }
439
440    // Slow path: scan all tables for a cell referencing this frame
441    let all_tables =
442        frontend::commands::table_commands::get_all_table(&inner.ctx).unwrap_or_default();
443    for table_dto in &all_tables {
444        for &cell_id in &table_dto.cells {
445            if let Some(cell_dto) =
446                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{ cell_id })
447                    .ok()
448                    .flatten()
449                && cell_dto.cell_frame == Some(frame_id)
450            {
451                return Some(TableCellContext {
452                    table_id: table_dto.id as usize,
453                    row: to_usize(cell_dto.row),
454                    column: to_usize(cell_dto.column),
455                });
456            }
457        }
458    }
459
460    None
461}
462
463/// Compute 0-indexed block number by scanning all blocks sorted by document_position.
464fn compute_block_number(inner: &TextDocumentInner, block_id: u64) -> usize {
465    let mut all_blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
466    let store = inner.ctx.db_context.get_store();
467    crate::inner::refresh_block_positions(&mut all_blocks, store);
468    let mut sorted: Vec<_> = all_blocks.iter().collect();
469    sorted.sort_by_key(|b| b.document_position);
470    sorted.iter().position(|b| b.id == block_id).unwrap_or(0)
471}
472
473/// Build fragments for a block from its format runs and image anchors,
474/// with highlight spans merged in when a syntax highlighter is attached.
475pub(crate) fn build_fragments(inner: &TextDocumentInner, block_id: u64) -> Vec<FragmentContent> {
476    build_fragments_with_text(inner, block_id, None, inner.highlight_kind)
477}
478
479/// Like `build_fragments` but accepts a pre-materialized block text to
480/// avoid the double `block_content_via_store` allocation when the
481/// caller (e.g. `build_block_snapshot_with_position_and_parent`)
482/// already has the text. Per-block snapshot cost halves for typing in
483/// a multi-block document.
484pub(crate) fn build_fragments_with_text(
485    inner: &TextDocumentInner,
486    block_id: u64,
487    prefetched_text: Option<&str>,
488    effective_kind: crate::highlight::HighlighterKind,
489) -> Vec<FragmentContent> {
490    let fragments = build_raw_fragments(inner, block_id, prefetched_text);
491
492    // Only merge highlights into the shaping input when the effective
493    // highlighter is metric-affecting. Paint-only highlighters keep
494    // fragments as BASE and carry their spans separately in
495    // `BlockSnapshot::paint_highlights`, so the engine can recolor without
496    // reshaping. `effective_kind` is normally `inner.highlight_kind`, but a
497    // "without highlights" snapshot passes `None` to force base fragments
498    // regardless of the live highlighter. See `HighlighterKind`.
499    if effective_kind == crate::highlight::HighlighterKind::Metric
500        && let Some(ref hl) = inner.highlight
501        && let Some(block_hl) = hl.blocks.get(&(block_id as usize))
502        && !block_hl.spans.is_empty()
503    {
504        return crate::highlight::merge_highlight_spans(fragments, &block_hl.spans);
505    }
506
507    fragments
508}
509
510/// Build raw fragments from the block's format_runs and block_images
511/// tables (Phase 1 of the rope migration). Reads the per-block plain_text
512/// from the Block DTO and uses the format-run byte ranges + image
513/// anchors to produce a stream of `FragmentContent::{Text, Image}`
514/// values in document order.
515///
516/// `element_id` is synthesized from (block_id, byte_start) via
517/// `synth_element_id`. Synthesized ids are stable for the same
518/// (block, byte_start) pair and never collide with real entity ids
519/// (top bit set).
520///
521/// Uncovered byte ranges between runs (or before the first run / after
522/// the last) emit Text fragments with `TextFormat::default()` — the
523/// "no character formatting" case.
524fn build_raw_fragments(
525    inner: &TextDocumentInner,
526    block_id: u64,
527    prefetched_text: Option<&str>,
528) -> Vec<FragmentContent> {
529    let _block_dto = match block_commands::get_block(&inner.ctx, &block_id)
530        .ok()
531        .flatten()
532    {
533        Some(b) => b,
534        None => return Vec::new(),
535    };
536
537    let plain_owned;
538    let plain: &str = match prefetched_text {
539        Some(t) => t,
540        None => {
541            let entity: common::entities::Block = _block_dto.clone().into();
542            plain_owned = common::database::rope_helpers::block_content_via_store(
543                &entity,
544                inner.ctx.db_context.get_store(),
545            );
546            &plain_owned
547        }
548    };
549
550    let (runs, images) = {
551        let store = inner.ctx.db_context.get_store();
552        let runs: Vec<FormatRun> = store
553            .format_runs
554            .read()
555            .unwrap()
556            .get(&block_id)
557            .cloned()
558            .unwrap_or_default();
559        let images: Vec<ImageAnchor> = store
560            .block_images
561            .read()
562            .unwrap()
563            .get(&block_id)
564            .cloned()
565            .unwrap_or_default();
566        (runs, images)
567    };
568
569    let mut fragments = Vec::with_capacity(runs.len() + images.len() + 1);
570    let mut char_offset: usize = 0;
571    let mut byte_cursor: u32 = 0;
572    let mut img_iter = images.iter().peekable();
573
574    // Helper to push an unformatted text fragment for bytes [a..b).
575    // Returns the new char_offset and updates byte_cursor.
576    fn emit_default_text(
577        fragments: &mut Vec<FragmentContent>,
578        plain: &str,
579        block_id: u64,
580        byte_a: u32,
581        byte_b: u32,
582        char_offset: &mut usize,
583        byte_cursor: &mut u32,
584    ) {
585        if byte_a >= byte_b {
586            return;
587        }
588        let text = &plain[byte_a as usize..byte_b as usize];
589        let length = text.chars().count();
590        let word_starts = compute_word_starts(text);
591        fragments.push(FragmentContent::Text {
592            text: text.to_string(),
593            format: TextFormat::default(),
594            offset: *char_offset,
595            length,
596            element_id: synth_element_id(block_id, byte_a),
597            word_starts,
598        });
599        *char_offset += length;
600        *byte_cursor = byte_b;
601    }
602
603    // Helper to push a formatted text fragment for bytes [a..b) with the
604    // given run's format. Used both for whole runs and for the
605    // before-image / after-image slices when an image sits inside a run.
606    #[allow(clippy::too_many_arguments)]
607    fn emit_run_text(
608        fragments: &mut Vec<FragmentContent>,
609        plain: &str,
610        block_id: u64,
611        byte_a: u32,
612        byte_b: u32,
613        run_format: &frontend::common::format_runs::CharacterFormat,
614        char_offset: &mut usize,
615        byte_cursor: &mut u32,
616    ) {
617        if byte_a >= byte_b {
618            return;
619        }
620        let text = &plain[byte_a as usize..byte_b as usize];
621        let length = text.chars().count();
622        let word_starts = compute_word_starts(text);
623        fragments.push(FragmentContent::Text {
624            text: text.to_string(),
625            format: TextFormat::from(run_format),
626            offset: *char_offset,
627            length,
628            element_id: synth_element_id(block_id, byte_a),
629            word_starts,
630        });
631        *char_offset += length;
632        *byte_cursor = byte_b;
633    }
634
635    for run in &runs {
636        let mut run_cursor = run.byte_start;
637
638        // Emit images that fall strictly before this run, then handle
639        // images that fall inside the run by splitting it at each
640        // image's byte_offset.
641        while let Some(img) = img_iter.peek() {
642            if img.byte_offset < run.byte_start {
643                // Image before the run — emit unformatted gap text, then image.
644                emit_default_text(
645                    &mut fragments,
646                    plain,
647                    block_id,
648                    byte_cursor,
649                    img.byte_offset,
650                    &mut char_offset,
651                    &mut byte_cursor,
652                );
653                fragments.push(FragmentContent::Image {
654                    name: img.name.clone(),
655                    width: img.width as u32,
656                    height: img.height as u32,
657                    quality: img.quality as u32,
658                    format: TextFormat::from(&img.format),
659                    offset: char_offset,
660                    element_id: synth_element_id(block_id, img.byte_offset),
661                });
662                char_offset += 1;
663                img_iter.next();
664            } else if img.byte_offset <= run.byte_end {
665                // Image at the run's start or inside the run.
666                // First close any unformatted gap upstream of the run.
667                emit_default_text(
668                    &mut fragments,
669                    plain,
670                    block_id,
671                    byte_cursor,
672                    run_cursor,
673                    &mut char_offset,
674                    &mut byte_cursor,
675                );
676                // Emit the formatted text slice [run_cursor..img.byte_offset).
677                emit_run_text(
678                    &mut fragments,
679                    plain,
680                    block_id,
681                    run_cursor,
682                    img.byte_offset,
683                    &run.format,
684                    &mut char_offset,
685                    &mut byte_cursor,
686                );
687                // Emit the image itself.
688                fragments.push(FragmentContent::Image {
689                    name: img.name.clone(),
690                    width: img.width as u32,
691                    height: img.height as u32,
692                    quality: img.quality as u32,
693                    format: TextFormat::from(&img.format),
694                    offset: char_offset,
695                    element_id: synth_element_id(block_id, img.byte_offset),
696                });
697                char_offset += 1;
698                run_cursor = img.byte_offset;
699                byte_cursor = img.byte_offset;
700                img_iter.next();
701            } else {
702                break;
703            }
704        }
705
706        // Unformatted gap between byte_cursor and the run's start (if
707        // the run starts past where we last emitted).
708        emit_default_text(
709            &mut fragments,
710            plain,
711            block_id,
712            byte_cursor,
713            run_cursor,
714            &mut char_offset,
715            &mut byte_cursor,
716        );
717
718        // Emit the remaining tail of the run [run_cursor..run.byte_end).
719        emit_run_text(
720            &mut fragments,
721            plain,
722            block_id,
723            run_cursor,
724            run.byte_end,
725            &run.format,
726            &mut char_offset,
727            &mut byte_cursor,
728        );
729    }
730
731    // Any remaining images after the last run.
732    for img in img_iter {
733        emit_default_text(
734            &mut fragments,
735            plain,
736            block_id,
737            byte_cursor,
738            img.byte_offset,
739            &mut char_offset,
740            &mut byte_cursor,
741        );
742        fragments.push(FragmentContent::Image {
743            name: img.name.clone(),
744            width: img.width as u32,
745            height: img.height as u32,
746            quality: img.quality as u32,
747            format: TextFormat::from(&img.format),
748            offset: char_offset,
749            element_id: synth_element_id(block_id, img.byte_offset),
750        });
751        char_offset += 1;
752    }
753
754    // Trailing unformatted text after the last run / image.
755    emit_default_text(
756        &mut fragments,
757        plain,
758        block_id,
759        byte_cursor,
760        plain.len() as u32,
761        &mut char_offset,
762        &mut byte_cursor,
763    );
764
765    fragments
766}
767
768/// Compute character-index-based word starts for a text slice,
769/// following Unicode Standard Annex #29. Returned indices are
770/// positions within `text.chars()`, NOT byte offsets — matches
771/// AccessKit's `word_starts` contract where each entry is an index
772/// into `character_lengths`.
773fn compute_word_starts(text: &str) -> Vec<u8> {
774    use unicode_segmentation::UnicodeSegmentation;
775    let mut result = Vec::new();
776    // `unicode_word_indices` yields (byte_offset, word_slice) for each
777    // Unicode-word match. Convert each byte offset to a character
778    // index by counting `char_indices` up to that offset.
779    let mut byte_to_char: Vec<(usize, usize)> = Vec::new();
780    for (ci, (bi, _)) in text.char_indices().enumerate() {
781        byte_to_char.push((bi, ci));
782    }
783    for (byte_off, _word) in text.unicode_word_indices() {
784        let char_idx = byte_to_char
785            .iter()
786            .find(|(bi, _)| *bi == byte_off)
787            .map(|(_, ci)| *ci)
788            .unwrap_or(0);
789        // Saturating cast — text runs longer than 255 chars get their
790        // later word starts dropped. That's the AccessKit contract:
791        // `word_starts` is Box<[u8]>. Runs longer than ~255 chars are
792        // unusual for a single format run, and the first 255 word
793        // starts cover the viewport almost always. Documented in the
794        // plan.
795        if let Ok(idx) = u8::try_from(char_idx) {
796            result.push(idx);
797        } else {
798            break;
799        }
800    }
801    result
802}
803
804/// Compute 0-based index of a block within its list.
805fn compute_list_item_index(inner: &TextDocumentInner, list_id: EntityId, block_id: u64) -> usize {
806    let mut all_blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
807    let store = inner.ctx.db_context.get_store();
808    crate::inner::refresh_block_positions(&mut all_blocks, store);
809    let mut list_blocks: Vec<_> = all_blocks
810        .iter()
811        .filter(|b| b.list == Some(list_id))
812        .collect();
813    list_blocks.sort_by_key(|b| b.document_position);
814    list_blocks
815        .iter()
816        .position(|b| b.id == block_id)
817        .unwrap_or(0)
818}
819
820/// Format a list marker for the given item index.
821pub(crate) fn format_list_marker(
822    list_dto: &frontend::list::dtos::ListDto,
823    item_index: usize,
824) -> String {
825    let number = item_index + 1; // 1-based for display
826    let marker_body = match list_dto.style {
827        ListStyle::Disc => "\u{2022}".to_string(),   // •
828        ListStyle::Circle => "\u{25E6}".to_string(), // ◦
829        ListStyle::Square => "\u{25AA}".to_string(), // ▪
830        ListStyle::Decimal => format!("{number}"),
831        ListStyle::LowerAlpha => {
832            if number <= 26 {
833                ((b'a' + (number as u8 - 1)) as char).to_string()
834            } else {
835                format!("{number}")
836            }
837        }
838        ListStyle::UpperAlpha => {
839            if number <= 26 {
840                ((b'A' + (number as u8 - 1)) as char).to_string()
841            } else {
842                format!("{number}")
843            }
844        }
845        ListStyle::LowerRoman => to_roman_lower(number),
846        ListStyle::UpperRoman => to_roman_upper(number),
847    };
848    format!("{}{marker_body}{}", list_dto.prefix, list_dto.suffix)
849}
850
851fn to_roman_upper(mut n: usize) -> String {
852    const VALUES: &[(usize, &str)] = &[
853        (1000, "M"),
854        (900, "CM"),
855        (500, "D"),
856        (400, "CD"),
857        (100, "C"),
858        (90, "XC"),
859        (50, "L"),
860        (40, "XL"),
861        (10, "X"),
862        (9, "IX"),
863        (5, "V"),
864        (4, "IV"),
865        (1, "I"),
866    ];
867    let mut result = String::new();
868    for &(val, sym) in VALUES {
869        while n >= val {
870            result.push_str(sym);
871            n -= val;
872        }
873    }
874    result
875}
876
877fn to_roman_lower(n: usize) -> String {
878    to_roman_upper(n).to_lowercase()
879}
880
881/// Build a ListInfo for a block. Called while lock is held.
882fn build_list_info(
883    inner: &TextDocumentInner,
884    block_dto: &frontend::block::dtos::BlockDto,
885) -> Option<ListInfo> {
886    let list_id = block_dto.list?;
887    let list_dto = list_commands::get_list(&inner.ctx, &{ list_id })
888        .ok()
889        .flatten()?;
890
891    let item_index = compute_list_item_index(inner, list_id, block_dto.id);
892    let marker = format_list_marker(&list_dto, item_index);
893
894    Some(ListInfo {
895        list_id: list_id as usize,
896        style: list_dto.style.clone(),
897        indent: list_dto.indent as u8,
898        marker,
899        item_index,
900    })
901}
902
903/// Build a BlockSnapshot for a block. Called while lock is held.
904pub(crate) fn build_block_snapshot(
905    inner: &TextDocumentInner,
906    block_id: u64,
907    effective_kind: crate::highlight::HighlighterKind,
908) -> Option<BlockSnapshot> {
909    build_block_snapshot_with_position_and_parent(inner, block_id, None, None, effective_kind)
910}
911
912/// Build a BlockSnapshot, optionally overriding the position with a computed value.
913/// When `computed_position` is Some, it's used instead of `block_dto.document_position`
914/// (which may be stale if position updates are deferred).
915pub(crate) fn build_block_snapshot_with_position(
916    inner: &TextDocumentInner,
917    block_id: u64,
918    computed_position: Option<usize>,
919    effective_kind: crate::highlight::HighlighterKind,
920) -> Option<BlockSnapshot> {
921    build_block_snapshot_with_position_and_parent(
922        inner,
923        block_id,
924        computed_position,
925        None,
926        effective_kind,
927    )
928}
929
930/// Build a BlockSnapshot with an optional `parent_frame_hint`. When the
931/// caller already knows which frame owns the block (e.g. snapshot_flow's
932/// per-frame walk), passing it here skips the per-block `find_parent_frame`
933/// call — which would otherwise fetch every Frame in the store on every
934/// invocation. That walk was a major contributor to per-keystroke
935/// editor lag.
936pub(crate) fn build_block_snapshot_with_position_and_parent(
937    inner: &TextDocumentInner,
938    block_id: u64,
939    computed_position: Option<usize>,
940    parent_frame_hint: Option<EntityId>,
941    effective_kind: crate::highlight::HighlighterKind,
942) -> Option<BlockSnapshot> {
943    let mut block_dto = block_commands::get_block(&inner.ctx, &block_id)
944        .ok()
945        .flatten()?;
946    let store_for_pos = inner.ctx.db_context.get_store();
947    crate::inner::refresh_block_position(&mut block_dto, store_for_pos);
948
949    let block_format = BlockFormat::from(&block_dto);
950    let list_info = build_list_info(inner, &block_dto);
951
952    let parent_frame_id = parent_frame_hint
953        .or_else(|| find_parent_frame(inner, block_id))
954        .map(|id| id as usize);
955    let table_cell = find_table_cell_context(inner, block_id);
956
957    // The flow-snapshot position MUST agree with the space the editing path
958    // resolves cursor positions against. When the rope mirrors every block
959    // (now true even with tables, since cell content is mirrored inline), the
960    // rope is the single source of truth: its char order — including the
961    // 1-char table-anchor sentinel — is what `find_block_at_char_position`
962    // uses. So derive `position` from the rope-refreshed `document_position`
963    // (set above) rather than the caller's running counter, which omits the
964    // sentinel and would drift past every table. Only when the rope is NOT
965    // authoritative (programmatically-inserted sub-frames whose blocks aren't
966    // mirrored) do we fall back to the caller's computed running position.
967    let position = if common::database::rope_helpers::rope_positions_match_flow(store_for_pos) {
968        to_usize(block_dto.document_position)
969    } else {
970        computed_position.unwrap_or_else(|| to_usize(block_dto.document_position))
971    };
972
973    // Materialize the block text once and pass it to build_fragments
974    // and into the snapshot's `text` field — saves one redundant rope
975    // slice + String allocation per block per snapshot_flow call.
976    let entity: common::entities::Block = block_dto.clone().into();
977    let store = inner.ctx.db_context.get_store();
978    let text = common::database::rope_helpers::block_content_via_store(&entity, store);
979    let length = to_usize(common::database::rope_helpers::block_char_length(
980        &entity, store,
981    ));
982    let fragments = build_fragments_with_text(inner, block_id, Some(&text), effective_kind);
983
984    // Paint-only highlighter: emit the spans as a separate overlay (fragments
985    // stayed base above). Metric / none: empty (highlights merged into
986    // fragments, or none). A "without highlights" snapshot passes
987    // `effective_kind = None`, so this is empty regardless of the live kind.
988    let paint_highlights = if effective_kind == crate::highlight::HighlighterKind::PaintOnly {
989        inner
990            .highlight
991            .as_ref()
992            .and_then(|hl| hl.blocks.get(&(block_id as usize)))
993            .map(|bd| crate::highlight::extract_paint_spans(&bd.spans, length))
994            .unwrap_or_default()
995    } else {
996        Vec::new()
997    };
998
999    Some(BlockSnapshot {
1000        block_id: block_id as usize,
1001        position,
1002        length,
1003        text,
1004        fragments,
1005        block_format,
1006        list_info,
1007        parent_frame_id,
1008        table_cell,
1009        paint_highlights,
1010    })
1011}
1012
1013/// Build BlockSnapshots for all blocks in a frame, sorted by document_position.
1014pub(crate) fn build_blocks_snapshot_for_frame(
1015    inner: &TextDocumentInner,
1016    frame_id: u64,
1017    effective_kind: crate::highlight::HighlighterKind,
1018) -> Vec<BlockSnapshot> {
1019    let frame_dto = match frame_commands::get_frame(&inner.ctx, &(frame_id as EntityId))
1020        .ok()
1021        .flatten()
1022    {
1023        Some(f) => f,
1024        None => return Vec::new(),
1025    };
1026
1027    let mut block_dtos: Vec<_> = frame_dto
1028        .blocks
1029        .iter()
1030        .filter_map(|&id| {
1031            block_commands::get_block(&inner.ctx, &{ id })
1032                .ok()
1033                .flatten()
1034        })
1035        .collect();
1036    let store = inner.ctx.db_context.get_store();
1037    crate::inner::refresh_block_positions(&mut block_dtos, store);
1038    block_dtos.sort_by_key(|b| b.document_position);
1039
1040    block_dtos
1041        .iter()
1042        .filter_map(|b| build_block_snapshot(inner, b.id, effective_kind))
1043        .collect()
1044}
1045
1046/// Build BlockSnapshots with computed positions starting from `start_pos`.
1047///
1048/// Returns `(snapshots, running_pos_after_last_block)`.
1049/// Positions are computed sequentially from `start_pos` using each block's
1050/// `text_length`, matching the logic in `find_block_at_position_sequential`.
1051pub(crate) fn build_blocks_snapshot_for_frame_with_positions(
1052    inner: &TextDocumentInner,
1053    frame_id: u64,
1054    start_pos: usize,
1055    effective_kind: crate::highlight::HighlighterKind,
1056) -> (Vec<BlockSnapshot>, usize) {
1057    let frame_dto = match frame_commands::get_frame(&inner.ctx, &(frame_id as EntityId))
1058        .ok()
1059        .flatten()
1060    {
1061        Some(f) => f,
1062        None => return (Vec::new(), start_pos),
1063    };
1064
1065    let mut block_dtos: Vec<_> = frame_dto
1066        .blocks
1067        .iter()
1068        .filter_map(|&id| {
1069            block_commands::get_block(&inner.ctx, &{ id })
1070                .ok()
1071                .flatten()
1072        })
1073        .collect();
1074    let store = inner.ctx.db_context.get_store();
1075    crate::inner::refresh_block_positions(&mut block_dtos, store);
1076    block_dtos.sort_by_key(|b| b.document_position);
1077
1078    let mut running_pos = start_pos;
1079    let mut snapshots = Vec::with_capacity(block_dtos.len());
1080    for b in &block_dtos {
1081        if let Some(snap) =
1082            build_block_snapshot_with_position(inner, b.id, Some(running_pos), effective_kind)
1083        {
1084            running_pos += snap.length + 1; // +1 for block separator
1085            snapshots.push(snap);
1086        }
1087    }
1088    (snapshots, running_pos)
1089}