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, document_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.
377pub(crate) fn 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.ctx.db_context.get_store().tables.read().is_empty()
394}
395
396/// Find table cell context for a block (snapshot-friendly, no live handles).
397/// Returns `None` if the block is not inside a table cell.
398fn find_table_cell_context(inner: &TextDocumentInner, block_id: u64) -> Option<TableCellContext> {
399    // Fast exit: a doc with no tables can't have any cell-bound blocks.
400    // Avoids per-block `get_all_frame` + `get_all_table` walks during
401    // snapshot_flow, which is called per editor pane on every keystroke.
402    if document_has_no_tables(inner) {
403        return None;
404    }
405    let frame_id = find_parent_frame(inner, block_id)?;
406
407    let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
408        .ok()
409        .flatten()?;
410
411    // Fast path: anchor frame with `table` field set
412    if let Some(table_entity_id) = frame_dto.table {
413        let table_dto =
414            frontend::commands::table_commands::get_table(&inner.ctx, &{ table_entity_id })
415                .ok()
416                .flatten()?;
417        for &cell_id in &table_dto.cells {
418            if let Some(cell_dto) =
419                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{ cell_id })
420                    .ok()
421                    .flatten()
422                && cell_dto.cell_frame == Some(frame_id)
423            {
424                return Some(TableCellContext {
425                    table_id: table_entity_id as usize,
426                    row: to_usize(cell_dto.row),
427                    column: to_usize(cell_dto.column),
428                });
429            }
430        }
431    }
432
433    // Slow path: scan all tables for a cell referencing this frame
434    let all_tables =
435        frontend::commands::table_commands::get_all_table(&inner.ctx).unwrap_or_default();
436    for table_dto in &all_tables {
437        for &cell_id in &table_dto.cells {
438            if let Some(cell_dto) =
439                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{ cell_id })
440                    .ok()
441                    .flatten()
442                && cell_dto.cell_frame == Some(frame_id)
443            {
444                return Some(TableCellContext {
445                    table_id: table_dto.id as usize,
446                    row: to_usize(cell_dto.row),
447                    column: to_usize(cell_dto.column),
448                });
449            }
450        }
451    }
452
453    None
454}
455
456/// Compute 0-indexed block number by scanning all blocks sorted by document_position.
457fn compute_block_number(inner: &TextDocumentInner, block_id: u64) -> usize {
458    let mut all_blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
459    let store = inner.ctx.db_context.get_store();
460    crate::inner::refresh_block_positions(&mut all_blocks, store);
461    let mut sorted: Vec<_> = all_blocks.iter().collect();
462    sorted.sort_by_key(|b| b.document_position);
463    sorted.iter().position(|b| b.id == block_id).unwrap_or(0)
464}
465
466/// Build fragments for a block from its format runs and image anchors,
467/// with highlight spans merged in when a syntax highlighter is attached.
468pub(crate) fn build_fragments(inner: &TextDocumentInner, block_id: u64) -> Vec<FragmentContent> {
469    build_fragments_with_text(inner, block_id, None, inner.highlight_kind)
470}
471
472/// Like `build_fragments` but accepts a pre-materialized block text to
473/// avoid the double `block_content_via_store` allocation when the
474/// caller (e.g. `build_block_snapshot_with_position_and_parent`)
475/// already has the text. Per-block snapshot cost halves for typing in
476/// a multi-block document.
477pub(crate) fn build_fragments_with_text(
478    inner: &TextDocumentInner,
479    block_id: u64,
480    prefetched_text: Option<&str>,
481    effective_kind: crate::highlight::HighlighterKind,
482) -> Vec<FragmentContent> {
483    let fragments = build_raw_fragments(inner, block_id, prefetched_text);
484
485    // Only merge highlights into the shaping input when the effective
486    // highlighter is metric-affecting. Paint-only highlighters keep
487    // fragments as BASE and carry their spans separately in
488    // `BlockSnapshot::paint_highlights`, so the engine can recolor without
489    // reshaping. `effective_kind` is normally `inner.highlight_kind`, but a
490    // "without highlights" snapshot passes `None` to force base fragments
491    // regardless of the live highlighter. See `HighlighterKind`.
492    if effective_kind == crate::highlight::HighlighterKind::Metric
493        && let Some(ref hl) = inner.highlight
494        && let Some(block_hl) = hl.blocks.get(&(block_id as usize))
495        && !block_hl.spans.is_empty()
496    {
497        return crate::highlight::merge_highlight_spans(fragments, &block_hl.spans);
498    }
499
500    fragments
501}
502
503/// Build raw fragments from the block's format_runs and block_images
504/// tables (Phase 1 of the rope migration). Reads the per-block plain_text
505/// from the Block DTO and uses the format-run byte ranges + image
506/// anchors to produce a stream of `FragmentContent::{Text, Image}`
507/// values in document order.
508///
509/// `element_id` is synthesized from (block_id, byte_start) via
510/// `synth_element_id`. Synthesized ids are stable for the same
511/// (block, byte_start) pair and never collide with real entity ids
512/// (top bit set).
513///
514/// Uncovered byte ranges between runs (or before the first run / after
515/// the last) emit Text fragments with `TextFormat::default()` — the
516/// "no character formatting" case.
517fn build_raw_fragments(
518    inner: &TextDocumentInner,
519    block_id: u64,
520    prefetched_text: Option<&str>,
521) -> Vec<FragmentContent> {
522    let _block_dto = match block_commands::get_block(&inner.ctx, &block_id)
523        .ok()
524        .flatten()
525    {
526        Some(b) => b,
527        None => return Vec::new(),
528    };
529
530    let plain_owned;
531    let plain: &str = match prefetched_text {
532        Some(t) => t,
533        None => {
534            let entity: common::entities::Block = _block_dto.clone().into();
535            plain_owned = common::database::rope_helpers::block_content_via_store(
536                &entity,
537                inner.ctx.db_context.get_store(),
538            );
539            &plain_owned
540        }
541    };
542
543    let (runs, images) = {
544        let store = inner.ctx.db_context.get_store();
545        let runs: Vec<FormatRun> = store
546            .format_runs
547            .read()
548            .get(&block_id)
549            .cloned()
550            .unwrap_or_default();
551        let images: Vec<ImageAnchor> = store
552            .block_images
553            .read()
554            .get(&block_id)
555            .cloned()
556            .unwrap_or_default();
557        (runs, images)
558    };
559
560    let mut fragments = Vec::with_capacity(runs.len() + images.len() + 1);
561    let mut char_offset: usize = 0;
562    let mut byte_cursor: u32 = 0;
563    let mut img_iter = images.iter().peekable();
564
565    // Helper to push an unformatted text fragment for bytes [a..b).
566    // Returns the new char_offset and updates byte_cursor.
567    fn emit_default_text(
568        fragments: &mut Vec<FragmentContent>,
569        plain: &str,
570        block_id: u64,
571        byte_a: u32,
572        byte_b: u32,
573        char_offset: &mut usize,
574        byte_cursor: &mut u32,
575    ) {
576        if byte_a >= byte_b {
577            return;
578        }
579        let text = &plain[byte_a as usize..byte_b as usize];
580        let length = text.chars().count();
581        let word_starts = compute_word_starts(text);
582        fragments.push(FragmentContent::Text {
583            text: text.to_string(),
584            format: TextFormat::default(),
585            offset: *char_offset,
586            length,
587            element_id: synth_element_id(block_id, byte_a),
588            word_starts,
589        });
590        *char_offset += length;
591        *byte_cursor = byte_b;
592    }
593
594    // Helper to push a formatted text fragment for bytes [a..b) with the
595    // given run's format. Used both for whole runs and for the
596    // before-image / after-image slices when an image sits inside a run.
597    #[allow(clippy::too_many_arguments)]
598    fn emit_run_text(
599        fragments: &mut Vec<FragmentContent>,
600        plain: &str,
601        block_id: u64,
602        byte_a: u32,
603        byte_b: u32,
604        run_format: &frontend::common::format_runs::CharacterFormat,
605        char_offset: &mut usize,
606        byte_cursor: &mut u32,
607    ) {
608        if byte_a >= byte_b {
609            return;
610        }
611        let text = &plain[byte_a as usize..byte_b as usize];
612        let length = text.chars().count();
613        let word_starts = compute_word_starts(text);
614        fragments.push(FragmentContent::Text {
615            text: text.to_string(),
616            format: TextFormat::from(run_format),
617            offset: *char_offset,
618            length,
619            element_id: synth_element_id(block_id, byte_a),
620            word_starts,
621        });
622        *char_offset += length;
623        *byte_cursor = byte_b;
624    }
625
626    for run in &runs {
627        let mut run_cursor = run.byte_start;
628
629        // Emit images that fall strictly before this run, then handle
630        // images that fall inside the run by splitting it at each
631        // image's byte_offset.
632        while let Some(img) = img_iter.peek() {
633            if img.byte_offset < run.byte_start {
634                // Image before the run — emit unformatted gap text, then image.
635                emit_default_text(
636                    &mut fragments,
637                    plain,
638                    block_id,
639                    byte_cursor,
640                    img.byte_offset,
641                    &mut char_offset,
642                    &mut byte_cursor,
643                );
644                fragments.push(FragmentContent::Image {
645                    name: img.name.clone(),
646                    width: img.width as u32,
647                    height: img.height as u32,
648                    quality: img.quality as u32,
649                    format: TextFormat::from(&img.format),
650                    offset: char_offset,
651                    element_id: synth_element_id(block_id, img.byte_offset),
652                });
653                char_offset += 1;
654                img_iter.next();
655            } else if img.byte_offset <= run.byte_end {
656                // Image at the run's start or inside the run.
657                // First close any unformatted gap upstream of the run.
658                emit_default_text(
659                    &mut fragments,
660                    plain,
661                    block_id,
662                    byte_cursor,
663                    run_cursor,
664                    &mut char_offset,
665                    &mut byte_cursor,
666                );
667                // Emit the formatted text slice [run_cursor..img.byte_offset).
668                emit_run_text(
669                    &mut fragments,
670                    plain,
671                    block_id,
672                    run_cursor,
673                    img.byte_offset,
674                    &run.format,
675                    &mut char_offset,
676                    &mut byte_cursor,
677                );
678                // Emit the image itself.
679                fragments.push(FragmentContent::Image {
680                    name: img.name.clone(),
681                    width: img.width as u32,
682                    height: img.height as u32,
683                    quality: img.quality as u32,
684                    format: TextFormat::from(&img.format),
685                    offset: char_offset,
686                    element_id: synth_element_id(block_id, img.byte_offset),
687                });
688                char_offset += 1;
689                run_cursor = img.byte_offset;
690                byte_cursor = img.byte_offset;
691                img_iter.next();
692            } else {
693                break;
694            }
695        }
696
697        // Unformatted gap between byte_cursor and the run's start (if
698        // the run starts past where we last emitted).
699        emit_default_text(
700            &mut fragments,
701            plain,
702            block_id,
703            byte_cursor,
704            run_cursor,
705            &mut char_offset,
706            &mut byte_cursor,
707        );
708
709        // Emit the remaining tail of the run [run_cursor..run.byte_end).
710        emit_run_text(
711            &mut fragments,
712            plain,
713            block_id,
714            run_cursor,
715            run.byte_end,
716            &run.format,
717            &mut char_offset,
718            &mut byte_cursor,
719        );
720    }
721
722    // Any remaining images after the last run.
723    for img in img_iter {
724        emit_default_text(
725            &mut fragments,
726            plain,
727            block_id,
728            byte_cursor,
729            img.byte_offset,
730            &mut char_offset,
731            &mut byte_cursor,
732        );
733        fragments.push(FragmentContent::Image {
734            name: img.name.clone(),
735            width: img.width as u32,
736            height: img.height as u32,
737            quality: img.quality as u32,
738            format: TextFormat::from(&img.format),
739            offset: char_offset,
740            element_id: synth_element_id(block_id, img.byte_offset),
741        });
742        char_offset += 1;
743    }
744
745    // Trailing unformatted text after the last run / image.
746    emit_default_text(
747        &mut fragments,
748        plain,
749        block_id,
750        byte_cursor,
751        plain.len() as u32,
752        &mut char_offset,
753        &mut byte_cursor,
754    );
755
756    fragments
757}
758
759/// Compute character-index-based word starts for a text slice,
760/// following Unicode Standard Annex #29. Returned indices are
761/// positions within `text.chars()`, NOT byte offsets — matches
762/// AccessKit's `word_starts` contract where each entry is an index
763/// into `character_lengths`.
764fn compute_word_starts(text: &str) -> Vec<u8> {
765    use unicode_segmentation::UnicodeSegmentation;
766    let mut result = Vec::new();
767    // `unicode_word_indices` yields (byte_offset, word_slice) for each
768    // Unicode-word match. Convert each byte offset to a character
769    // index by counting `char_indices` up to that offset.
770    let mut byte_to_char: Vec<(usize, usize)> = Vec::new();
771    for (ci, (bi, _)) in text.char_indices().enumerate() {
772        byte_to_char.push((bi, ci));
773    }
774    for (byte_off, _word) in text.unicode_word_indices() {
775        let char_idx = byte_to_char
776            .iter()
777            .find(|(bi, _)| *bi == byte_off)
778            .map(|(_, ci)| *ci)
779            .unwrap_or(0);
780        // Saturating cast — text runs longer than 255 chars get their
781        // later word starts dropped. That's the AccessKit contract:
782        // `word_starts` is Box<[u8]>. Runs longer than ~255 chars are
783        // unusual for a single format run, and the first 255 word
784        // starts cover the viewport almost always. Documented in the
785        // plan.
786        if let Ok(idx) = u8::try_from(char_idx) {
787            result.push(idx);
788        } else {
789            break;
790        }
791    }
792    result
793}
794
795/// Compute 0-based index of a block within its list.
796fn compute_list_item_index(inner: &TextDocumentInner, list_id: EntityId, block_id: u64) -> usize {
797    let mut all_blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
798    let store = inner.ctx.db_context.get_store();
799    crate::inner::refresh_block_positions(&mut all_blocks, store);
800    let mut list_blocks: Vec<_> = all_blocks
801        .iter()
802        .filter(|b| b.list == Some(list_id))
803        .collect();
804    list_blocks.sort_by_key(|b| b.document_position);
805    list_blocks
806        .iter()
807        .position(|b| b.id == block_id)
808        .unwrap_or(0)
809}
810
811/// Format a list marker for the given item index.
812pub(crate) fn format_list_marker(
813    list_dto: &frontend::list::dtos::ListDto,
814    item_index: usize,
815) -> String {
816    let number = item_index + 1; // 1-based for display
817    let marker_body = match list_dto.style {
818        ListStyle::Disc => "\u{2022}".to_string(),   // •
819        ListStyle::Circle => "\u{25E6}".to_string(), // ◦
820        ListStyle::Square => "\u{25AA}".to_string(), // ▪
821        ListStyle::Decimal => format!("{number}"),
822        ListStyle::LowerAlpha => {
823            if number <= 26 {
824                ((b'a' + (number as u8 - 1)) as char).to_string()
825            } else {
826                format!("{number}")
827            }
828        }
829        ListStyle::UpperAlpha => {
830            if number <= 26 {
831                ((b'A' + (number as u8 - 1)) as char).to_string()
832            } else {
833                format!("{number}")
834            }
835        }
836        ListStyle::LowerRoman => to_roman_lower(number),
837        ListStyle::UpperRoman => to_roman_upper(number),
838    };
839    format!("{}{marker_body}{}", list_dto.prefix, list_dto.suffix)
840}
841
842fn to_roman_upper(mut n: usize) -> String {
843    const VALUES: &[(usize, &str)] = &[
844        (1000, "M"),
845        (900, "CM"),
846        (500, "D"),
847        (400, "CD"),
848        (100, "C"),
849        (90, "XC"),
850        (50, "L"),
851        (40, "XL"),
852        (10, "X"),
853        (9, "IX"),
854        (5, "V"),
855        (4, "IV"),
856        (1, "I"),
857    ];
858    let mut result = String::new();
859    for &(val, sym) in VALUES {
860        while n >= val {
861            result.push_str(sym);
862            n -= val;
863        }
864    }
865    result
866}
867
868fn to_roman_lower(n: usize) -> String {
869    to_roman_upper(n).to_lowercase()
870}
871
872/// Build a ListInfo for a block. Called while lock is held.
873fn build_list_info(
874    inner: &TextDocumentInner,
875    block_dto: &frontend::block::dtos::BlockDto,
876) -> Option<ListInfo> {
877    let list_id = block_dto.list?;
878    let list_dto = list_commands::get_list(&inner.ctx, &{ list_id })
879        .ok()
880        .flatten()?;
881
882    let item_index = compute_list_item_index(inner, list_id, block_dto.id);
883    let marker = format_list_marker(&list_dto, item_index);
884
885    Some(ListInfo {
886        list_id: list_id as usize,
887        style: list_dto.style.clone(),
888        indent: list_dto.indent as u8,
889        marker,
890        item_index,
891    })
892}
893
894/// Build a BlockSnapshot for a block. Called while lock is held.
895pub(crate) fn build_block_snapshot(
896    inner: &TextDocumentInner,
897    block_id: u64,
898    effective_kind: crate::highlight::HighlighterKind,
899) -> Option<BlockSnapshot> {
900    build_block_snapshot_with_position_and_parent(inner, block_id, None, None, effective_kind)
901}
902
903/// Build a BlockSnapshot, optionally overriding the position with a computed value.
904/// When `computed_position` is Some, it's used instead of `block_dto.document_position`
905/// (which may be stale if position updates are deferred).
906pub(crate) fn build_block_snapshot_with_position(
907    inner: &TextDocumentInner,
908    block_id: u64,
909    computed_position: Option<usize>,
910    effective_kind: crate::highlight::HighlighterKind,
911) -> Option<BlockSnapshot> {
912    build_block_snapshot_with_position_and_parent(
913        inner,
914        block_id,
915        computed_position,
916        None,
917        effective_kind,
918    )
919}
920
921/// Build a BlockSnapshot with an optional `parent_frame_hint`. When the
922/// caller already knows which frame owns the block (e.g. snapshot_flow's
923/// per-frame walk), passing it here skips the per-block `find_parent_frame`
924/// call — which would otherwise fetch every Frame in the store on every
925/// invocation. That walk was a major contributor to per-keystroke
926/// editor lag.
927pub(crate) fn build_block_snapshot_with_position_and_parent(
928    inner: &TextDocumentInner,
929    block_id: u64,
930    computed_position: Option<usize>,
931    parent_frame_hint: Option<EntityId>,
932    effective_kind: crate::highlight::HighlighterKind,
933) -> Option<BlockSnapshot> {
934    let mut block_dto = block_commands::get_block(&inner.ctx, &block_id)
935        .ok()
936        .flatten()?;
937    let store_for_pos = inner.ctx.db_context.get_store();
938    crate::inner::refresh_block_position(&mut block_dto, store_for_pos);
939
940    let mut block_format = BlockFormat::from(&block_dto);
941    // Inherit the document-wide default language when the block sets none,
942    // so hyphenation has a language for every block. The bridge still
943    // falls back to English if this is also unset.
944    if block_format.language.is_none() {
945        block_format.language = document_commands::get_document(&inner.ctx, &inner.document_id)
946            .ok()
947            .flatten()
948            .and_then(|d| d.default_language);
949    }
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}