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