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