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        // The fully-merged visual view: every session, regardless of the paint-vs-metric
313        // split the optimized path draws on.
314        let spans = crate::highlight::merged_spans_for_block(
315            &inner,
316            self.block_id,
317            &crate::highlight::HighlightMask::ALL,
318        );
319        if !spans.is_empty() {
320            return crate::highlight::merge_highlight_spans(fragments, &spans);
321        }
322        fragments
323    }
324
325    // ── List Membership ─────────────────────────────────────
326
327    /// List this block belongs to. O(1).
328    pub fn list(&self) -> Option<TextList> {
329        let inner = self.doc.lock();
330        let block_dto = block_commands::get_block(&inner.ctx, &(self.block_id as u64))
331            .ok()
332            .flatten()?;
333        let list_id = block_dto.list?;
334        Some(TextList {
335            doc: Arc::clone(&self.doc),
336            list_id: list_id as usize,
337        })
338    }
339
340    /// 0-based position within its list. **O(n)** where n = total blocks.
341    pub fn list_item_index(&self) -> Option<usize> {
342        let inner = self.doc.lock();
343        let block_dto = block_commands::get_block(&inner.ctx, &(self.block_id as u64))
344            .ok()
345            .flatten()?;
346        let list_id = block_dto.list?;
347        Some(compute_list_item_index(
348            &inner,
349            list_id,
350            self.block_id as u64,
351        ))
352    }
353
354    // ── Snapshot ─────────────────────────────────────────────
355
356    /// All layout-relevant data in one lock acquisition. O(k+n).
357    pub fn snapshot(&self) -> BlockSnapshot {
358        let inner = self.doc.lock();
359        build_block_snapshot(
360            &inner,
361            self.block_id as u64,
362            crate::highlight::SnapshotHighlights {
363                kind: inner.highlight_kind,
364                mask: &crate::highlight::HighlightMask::ALL,
365                suppress_paint: false,
366            },
367        )
368        .unwrap_or_else(|| BlockSnapshot {
369            block_id: self.block_id,
370            position: 0,
371            length: 0,
372            text: String::new(),
373            fragments: Vec::new(),
374            block_format: BlockFormat::default(),
375            list_info: None,
376            parent_frame_id: None,
377            table_cell: None,
378            paint_highlights: Vec::new(),
379        })
380    }
381}
382
383// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
384// Internal helpers (called while lock is held)
385// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
386
387/// Find the parent frame of a block by scanning all frames.
388pub(crate) fn find_parent_frame(inner: &TextDocumentInner, block_id: u64) -> Option<EntityId> {
389    let all_frames = frame_commands::get_all_frame(&inner.ctx).ok()?;
390    let block_entity_id = block_id as EntityId;
391    for frame in &all_frames {
392        if frame.blocks.contains(&block_entity_id) {
393            return Some(frame.id as EntityId);
394        }
395    }
396    None
397}
398
399/// O(1) fast check used by the snapshot hot path: returns true iff the
400/// store has zero table entities. Used to skip the expensive
401/// `find_table_cell_context` walks for documents that have no tables
402/// (e.g. typical markdown documents in an editor).
403fn document_has_no_tables(inner: &TextDocumentInner) -> bool {
404    inner.ctx.db_context.get_store().tables.read().is_empty()
405}
406
407/// Find table cell context for a block (snapshot-friendly, no live handles).
408/// Returns `None` if the block is not inside a table cell.
409fn find_table_cell_context(inner: &TextDocumentInner, block_id: u64) -> Option<TableCellContext> {
410    // Fast exit: a doc with no tables can't have any cell-bound blocks.
411    // Avoids per-block `get_all_frame` + `get_all_table` walks during
412    // snapshot_flow, which is called per editor pane on every keystroke.
413    if document_has_no_tables(inner) {
414        return None;
415    }
416    let frame_id = find_parent_frame(inner, block_id)?;
417
418    let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
419        .ok()
420        .flatten()?;
421
422    // Fast path: anchor frame with `table` field set
423    if let Some(table_entity_id) = frame_dto.table {
424        let table_dto =
425            frontend::commands::table_commands::get_table(&inner.ctx, &{ table_entity_id })
426                .ok()
427                .flatten()?;
428        for &cell_id in &table_dto.cells {
429            if let Some(cell_dto) =
430                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{ cell_id })
431                    .ok()
432                    .flatten()
433                && cell_dto.cell_frame == Some(frame_id)
434            {
435                return Some(TableCellContext {
436                    table_id: table_entity_id as usize,
437                    row: to_usize(cell_dto.row),
438                    column: to_usize(cell_dto.column),
439                });
440            }
441        }
442    }
443
444    // Slow path: scan all tables for a cell referencing this frame
445    let all_tables =
446        frontend::commands::table_commands::get_all_table(&inner.ctx).unwrap_or_default();
447    for table_dto in &all_tables {
448        for &cell_id in &table_dto.cells {
449            if let Some(cell_dto) =
450                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{ cell_id })
451                    .ok()
452                    .flatten()
453                && cell_dto.cell_frame == Some(frame_id)
454            {
455                return Some(TableCellContext {
456                    table_id: table_dto.id as usize,
457                    row: to_usize(cell_dto.row),
458                    column: to_usize(cell_dto.column),
459                });
460            }
461        }
462    }
463
464    None
465}
466
467/// Compute 0-indexed block number by scanning all blocks sorted by document_position.
468fn compute_block_number(inner: &TextDocumentInner, block_id: u64) -> usize {
469    let mut all_blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
470    let store = inner.ctx.db_context.get_store();
471    crate::inner::refresh_block_positions(&mut all_blocks, store);
472    let mut sorted: Vec<_> = all_blocks.iter().collect();
473    sorted.sort_by_key(|b| b.document_position);
474    sorted.iter().position(|b| b.id == block_id).unwrap_or(0)
475}
476
477/// Build fragments for a block from its format runs and image anchors,
478/// with highlight spans merged in when a syntax highlighter is attached.
479pub(crate) fn build_fragments(inner: &TextDocumentInner, block_id: u64) -> Vec<FragmentContent> {
480    build_fragments_with_text(
481        inner,
482        block_id,
483        None,
484        crate::highlight::SnapshotHighlights {
485            kind: inner.highlight_kind,
486            mask: &crate::highlight::HighlightMask::ALL,
487            suppress_paint: false,
488        },
489    )
490}
491
492/// Like `build_fragments` but accepts a pre-materialized block text to
493/// avoid the double `block_content_via_store` allocation when the
494/// caller (e.g. `build_block_snapshot_with_position_and_parent`)
495/// already has the text. Per-block snapshot cost halves for typing in
496/// a multi-block document.
497pub(crate) fn build_fragments_with_text(
498    inner: &TextDocumentInner,
499    block_id: u64,
500    prefetched_text: Option<&str>,
501    hl: crate::highlight::SnapshotHighlights,
502) -> Vec<FragmentContent> {
503    let fragments = build_raw_fragments(inner, block_id, prefetched_text);
504
505    // Only merge highlights into the shaping input when the effective kind is
506    // metric-affecting. Paint-only sessions keep fragments as BASE and carry their spans
507    // separately in `BlockSnapshot::paint_highlights`, so the engine can recolor without
508    // reshaping. A "without highlights" (empty-mask) snapshot resolves to `kind == None`,
509    // forcing base fragments regardless of the live sessions. See `HighlighterKind`.
510    if hl.kind == crate::highlight::HighlighterKind::Metric {
511        let spans = crate::highlight::merged_spans_for_block(inner, block_id as usize, hl.mask);
512        if !spans.is_empty() {
513            return crate::highlight::merge_highlight_spans(fragments, &spans);
514        }
515    }
516
517    fragments
518}
519
520/// Build raw fragments from the block's format_runs and block_images
521/// tables (Phase 1 of the rope migration). Reads the per-block plain_text
522/// from the Block DTO and uses the format-run byte ranges + image
523/// anchors to produce a stream of `FragmentContent::{Text, Image}`
524/// values in document order.
525///
526/// `element_id` is synthesized from (block_id, byte_start) via
527/// `synth_element_id`. Synthesized ids are stable for the same
528/// (block, byte_start) pair and never collide with real entity ids
529/// (top bit set).
530///
531/// Uncovered byte ranges between runs (or before the first run / after
532/// the last) emit Text fragments with `TextFormat::default()` — the
533/// "no character formatting" case.
534fn build_raw_fragments(
535    inner: &TextDocumentInner,
536    block_id: u64,
537    prefetched_text: Option<&str>,
538) -> Vec<FragmentContent> {
539    let _block_dto = match block_commands::get_block(&inner.ctx, &block_id)
540        .ok()
541        .flatten()
542    {
543        Some(b) => b,
544        None => return Vec::new(),
545    };
546
547    let plain_owned;
548    let plain: &str = match prefetched_text {
549        Some(t) => t,
550        None => {
551            let entity: common::entities::Block = _block_dto.clone().into();
552            plain_owned = common::database::rope_helpers::block_content_via_store(
553                &entity,
554                inner.ctx.db_context.get_store(),
555            );
556            &plain_owned
557        }
558    };
559
560    let (runs, images) = {
561        let store = inner.ctx.db_context.get_store();
562        let runs: Vec<FormatRun> = store
563            .format_runs
564            .read()
565            .get(&block_id)
566            .cloned()
567            .unwrap_or_default();
568        let images: Vec<ImageAnchor> = store
569            .block_images
570            .read()
571            .get(&block_id)
572            .cloned()
573            .unwrap_or_default();
574        (runs, images)
575    };
576
577    let mut fragments = Vec::with_capacity(runs.len() + images.len() + 1);
578    let mut char_offset: usize = 0;
579    let mut byte_cursor: u32 = 0;
580    let mut img_iter = images.iter().peekable();
581
582    // Helper to push an unformatted text fragment for bytes [a..b).
583    // Returns the new char_offset and updates byte_cursor.
584    fn emit_default_text(
585        fragments: &mut Vec<FragmentContent>,
586        plain: &str,
587        block_id: u64,
588        byte_a: u32,
589        byte_b: u32,
590        char_offset: &mut usize,
591        byte_cursor: &mut u32,
592    ) {
593        if byte_a >= byte_b {
594            return;
595        }
596        let text = &plain[byte_a as usize..byte_b as usize];
597        let length = text.chars().count();
598        let word_starts = compute_word_starts(text);
599        fragments.push(FragmentContent::Text {
600            text: text.to_string(),
601            format: TextFormat::default(),
602            offset: *char_offset,
603            length,
604            element_id: synth_element_id(block_id, byte_a),
605            word_starts,
606        });
607        *char_offset += length;
608        *byte_cursor = byte_b;
609    }
610
611    // Helper to push a formatted text fragment for bytes [a..b) with the
612    // given run's format. Used both for whole runs and for the
613    // before-image / after-image slices when an image sits inside a run.
614    #[allow(clippy::too_many_arguments)]
615    fn emit_run_text(
616        fragments: &mut Vec<FragmentContent>,
617        plain: &str,
618        block_id: u64,
619        byte_a: u32,
620        byte_b: u32,
621        run_format: &frontend::common::format_runs::CharacterFormat,
622        char_offset: &mut usize,
623        byte_cursor: &mut u32,
624    ) {
625        if byte_a >= byte_b {
626            return;
627        }
628        let text = &plain[byte_a as usize..byte_b as usize];
629        let length = text.chars().count();
630        let word_starts = compute_word_starts(text);
631        fragments.push(FragmentContent::Text {
632            text: text.to_string(),
633            format: TextFormat::from(run_format),
634            offset: *char_offset,
635            length,
636            element_id: synth_element_id(block_id, byte_a),
637            word_starts,
638        });
639        *char_offset += length;
640        *byte_cursor = byte_b;
641    }
642
643    for run in &runs {
644        let mut run_cursor = run.byte_start;
645
646        // Emit images that fall strictly before this run, then handle
647        // images that fall inside the run by splitting it at each
648        // image's byte_offset.
649        while let Some(img) = img_iter.peek() {
650            if img.byte_offset < run.byte_start {
651                // Image before the run — emit unformatted gap text, then image.
652                emit_default_text(
653                    &mut fragments,
654                    plain,
655                    block_id,
656                    byte_cursor,
657                    img.byte_offset,
658                    &mut char_offset,
659                    &mut byte_cursor,
660                );
661                fragments.push(FragmentContent::Image {
662                    name: img.name.clone(),
663                    width: img.width as u32,
664                    height: img.height as u32,
665                    quality: img.quality as u32,
666                    format: TextFormat::from(&img.format),
667                    offset: char_offset,
668                    element_id: synth_element_id(block_id, img.byte_offset),
669                });
670                char_offset += 1;
671                img_iter.next();
672            } else if img.byte_offset <= run.byte_end {
673                // Image at the run's start or inside the run.
674                // First close any unformatted gap upstream of the run.
675                emit_default_text(
676                    &mut fragments,
677                    plain,
678                    block_id,
679                    byte_cursor,
680                    run_cursor,
681                    &mut char_offset,
682                    &mut byte_cursor,
683                );
684                // Emit the formatted text slice [run_cursor..img.byte_offset).
685                emit_run_text(
686                    &mut fragments,
687                    plain,
688                    block_id,
689                    run_cursor,
690                    img.byte_offset,
691                    &run.format,
692                    &mut char_offset,
693                    &mut byte_cursor,
694                );
695                // Emit the image itself.
696                fragments.push(FragmentContent::Image {
697                    name: img.name.clone(),
698                    width: img.width as u32,
699                    height: img.height as u32,
700                    quality: img.quality as u32,
701                    format: TextFormat::from(&img.format),
702                    offset: char_offset,
703                    element_id: synth_element_id(block_id, img.byte_offset),
704                });
705                char_offset += 1;
706                run_cursor = img.byte_offset;
707                byte_cursor = img.byte_offset;
708                img_iter.next();
709            } else {
710                break;
711            }
712        }
713
714        // Unformatted gap between byte_cursor and the run's start (if
715        // the run starts past where we last emitted).
716        emit_default_text(
717            &mut fragments,
718            plain,
719            block_id,
720            byte_cursor,
721            run_cursor,
722            &mut char_offset,
723            &mut byte_cursor,
724        );
725
726        // Emit the remaining tail of the run [run_cursor..run.byte_end).
727        emit_run_text(
728            &mut fragments,
729            plain,
730            block_id,
731            run_cursor,
732            run.byte_end,
733            &run.format,
734            &mut char_offset,
735            &mut byte_cursor,
736        );
737    }
738
739    // Any remaining images after the last run.
740    for img in img_iter {
741        emit_default_text(
742            &mut fragments,
743            plain,
744            block_id,
745            byte_cursor,
746            img.byte_offset,
747            &mut char_offset,
748            &mut byte_cursor,
749        );
750        fragments.push(FragmentContent::Image {
751            name: img.name.clone(),
752            width: img.width as u32,
753            height: img.height as u32,
754            quality: img.quality as u32,
755            format: TextFormat::from(&img.format),
756            offset: char_offset,
757            element_id: synth_element_id(block_id, img.byte_offset),
758        });
759        char_offset += 1;
760    }
761
762    // Trailing unformatted text after the last run / image.
763    emit_default_text(
764        &mut fragments,
765        plain,
766        block_id,
767        byte_cursor,
768        plain.len() as u32,
769        &mut char_offset,
770        &mut byte_cursor,
771    );
772
773    fragments
774}
775
776/// Compute character-index-based word starts for a text slice,
777/// following Unicode Standard Annex #29. Returned indices are
778/// positions within `text.chars()`, NOT byte offsets — matches
779/// AccessKit's `word_starts` contract where each entry is an index
780/// into `character_lengths`.
781fn compute_word_starts(text: &str) -> Vec<u8> {
782    use unicode_segmentation::UnicodeSegmentation;
783    let mut result = Vec::new();
784    // `unicode_word_indices` yields (byte_offset, word_slice) for each
785    // Unicode-word match. Convert each byte offset to a character
786    // index by counting `char_indices` up to that offset.
787    let mut byte_to_char: Vec<(usize, usize)> = Vec::new();
788    for (ci, (bi, _)) in text.char_indices().enumerate() {
789        byte_to_char.push((bi, ci));
790    }
791    for (byte_off, _word) in text.unicode_word_indices() {
792        let char_idx = byte_to_char
793            .iter()
794            .find(|(bi, _)| *bi == byte_off)
795            .map(|(_, ci)| *ci)
796            .unwrap_or(0);
797        // Saturating cast — text runs longer than 255 chars get their
798        // later word starts dropped. That's the AccessKit contract:
799        // `word_starts` is Box<[u8]>. Runs longer than ~255 chars are
800        // unusual for a single format run, and the first 255 word
801        // starts cover the viewport almost always. Documented in the
802        // plan.
803        if let Ok(idx) = u8::try_from(char_idx) {
804            result.push(idx);
805        } else {
806            break;
807        }
808    }
809    result
810}
811
812/// Compute 0-based index of a block within its list.
813fn compute_list_item_index(inner: &TextDocumentInner, list_id: EntityId, block_id: u64) -> usize {
814    let mut all_blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
815    let store = inner.ctx.db_context.get_store();
816    crate::inner::refresh_block_positions(&mut all_blocks, store);
817    let mut list_blocks: Vec<_> = all_blocks
818        .iter()
819        .filter(|b| b.list == Some(list_id))
820        .collect();
821    list_blocks.sort_by_key(|b| b.document_position);
822    list_blocks
823        .iter()
824        .position(|b| b.id == block_id)
825        .unwrap_or(0)
826}
827
828/// Format a list marker for the given item index.
829pub(crate) fn format_list_marker(
830    list_dto: &frontend::list::dtos::ListDto,
831    item_index: usize,
832) -> String {
833    let number = item_index + 1; // 1-based for display
834    let marker_body = match list_dto.style {
835        ListStyle::Disc => "\u{2022}".to_string(),   // •
836        ListStyle::Circle => "\u{25E6}".to_string(), // ◦
837        ListStyle::Square => "\u{25AA}".to_string(), // ▪
838        ListStyle::Decimal => format!("{number}"),
839        ListStyle::LowerAlpha => {
840            if number <= 26 {
841                ((b'a' + (number as u8 - 1)) as char).to_string()
842            } else {
843                format!("{number}")
844            }
845        }
846        ListStyle::UpperAlpha => {
847            if number <= 26 {
848                ((b'A' + (number as u8 - 1)) as char).to_string()
849            } else {
850                format!("{number}")
851            }
852        }
853        ListStyle::LowerRoman => to_roman_lower(number),
854        ListStyle::UpperRoman => to_roman_upper(number),
855    };
856    format!("{}{marker_body}{}", list_dto.prefix, list_dto.suffix)
857}
858
859fn to_roman_upper(mut n: usize) -> String {
860    const VALUES: &[(usize, &str)] = &[
861        (1000, "M"),
862        (900, "CM"),
863        (500, "D"),
864        (400, "CD"),
865        (100, "C"),
866        (90, "XC"),
867        (50, "L"),
868        (40, "XL"),
869        (10, "X"),
870        (9, "IX"),
871        (5, "V"),
872        (4, "IV"),
873        (1, "I"),
874    ];
875    let mut result = String::new();
876    for &(val, sym) in VALUES {
877        while n >= val {
878            result.push_str(sym);
879            n -= val;
880        }
881    }
882    result
883}
884
885fn to_roman_lower(n: usize) -> String {
886    to_roman_upper(n).to_lowercase()
887}
888
889/// Build a ListInfo for a block. Called while lock is held.
890fn build_list_info(
891    inner: &TextDocumentInner,
892    block_dto: &frontend::block::dtos::BlockDto,
893) -> Option<ListInfo> {
894    let list_id = block_dto.list?;
895    let list_dto = list_commands::get_list(&inner.ctx, &{ list_id })
896        .ok()
897        .flatten()?;
898
899    let item_index = compute_list_item_index(inner, list_id, block_dto.id);
900    let marker = format_list_marker(&list_dto, item_index);
901
902    Some(ListInfo {
903        list_id: list_id as usize,
904        style: list_dto.style.clone(),
905        indent: list_dto.indent as u8,
906        marker,
907        item_index,
908    })
909}
910
911/// Build a BlockSnapshot for a block. Called while lock is held.
912pub(crate) fn build_block_snapshot(
913    inner: &TextDocumentInner,
914    block_id: u64,
915    hl: crate::highlight::SnapshotHighlights,
916) -> Option<BlockSnapshot> {
917    build_block_snapshot_with_position_and_parent(inner, block_id, None, None, hl)
918}
919
920/// Build a BlockSnapshot, optionally overriding the position with a computed value.
921/// When `computed_position` is Some, it's used instead of `block_dto.document_position`
922/// (which may be stale if position updates are deferred).
923pub(crate) fn build_block_snapshot_with_position(
924    inner: &TextDocumentInner,
925    block_id: u64,
926    computed_position: Option<usize>,
927    hl: crate::highlight::SnapshotHighlights,
928) -> Option<BlockSnapshot> {
929    build_block_snapshot_with_position_and_parent(inner, block_id, computed_position, None, hl)
930}
931
932/// Build a BlockSnapshot with an optional `parent_frame_hint`. When the
933/// caller already knows which frame owns the block (e.g. snapshot_flow's
934/// per-frame walk), passing it here skips the per-block `find_parent_frame`
935/// call — which would otherwise fetch every Frame in the store on every
936/// invocation. That walk was a major contributor to per-keystroke
937/// editor lag.
938pub(crate) fn build_block_snapshot_with_position_and_parent(
939    inner: &TextDocumentInner,
940    block_id: u64,
941    computed_position: Option<usize>,
942    parent_frame_hint: Option<EntityId>,
943    hl: crate::highlight::SnapshotHighlights,
944) -> Option<BlockSnapshot> {
945    let mut block_dto = block_commands::get_block(&inner.ctx, &block_id)
946        .ok()
947        .flatten()?;
948    let store_for_pos = inner.ctx.db_context.get_store();
949    crate::inner::refresh_block_position(&mut block_dto, store_for_pos);
950
951    let mut block_format = BlockFormat::from(&block_dto);
952    // Inherit the document-wide default language when the block sets none,
953    // so hyphenation has a language for every block. The bridge still
954    // falls back to English if this is also unset.
955    if block_format.language.is_none() {
956        block_format.language = document_commands::get_document(&inner.ctx, &inner.document_id)
957            .ok()
958            .flatten()
959            .and_then(|d| d.default_language);
960    }
961    let list_info = build_list_info(inner, &block_dto);
962
963    let parent_frame_id = parent_frame_hint
964        .or_else(|| find_parent_frame(inner, block_id))
965        .map(|id| id as usize);
966    let table_cell = find_table_cell_context(inner, block_id);
967
968    // The flow-snapshot position MUST agree with the space the editing path
969    // resolves cursor positions against. When the rope mirrors every block
970    // (now true even with tables, since cell content is mirrored inline), the
971    // rope is the single source of truth: its char order — including the
972    // 1-char table-anchor sentinel — is what `find_block_at_char_position`
973    // uses. So derive `position` from the rope-refreshed `document_position`
974    // (set above) rather than the caller's running counter, which omits the
975    // sentinel and would drift past every table. Only when the rope is NOT
976    // authoritative (programmatically-inserted sub-frames whose blocks aren't
977    // mirrored) do we fall back to the caller's computed running position.
978    let position = if common::database::rope_helpers::rope_positions_match_flow(store_for_pos) {
979        to_usize(block_dto.document_position)
980    } else {
981        computed_position.unwrap_or_else(|| to_usize(block_dto.document_position))
982    };
983
984    // Materialize the block text once and pass it to build_fragments
985    // and into the snapshot's `text` field — saves one redundant rope
986    // slice + String allocation per block per snapshot_flow call.
987    let entity: common::entities::Block = block_dto.clone().into();
988    let store = inner.ctx.db_context.get_store();
989    let text = common::database::rope_helpers::block_content_via_store(&entity, store);
990    let length = to_usize(common::database::rope_helpers::block_char_length(
991        &entity, store,
992    ));
993    let fragments = build_fragments_with_text(inner, block_id, Some(&text), hl);
994
995    // Paint-only sessions: emit the merged spans as a separate overlay (fragments stayed base
996    // above). Metric / none: empty (highlights merged into fragments, or none). A "without
997    // highlights" (empty-mask) snapshot resolves to `kind == None`, so this is empty
998    // regardless of the live sessions.
999    let paint_highlights =
1000        if hl.kind == crate::highlight::HighlighterKind::PaintOnly && !hl.suppress_paint {
1001            let spans = crate::highlight::merged_spans_for_block(inner, block_id as usize, hl.mask);
1002            crate::highlight::extract_paint_spans(&spans, length)
1003        } else {
1004            Vec::new()
1005        };
1006
1007    Some(BlockSnapshot {
1008        block_id: block_id as usize,
1009        position,
1010        length,
1011        text,
1012        fragments,
1013        block_format,
1014        list_info,
1015        parent_frame_id,
1016        table_cell,
1017        paint_highlights,
1018    })
1019}
1020
1021/// Build BlockSnapshots for all blocks in a frame, sorted by document_position.
1022pub(crate) fn build_blocks_snapshot_for_frame(
1023    inner: &TextDocumentInner,
1024    frame_id: u64,
1025    hl: crate::highlight::SnapshotHighlights,
1026) -> Vec<BlockSnapshot> {
1027    let frame_dto = match frame_commands::get_frame(&inner.ctx, &(frame_id as EntityId))
1028        .ok()
1029        .flatten()
1030    {
1031        Some(f) => f,
1032        None => return Vec::new(),
1033    };
1034
1035    let mut block_dtos: Vec<_> = frame_dto
1036        .blocks
1037        .iter()
1038        .filter_map(|&id| {
1039            block_commands::get_block(&inner.ctx, &{ id })
1040                .ok()
1041                .flatten()
1042        })
1043        .collect();
1044    let store = inner.ctx.db_context.get_store();
1045    crate::inner::refresh_block_positions(&mut block_dtos, store);
1046    block_dtos.sort_by_key(|b| b.document_position);
1047
1048    block_dtos
1049        .iter()
1050        .filter_map(|b| build_block_snapshot(inner, b.id, hl))
1051        .collect()
1052}
1053
1054/// Build BlockSnapshots with computed positions starting from `start_pos`.
1055///
1056/// Returns `(snapshots, running_pos_after_last_block)`.
1057/// Positions are computed sequentially from `start_pos` using each block's
1058/// `text_length`, matching the logic in `find_block_at_position_sequential`.
1059pub(crate) fn build_blocks_snapshot_for_frame_with_positions(
1060    inner: &TextDocumentInner,
1061    frame_id: u64,
1062    start_pos: usize,
1063    hl: crate::highlight::SnapshotHighlights,
1064) -> (Vec<BlockSnapshot>, usize) {
1065    let frame_dto = match frame_commands::get_frame(&inner.ctx, &(frame_id as EntityId))
1066        .ok()
1067        .flatten()
1068    {
1069        Some(f) => f,
1070        None => return (Vec::new(), start_pos),
1071    };
1072
1073    let mut block_dtos: Vec<_> = frame_dto
1074        .blocks
1075        .iter()
1076        .filter_map(|&id| {
1077            block_commands::get_block(&inner.ctx, &{ id })
1078                .ok()
1079                .flatten()
1080        })
1081        .collect();
1082    let store = inner.ctx.db_context.get_store();
1083    crate::inner::refresh_block_positions(&mut block_dtos, store);
1084    block_dtos.sort_by_key(|b| b.document_position);
1085
1086    let mut running_pos = start_pos;
1087    let mut snapshots = Vec::with_capacity(block_dtos.len());
1088    for b in &block_dtos {
1089        if let Some(snap) = build_block_snapshot_with_position(inner, b.id, Some(running_pos), hl) {
1090            running_pos += snap.length + 1; // +1 for block separator
1091            snapshots.push(snap);
1092        }
1093    }
1094    (snapshots, running_pos)
1095}