Skip to main content

text_document/
cursor.rs

1//! TextCursor implementation — Qt-style multi-cursor with automatic position adjustment.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use crate::{DocumentError, Result};
8
9use crate::ListStyle;
10use frontend::commands::{
11    document_editing_commands, document_formatting_commands, document_inspection_commands,
12    undo_redo_commands,
13};
14
15use unicode_segmentation::UnicodeSegmentation;
16
17use crate::convert::{to_i64, to_usize};
18use crate::events::{DocumentEvent, InsertionOrigin};
19use crate::flow::{CellRange, FlowElement, FrameRef, SelectionKind, TableCellRef};
20use crate::fragment::DocumentFragment;
21use crate::inner::{CursorData, QueuedEvents, TextDocumentInner};
22use crate::link_extent::LinkExtent;
23use crate::text_block::TextBlock;
24use crate::text_table::TextTable;
25use crate::{BlockFormat, FrameFormat, MoveMode, MoveOperation, SelectionType, TextFormat};
26
27use crate::document::get_main_frame_id;
28
29/// Compute the maximum valid cursor position from document stats.
30///
31/// Cursor positions include block separators (one between each pair of adjacent
32/// blocks), but `character_count` does not. The max position is therefore
33/// `character_count + (block_count - 1)`.
34fn max_cursor_position(stats: &frontend::document_inspection::DocumentStatsDto) -> usize {
35    let chars = to_usize(stats.character_count);
36    let blocks = to_usize(stats.block_count);
37    if blocks > 1 {
38        chars + blocks - 1
39    } else {
40        chars
41    }
42}
43
44/// A cursor into a [`TextDocument`](crate::TextDocument).
45///
46/// Multiple cursors can coexist on the same document (like Qt's `QTextCursor`).
47/// When any cursor edits text, all other cursors' positions are automatically
48/// adjusted by the document.
49///
50/// Cloning a cursor creates an **independent** cursor at the same position.
51pub struct TextCursor {
52    pub(crate) doc: Arc<Mutex<TextDocumentInner>>,
53    pub(crate) data: Arc<Mutex<CursorData>>,
54}
55
56impl Clone for TextCursor {
57    fn clone(&self) -> Self {
58        let (position, anchor, content_locale) = {
59            let d = self.data.lock();
60            (d.position, d.anchor, d.content_locale.clone())
61        };
62        let data = {
63            let mut inner = self.doc.lock();
64            let data = Arc::new(Mutex::new(CursorData {
65                position,
66                anchor,
67                cell_selection_override: None,
68                // A clone reads the same text as its original, so it inherits the language.
69                content_locale,
70            }));
71            inner.cursors.push(Arc::downgrade(&data));
72            data
73        };
74        TextCursor {
75            doc: self.doc.clone(),
76            data,
77        }
78    }
79}
80
81impl TextCursor {
82    // ── Helpers (called while doc lock is NOT held) ──────────
83
84    fn read_cursor(&self) -> (usize, usize) {
85        let d = self.data.lock();
86        (d.position, d.anchor)
87    }
88
89    /// Common post-edit bookkeeping: adjust all cursors, set this cursor to
90    /// `new_pos`, mark modified, invalidate text cache, queue a
91    /// `ContentsChanged` event, and return the queued events for dispatch.
92    fn finish_edit(
93        &self,
94        inner: &mut TextDocumentInner,
95        edit_pos: usize,
96        removed: usize,
97        new_pos: usize,
98        blocks_affected: usize,
99    ) -> QueuedEvents {
100        self.finish_edit_ext(inner, edit_pos, removed, new_pos, blocks_affected, true)
101    }
102
103    fn finish_edit_ext(
104        &self,
105        inner: &mut TextDocumentInner,
106        edit_pos: usize,
107        removed: usize,
108        new_pos: usize,
109        blocks_affected: usize,
110        flow_may_change: bool,
111    ) -> QueuedEvents {
112        self.finish_edit_from(
113            inner,
114            edit_pos,
115            removed,
116            new_pos,
117            blocks_affected,
118            flow_may_change,
119            InsertionOrigin::Unspecified,
120        )
121    }
122
123    /// As [`finish_edit_ext`](Self::finish_edit_ext), and additionally reports
124    /// which channel the text arrived through.
125    ///
126    /// ⚠ **The one place an origin is turned into an event.** Every insertion
127    /// method reaches this, so the origin travels through one path rather than
128    /// through each method's own idea of what it did — which is what keeps
129    /// `TextInserted` and `ContentsChanged` describing the same edit instead of
130    /// two edits that happen to coincide.
131    #[allow(clippy::too_many_arguments)]
132    fn finish_edit_from(
133        &self,
134        inner: &mut TextDocumentInner,
135        edit_pos: usize,
136        removed: usize,
137        new_pos: usize,
138        blocks_affected: usize,
139        flow_may_change: bool,
140        origin: InsertionOrigin,
141    ) -> QueuedEvents {
142        // Defensive: a use case can return new_position < edit_pos when
143        // invoked through a stale or out-of-range cursor (e.g. after an
144        // undo restores a state where the previously-saved cursor position
145        // is no longer valid — fuzz finds this). Treat the edit as adding
146        // 0 chars rather than overflowing; the cursor still moves to
147        // `new_pos` below.
148        let added = new_pos.saturating_sub(edit_pos);
149        inner.adjust_cursors(edit_pos, removed, added);
150        {
151            let mut d = self.data.lock();
152            d.position = new_pos;
153            d.anchor = new_pos;
154        }
155        inner.modified = true;
156        inner.invalidate_text_cache();
157        inner.rehighlight_affected(edit_pos);
158        inner.queue_event(DocumentEvent::ContentsChanged {
159            position: edit_pos,
160            chars_removed: removed,
161            chars_added: added,
162            blocks_affected,
163        });
164        // Only when something was actually inserted. An edit that only deletes
165        // has no origin to report, and emitting one with `chars_inserted: 0`
166        // would put a channel's name on text that never arrived.
167        if added > 0 {
168            inner.queue_event(DocumentEvent::TextInserted {
169                position: edit_pos,
170                chars_inserted: added,
171                origin,
172            });
173        }
174        inner.check_block_count_changed();
175        if flow_may_change {
176            inner.check_flow_changed();
177        }
178        self.queue_undo_redo_event(inner)
179    }
180
181    // ── Position & selection ─────────────────────────────────
182
183    /// Current cursor position (between characters).
184    pub fn position(&self) -> usize {
185        self.data.lock().position
186    }
187
188    /// Anchor position. Equal to `position()` when no selection.
189    pub fn anchor(&self) -> usize {
190        self.data.lock().anchor
191    }
192
193    /// Returns true if there is a selection.
194    pub fn has_selection(&self) -> bool {
195        let d = self.data.lock();
196        d.position != d.anchor
197    }
198
199    /// Start of the selection (min of position and anchor).
200    pub fn selection_start(&self) -> usize {
201        let d = self.data.lock();
202        d.position.min(d.anchor)
203    }
204
205    /// End of the selection (max of position and anchor).
206    pub fn selection_end(&self) -> usize {
207        let d = self.data.lock();
208        d.position.max(d.anchor)
209    }
210
211    /// Get the selected text. Returns empty string if no selection.
212    pub fn selected_text(&self) -> Result<String> {
213        let (pos, anchor) = self.read_cursor();
214        if pos == anchor {
215            return Ok(String::new());
216        }
217        let start = pos.min(anchor);
218        let len = pos.max(anchor) - start;
219        let inner = self.doc.lock();
220        let dto = frontend::document_inspection::GetTextAtPositionDto {
221            position: to_i64(start),
222            length: to_i64(len),
223        };
224        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
225        Ok(result.text)
226    }
227
228    /// Up to `max_len` characters of plain text immediately preceding the cursor position,
229    /// bounded and read directly from the store — the same fast block lookup
230    /// [`char_format`](Self::char_format) uses, never materializing the whole document the
231    /// way [`selected_text`](Self::selected_text)'s
232    /// `document_inspection_commands::get_text_at_position` does internally. Block
233    /// boundaries appear as `'\n'`. Returns fewer than `max_len` characters near the start of
234    /// the document.
235    ///
236    /// Falls back to the (correct, but O(document size)) slow path for documents containing
237    /// tables or unmirrored sub-frames, where the fast rope-index lookup doesn't apply — see
238    /// [`common::database::rope_helpers::find_block_at_char_position`]'s own doc comment for
239    /// exactly which documents that is.
240    pub fn text_before(&self, max_len: usize) -> Result<String> {
241        if max_len == 0 {
242            return Ok(String::new());
243        }
244        let pos = self.position();
245        let inner = self.doc.lock();
246        let store = inner.ctx.db_context.get_store();
247
248        // One O(log n) probe decides whether the fast path even applies to this document —
249        // same gate `find_block_at_char_position` itself uses (tables / unmirrored
250        // sub-frames disqualify it). Cheaper to check once up front than to discover it mid
251        // walk.
252        if pos > 0
253            && common::database::rope_helpers::find_block_at_char_position(store, 0).is_none()
254        {
255            let dto = frontend::document_inspection::GetTextAtPositionDto {
256                position: 0,
257                length: to_i64(pos),
258            };
259            let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
260            let full = result.text;
261            let total = full.chars().count();
262            let skip = total.saturating_sub(max_len);
263            return Ok(full.chars().skip(skip).collect());
264        }
265
266        let mut pieces: Vec<String> = Vec::new();
267        let mut remaining = max_len;
268        let mut end_pos = pos;
269
270        while remaining > 0 && end_pos > 0 {
271            let query = (end_pos - 1) as i64;
272            // `find_block_at_char_position` returns the *previous*-block answer at a
273            // boundary (char_in_block == the block's own length), which is exactly the
274            // convention this backward walk needs — unlike `get_block_at_position`'s command,
275            // which deliberately advances to the *next* block at a boundary for its own
276            // (forward/click-mapping) callers.
277            let Some((block_id, char_in_block, block_char_start)) =
278                common::database::rope_helpers::find_block_at_char_position(store, query)
279            else {
280                // A table or sub-frame appeared partway through the walk (shouldn't happen
281                // given the up-front check above, but the primitive's contract only promises
282                // this per-call, not for the whole document) — stop rather than guess.
283                break;
284            };
285            let block_dto = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
286                .ok_or_else(|| DocumentError::NotFound("block not found".into()))?;
287            let entity: common::entities::Block = block_dto.into();
288            let block_text =
289                common::database::rope_helpers::block_content_via_store(&entity, store);
290            let block_len = block_text.chars().count() as i64;
291            let block_char_start = block_char_start as usize;
292
293            if char_in_block == block_len {
294                // `query` landed exactly on this (non-empty) block's own trailing separator.
295                pieces.push("\n".to_string());
296                remaining -= 1;
297                if remaining == 0 {
298                    break;
299                }
300                let take = remaining.min(block_len as usize);
301                let local_start = block_len as usize - take;
302                let slice: String = block_text.chars().skip(local_start).take(take).collect();
303                pieces.push(slice);
304                remaining -= take;
305                end_pos = block_char_start + local_start;
306            } else {
307                // `query` is a real character at local index `char_in_block`.
308                let available = char_in_block as usize + 1;
309                let take = remaining.min(available);
310                let local_start = available - take;
311                let slice: String = block_text.chars().skip(local_start).take(take).collect();
312                pieces.push(slice);
313                remaining -= take;
314                end_pos = block_char_start + local_start;
315            }
316        }
317
318        pieces.reverse();
319        Ok(pieces.concat())
320    }
321
322    /// Collapse the selection by moving anchor to position.
323    pub fn clear_selection(&self) {
324        let mut d = self.data.lock();
325        d.anchor = d.position;
326    }
327
328    // ── Boundary queries ─────────────────────────────────────
329
330    /// True if the cursor is at the start of a block.
331    pub fn at_block_start(&self) -> bool {
332        let pos = self.position();
333        let inner = self.doc.lock();
334        let dto = frontend::document_inspection::GetBlockAtPositionDto {
335            position: to_i64(pos),
336        };
337        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
338            pos == to_usize(info.block_start)
339        } else {
340            false
341        }
342    }
343
344    /// True if the cursor is at the end of a block.
345    pub fn at_block_end(&self) -> bool {
346        let pos = self.position();
347        let inner = self.doc.lock();
348        let dto = frontend::document_inspection::GetBlockAtPositionDto {
349            position: to_i64(pos),
350        };
351        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
352            pos == to_usize(info.block_start) + to_usize(info.block_length)
353        } else {
354            false
355        }
356    }
357
358    /// True if the cursor is at position 0.
359    pub fn at_start(&self) -> bool {
360        self.data.lock().position == 0
361    }
362
363    /// True if the cursor is at the very end of the document.
364    pub fn at_end(&self) -> bool {
365        let pos = self.position();
366        let inner = self.doc.lock();
367        let stats = document_inspection_commands::get_document_stats(&inner.ctx).unwrap_or({
368            frontend::document_inspection::DocumentStatsDto {
369                character_count: 0,
370                word_count: 0,
371                block_count: 0,
372                frame_count: 0,
373                image_count: 0,
374                list_count: 0,
375                table_count: 0,
376            }
377        });
378        pos >= max_cursor_position(&stats)
379    }
380
381    /// The block number (0-indexed) containing the cursor.
382    pub fn block_number(&self) -> usize {
383        let pos = self.position();
384        let inner = self.doc.lock();
385        let dto = frontend::document_inspection::GetBlockAtPositionDto {
386            position: to_i64(pos),
387        };
388        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
389            .map(|info| to_usize(info.block_number))
390            .unwrap_or(0)
391    }
392
393    /// The cursor's column within the current block (0-indexed).
394    pub fn position_in_block(&self) -> usize {
395        let pos = self.position();
396        let inner = self.doc.lock();
397        let dto = frontend::document_inspection::GetBlockAtPositionDto {
398            position: to_i64(pos),
399        };
400        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
401            .map(|info| pos.saturating_sub(to_usize(info.block_start)))
402            .unwrap_or(0)
403    }
404
405    // ── Movement ─────────────────────────────────────────────
406
407    /// Set the cursor to an absolute position.
408    ///
409    /// When extending a selection (`KeepAnchor`) across a table boundary,
410    /// the position is snapped to the adjacent block outside the table so
411    /// the entire table is "trapped" inside the selection range. This
412    /// mirrors LibreOffice's behaviour: partial table selections from
413    /// outside are not allowed; the table is always fully enclosed.
414    ///
415    /// The snap is skipped when:
416    /// - `mode` is `MoveAnchor` (plain click / move without selection)
417    /// - No adjacent block exists (table is first or last in the document)
418    pub fn set_position(&self, position: usize, mode: MoveMode) {
419        // Clamp to max document position (includes block separators)
420        let end = {
421            let inner = self.doc.lock();
422            document_inspection_commands::get_document_stats(&inner.ctx)
423                .map(|s| max_cursor_position(&s))
424                .unwrap_or(0)
425        };
426        let mut pos = position.min(end);
427
428        // Table-trap snap: when extending a selection, if one endpoint is
429        // inside a table and the other is outside, relocate the inside
430        // endpoint to the boundary of the adjacent block.
431        if mode == MoveMode::KeepAnchor {
432            let anchor = self.data.lock().anchor;
433            let pos_cell = self.table_cell_at(pos);
434            let anchor_cell = self.table_cell_at(anchor);
435            match (&pos_cell, &anchor_cell) {
436                (Some(tc), None) => {
437                    // Position is inside a table, anchor is outside.
438                    let before = anchor < pos;
439                    if let Some(boundary) = self.table_boundary_position(tc.table.id(), !before) {
440                        pos = boundary;
441                    }
442                }
443                (None, Some(tc)) => {
444                    // Anchor is inside a table, position is outside.
445                    // Snap the position so the table is enclosed.
446                    let before = pos < anchor;
447                    if let Some(boundary) = self.table_boundary_position(tc.table.id(), !before) {
448                        pos = boundary;
449                    }
450                }
451                _ => {}
452            }
453        }
454
455        {
456            let mut d = self.data.lock();
457            d.position = pos;
458            if mode == MoveMode::MoveAnchor {
459                d.anchor = pos;
460            }
461            d.cell_selection_override = None;
462        }
463        // Snap forward to the nearest grapheme cluster boundary so
464        // a caller passing an arbitrary scalar index (e.g. computed
465        // from a hit-test or a plain-text search) never leaves the
466        // cursor inside a multi-scalar grapheme cluster.
467        self.snap_position_to_grapheme_boundary();
468    }
469
470    /// Move the cursor by a semantic operation.
471    ///
472    /// `n` is used as a repeat count for character-level movements
473    /// (`NextCharacter`, `PreviousCharacter`, `Left`, `Right`).
474    /// For all other operations it is ignored. Returns `true` if the cursor moved.
475    pub fn move_position(&self, operation: MoveOperation, mode: MoveMode, n: usize) -> bool {
476        let old_pos = self.position();
477        let target = self.resolve_move(operation, n);
478        self.set_position(target, mode);
479        self.position() != old_pos
480    }
481
482    /// Select a region relative to the cursor position.
483    pub fn select(&self, selection: SelectionType) {
484        match selection {
485            SelectionType::Document => {
486                let end = {
487                    let inner = self.doc.lock();
488                    document_inspection_commands::get_document_stats(&inner.ctx)
489                        .map(|s| max_cursor_position(&s))
490                        .unwrap_or(0)
491                };
492                let mut d = self.data.lock();
493                d.anchor = 0;
494                d.position = end;
495                d.cell_selection_override = None;
496            }
497            SelectionType::BlockUnderCursor | SelectionType::LineUnderCursor => {
498                let pos = self.position();
499                let inner = self.doc.lock();
500                let dto = frontend::document_inspection::GetBlockAtPositionDto {
501                    position: to_i64(pos),
502                };
503                if let Ok(info) =
504                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
505                {
506                    let start = to_usize(info.block_start);
507                    let end = start + to_usize(info.block_length);
508                    drop(inner);
509                    let mut d = self.data.lock();
510                    d.anchor = start;
511                    d.position = end;
512                    d.cell_selection_override = None;
513                }
514            }
515            SelectionType::WordUnderCursor => {
516                let pos = self.position();
517                let (word_start, word_end) = self.find_word_boundaries(pos);
518                let mut d = self.data.lock();
519                d.anchor = word_start;
520                d.position = word_end;
521                d.cell_selection_override = None;
522            }
523            SelectionType::SentenceUnderCursor => {
524                let pos = self.position();
525                // A block with nothing to point at leaves the cursor where it was, the same way
526                // `WordUnderCursor` collapses to `(pos, pos)` off a word.
527                if let Some((start, end)) = self.find_sentence_boundaries(pos) {
528                    let mut d = self.data.lock();
529                    d.anchor = start;
530                    d.position = end;
531                    d.cell_selection_override = None;
532                }
533            }
534        }
535    }
536
537    /// The language this cursor reads its text as, for the sentence operations. A BCP-47-ish
538    /// tag (`"en"`, `"fr-FR"`); `None` — the default — means untailored UAX #29.
539    ///
540    /// Transient, per-cursor and non-persisted: it never reaches an entity, an undo command or
541    /// an export. The enum-driven [`select`](Self::select) / [`move_position`](Self::move_position)
542    /// have nowhere to take a locale argument, so it is set once on the cursor instead —
543    /// [`TextDocument::sentence_at`](crate::TextDocument::sentence_at) takes the same value per
544    /// call for callers that would rather not hold a cursor at all.
545    pub fn set_content_locale(&self, locale: Option<&str>) {
546        self.data.lock().content_locale = locale.map(str::to_string);
547    }
548
549    /// The language set by [`set_content_locale`](Self::set_content_locale).
550    pub fn content_locale(&self) -> Option<String> {
551        self.data.lock().content_locale.clone()
552    }
553
554    // ── Text editing ─────────────────────────────────────────
555
556    /// Insert plain text at the cursor. Replaces selection if any.
557    ///
558    /// Reports [`InsertionOrigin::Unspecified`] — the caller did not say. Use
559    /// [`insert_text_with_origin`](Self::insert_text_with_origin) to say.
560    pub fn insert_text(&self, text: &str) -> Result<()> {
561        self.insert_text_with_origin(text, InsertionOrigin::Unspecified)
562    }
563
564    /// Insert plain text at the cursor, saying which channel it came through.
565    ///
566    /// The origin reaches consumers as [`DocumentEvent::TextInserted`]. It is a
567    /// fact about the channel and never about who was at the other end of it.
568    pub fn insert_text_with_origin(&self, text: &str, origin: InsertionOrigin) -> Result<()> {
569        let (pos, anchor) = self.read_cursor();
570
571        // Try direct insert first (handles same-block selection and no-selection cases)
572        let dto = frontend::document_editing::InsertTextDto {
573            format_policy: Default::default(),
574            position: to_i64(pos),
575            anchor: to_i64(anchor),
576            text: text.into(),
577        };
578
579        let queued = {
580            let mut inner = self.doc.lock();
581            let result = match document_editing_commands::insert_text(
582                &inner.ctx,
583                Some(inner.stack_id),
584                &dto,
585            ) {
586                Ok(r) => r,
587                Err(_) if pos != anchor => {
588                    // Cross-block selection: compose delete + insert as a single undo unit
589                    undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
590
591                    let del_dto = frontend::document_editing::DeleteTextDto {
592                        position: to_i64(pos),
593                        anchor: to_i64(anchor),
594                    };
595                    let del_result = document_editing_commands::delete_text(
596                        &inner.ctx,
597                        Some(inner.stack_id),
598                        &del_dto,
599                    )?;
600                    let del_pos = to_usize(del_result.new_position);
601
602                    let ins_dto = frontend::document_editing::InsertTextDto {
603                        format_policy: Default::default(),
604                        position: to_i64(del_pos),
605                        anchor: to_i64(del_pos),
606                        text: text.into(),
607                    };
608                    let ins_result = document_editing_commands::insert_text(
609                        &inner.ctx,
610                        Some(inner.stack_id),
611                        &ins_dto,
612                    )?;
613
614                    undo_redo_commands::end_composite(&inner.ctx);
615                    ins_result
616                }
617                Err(e) => return Err(e.into()),
618            };
619
620            let edit_pos = pos.min(anchor);
621            let removed = pos.max(anchor) - edit_pos;
622            self.finish_edit_from(
623                &mut inner,
624                edit_pos,
625                removed,
626                to_usize(result.new_position),
627                to_usize(result.blocks_affected),
628                false,
629                origin,
630            )
631        };
632        crate::inner::dispatch_queued_events(queued);
633        Ok(())
634    }
635
636    /// Replace `[start, end)` with `text`, choosing what the replacement wears where it
637    /// overwrites formatted text — see [`crate::ReplaceFormatPolicy`]. Lands as one atomic
638    /// edit (one undo entry), with the cursor left at the end of the inserted text.
639    ///
640    /// The counterpart to [`insert_text`](Self::insert_text) for callers that must *choose*
641    /// the replacement's formatting rather than inherit whatever precedes the range — a
642    /// spell-check correction picked from a context menu, an autocorrect, a
643    /// replace-this-occurrence action. `start`/`end` may be given in either order.
644    ///
645    /// A range crossing a block boundary falls back to composing delete + insert as a single
646    /// undo unit, exactly like [`insert_text`](Self::insert_text)'s existing cross-block
647    /// fallback — `format_policy` only has meaning within one block, since
648    /// [`crate::ReplaceFormatPolicy`] operates on a single block's format runs.
649    pub fn replace(
650        &self,
651        start: usize,
652        end: usize,
653        text: &str,
654        policy: crate::ReplaceFormatPolicy,
655    ) -> Result<()> {
656        let (pos, anchor) = (start, end);
657
658        let dto = frontend::document_editing::InsertTextDto {
659            format_policy: policy,
660            position: to_i64(pos),
661            anchor: to_i64(anchor),
662            text: text.into(),
663        };
664
665        let queued = {
666            let mut inner = self.doc.lock();
667            let result = match document_editing_commands::insert_text(
668                &inner.ctx,
669                Some(inner.stack_id),
670                &dto,
671            ) {
672                Ok(r) => r,
673                Err(_) if pos != anchor => {
674                    // Cross-block selection: compose delete + insert as a single undo unit,
675                    // same as insert_text. format_policy is dropped here — it has no
676                    // single-block meaning across a boundary.
677                    undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
678
679                    let del_dto = frontend::document_editing::DeleteTextDto {
680                        position: to_i64(pos),
681                        anchor: to_i64(anchor),
682                    };
683                    let del_result = document_editing_commands::delete_text(
684                        &inner.ctx,
685                        Some(inner.stack_id),
686                        &del_dto,
687                    )?;
688                    let del_pos = to_usize(del_result.new_position);
689
690                    let ins_dto = frontend::document_editing::InsertTextDto {
691                        format_policy: Default::default(),
692                        position: to_i64(del_pos),
693                        anchor: to_i64(del_pos),
694                        text: text.into(),
695                    };
696                    let ins_result = document_editing_commands::insert_text(
697                        &inner.ctx,
698                        Some(inner.stack_id),
699                        &ins_dto,
700                    )?;
701
702                    undo_redo_commands::end_composite(&inner.ctx);
703                    ins_result
704                }
705                Err(e) => return Err(e.into()),
706            };
707
708            let edit_pos = pos.min(anchor);
709            let removed = pos.max(anchor) - edit_pos;
710            self.finish_edit_ext(
711                &mut inner,
712                edit_pos,
713                removed,
714                to_usize(result.new_position),
715                to_usize(result.blocks_affected),
716                false,
717            )
718        };
719        crate::inner::dispatch_queued_events(queued);
720        Ok(())
721    }
722
723    /// Insert text with a specific character format. Replaces selection if any.
724    /// Reports [`InsertionOrigin::Unspecified`] — the caller did not say.
725    pub fn insert_formatted_text(&self, text: &str, format: &TextFormat) -> Result<()> {
726        self.insert_formatted_text_with_origin(text, format, InsertionOrigin::Unspecified)
727    }
728
729    /// As [`insert_formatted_text`](Self::insert_formatted_text), saying which
730    /// channel the text came through.
731    pub fn insert_formatted_text_with_origin(
732        &self,
733        text: &str,
734        format: &TextFormat,
735        origin: InsertionOrigin,
736    ) -> Result<()> {
737        let (pos, anchor) = self.read_cursor();
738
739        let make_dto = |p: usize, a: usize| frontend::document_editing::InsertFormattedTextDto {
740            position: to_i64(p),
741            anchor: to_i64(a),
742            text: text.into(),
743            font_family: format.font_family.clone().unwrap_or_default(),
744            font_point_size: format.font_point_size.map(|v| v as i64).unwrap_or(0),
745            font_bold: format.font_bold.unwrap_or(false),
746            font_italic: format.font_italic.unwrap_or(false),
747            font_underline: format.font_underline.unwrap_or(false),
748            font_strikeout: format.font_strikeout.unwrap_or(false),
749        };
750
751        let queued = {
752            let mut inner = self.doc.lock();
753            let result = match document_editing_commands::insert_formatted_text(
754                &inner.ctx,
755                Some(inner.stack_id),
756                &make_dto(pos, anchor),
757            ) {
758                Ok(r) => r,
759                Err(_) if pos != anchor => {
760                    // Cross-block selection: compose delete + insert as a single undo unit
761                    undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
762
763                    let del_dto = frontend::document_editing::DeleteTextDto {
764                        position: to_i64(pos),
765                        anchor: to_i64(anchor),
766                    };
767                    let del_result = document_editing_commands::delete_text(
768                        &inner.ctx,
769                        Some(inner.stack_id),
770                        &del_dto,
771                    )?;
772                    let del_pos = to_usize(del_result.new_position);
773
774                    let ins_result = document_editing_commands::insert_formatted_text(
775                        &inner.ctx,
776                        Some(inner.stack_id),
777                        &make_dto(del_pos, del_pos),
778                    )?;
779
780                    undo_redo_commands::end_composite(&inner.ctx);
781                    ins_result
782                }
783                Err(e) => return Err(e.into()),
784            };
785
786            let edit_pos = pos.min(anchor);
787            let removed = pos.max(anchor) - edit_pos;
788            self.finish_edit_from(
789                &mut inner,
790                edit_pos,
791                removed,
792                to_usize(result.new_position),
793                1,
794                false,
795                origin,
796            )
797        };
798        crate::inner::dispatch_queued_events(queued);
799        Ok(())
800    }
801
802    /// Insert a block break (new paragraph). Replaces selection if any.
803    pub fn insert_block(&self) -> Result<()> {
804        let (pos, anchor) = self.read_cursor();
805        let queued = {
806            let mut inner = self.doc.lock();
807
808            let (insert_pos, removed) = if pos != anchor {
809                // Selection active: delete first, then split (Word convention)
810                undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
811                let del_dto = frontend::document_editing::DeleteTextDto {
812                    position: to_i64(pos),
813                    anchor: to_i64(anchor),
814                };
815                let del_result = document_editing_commands::delete_text(
816                    &inner.ctx,
817                    Some(inner.stack_id),
818                    &del_dto,
819                )?;
820                (
821                    to_usize(del_result.new_position),
822                    pos.max(anchor) - pos.min(anchor),
823                )
824            } else {
825                (pos, 0)
826            };
827
828            let dto = frontend::document_editing::InsertBlockDto {
829                position: to_i64(insert_pos),
830                anchor: to_i64(insert_pos),
831            };
832            let result =
833                document_editing_commands::insert_block(&inner.ctx, Some(inner.stack_id), &dto)?;
834
835            if pos != anchor {
836                undo_redo_commands::end_composite(&inner.ctx);
837            }
838
839            let edit_pos = pos.min(anchor);
840            self.finish_edit(
841                &mut inner,
842                edit_pos,
843                removed,
844                to_usize(result.new_position),
845                2,
846            )
847        };
848        crate::inner::dispatch_queued_events(queued);
849        Ok(())
850    }
851
852    /// Insert an HTML fragment at the cursor position. Replaces selection if any.
853    /// As [`insert_html`](Self::insert_html), saying which channel the text came
854    /// through.
855    pub fn insert_html_with_origin(&self, html: &str, origin: InsertionOrigin) -> Result<()> {
856        let frag = DocumentFragment::from_html(html);
857        self.insert_fragment_with_origin(&frag, origin)
858    }
859
860    pub fn insert_html(&self, html: &str) -> Result<()> {
861        // Delegate to insert_fragment so table structure is preserved.
862        let frag = DocumentFragment::from_html(html);
863        self.insert_fragment(&frag)
864    }
865
866    /// Insert a Markdown fragment at the cursor position. Replaces selection if any.
867    /// As [`insert_markdown`](Self::insert_markdown), saying which channel the text came
868    /// through.
869    pub fn insert_markdown_with_origin(
870        &self,
871        markdown: &str,
872        origin: InsertionOrigin,
873    ) -> Result<()> {
874        let frag = DocumentFragment::from_markdown(markdown);
875        self.insert_fragment_with_origin(&frag, origin)
876    }
877
878    pub fn insert_markdown(&self, markdown: &str) -> Result<()> {
879        let frag = DocumentFragment::from_markdown(markdown);
880        self.insert_fragment(&frag)
881    }
882
883    /// Insert a djot fragment at the cursor position. Replaces selection if any.
884    /// As [`insert_djot`](Self::insert_djot), saying which channel the text came
885    /// through.
886    pub fn insert_djot_with_origin(&self, djot: &str, origin: InsertionOrigin) -> Result<()> {
887        let frag = DocumentFragment::from_djot(djot);
888        self.insert_fragment_with_origin(&frag, origin)
889    }
890
891    pub fn insert_djot(&self, djot: &str) -> Result<()> {
892        let frag = DocumentFragment::from_djot(djot);
893        self.insert_fragment(&frag)
894    }
895
896    /// Insert a footnote reference naming `label` at the cursor.
897    ///
898    /// Goes through Djot rather than a dedicated editing use case, and
899    /// deliberately: `[^label]` is what `Content` stores and what a reload
900    /// parses, so the reference on screen and the reference that survives a save
901    /// are produced by the same code path. An insertion route of its own would
902    /// work right up until the document was closed and reopened.
903    ///
904    /// The label is the note's durable identity, minted by the caller. It is
905    /// never the number — that is derived at render time from document order,
906    /// so inserting a note above this one renumbers it without touching a
907    /// character of the prose.
908    pub fn insert_footnote_reference(&self, label: &str) -> Result<()> {
909        self.insert_djot(&format!("[^{label}]"))
910    }
911
912    /// Insert a document fragment at the cursor. Replaces selection if any.
913    /// Reports [`InsertionOrigin::Unspecified`] — the caller did not say.
914    pub fn insert_fragment(&self, fragment: &DocumentFragment) -> Result<()> {
915        self.insert_fragment_with_origin(fragment, InsertionOrigin::Unspecified)
916    }
917
918    /// As [`insert_fragment`](Self::insert_fragment), saying which channel the
919    /// text came through. This is the path a paste, a drop and an import all
920    /// take, so it is the one that most needs to be able to say so.
921    pub fn insert_fragment_with_origin(
922        &self,
923        fragment: &DocumentFragment,
924        origin: InsertionOrigin,
925    ) -> Result<()> {
926        let (pos, anchor) = self.read_cursor();
927        let queued = {
928            let mut inner = self.doc.lock();
929
930            let (insert_pos, removed) = if pos != anchor {
931                undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
932                let del_dto = frontend::document_editing::DeleteTextDto {
933                    position: to_i64(pos),
934                    anchor: to_i64(anchor),
935                };
936                let del_result = document_editing_commands::delete_text(
937                    &inner.ctx,
938                    Some(inner.stack_id),
939                    &del_dto,
940                )?;
941                (
942                    to_usize(del_result.new_position),
943                    pos.max(anchor) - pos.min(anchor),
944                )
945            } else {
946                (pos, 0)
947            };
948
949            let dto = frontend::document_editing::InsertFragmentDto {
950                position: to_i64(insert_pos),
951                anchor: to_i64(insert_pos),
952                fragment_data: fragment.raw_data().into(),
953            };
954            let result =
955                document_editing_commands::insert_fragment(&inner.ctx, Some(inner.stack_id), &dto)?;
956
957            if pos != anchor {
958                undo_redo_commands::end_composite(&inner.ctx);
959            }
960
961            let edit_pos = pos.min(anchor);
962            self.finish_edit_from(
963                &mut inner,
964                edit_pos,
965                removed,
966                to_usize(result.new_position),
967                to_usize(result.blocks_added),
968                true,
969                origin,
970            )
971        };
972        crate::inner::dispatch_queued_events(queued);
973        Ok(())
974    }
975
976    /// Extract the current selection as a [`DocumentFragment`].
977    pub fn selection(&self) -> DocumentFragment {
978        let (pos, anchor) = self.read_cursor();
979
980        // For cell/mixed selections, compute position/anchor that span the
981        // full cell range so ExtractFragment detects cross-cell correctly.
982        let (extract_pos, extract_anchor) = match self.selection_kind() {
983            SelectionKind::Cells(ref range) => match self.cell_range_positions(range) {
984                Some((start, end)) => (start, end),
985                None => return DocumentFragment::new(),
986            },
987            SelectionKind::Mixed {
988                ref cell_range,
989                text_before,
990                text_after,
991            } => {
992                let (cell_start, cell_end) = match self.cell_range_positions(cell_range) {
993                    Some(p) => p,
994                    None => return DocumentFragment::new(),
995                };
996                let start = if text_before {
997                    pos.min(anchor)
998                } else {
999                    cell_start
1000                };
1001                let end = if text_after {
1002                    pos.max(anchor)
1003                } else {
1004                    cell_end
1005                };
1006                (start.min(cell_start), end.max(cell_end))
1007            }
1008            SelectionKind::None => return DocumentFragment::new(),
1009            SelectionKind::Text => (pos, anchor),
1010        };
1011
1012        if extract_pos == extract_anchor {
1013            return DocumentFragment::new();
1014        }
1015
1016        let inner = self.doc.lock();
1017        let dto = frontend::document_inspection::ExtractFragmentDto {
1018            position: to_i64(extract_pos),
1019            anchor: to_i64(extract_anchor),
1020        };
1021        match document_inspection_commands::extract_fragment(&inner.ctx, &dto) {
1022            Ok(result) => DocumentFragment::from_raw(result.fragment_data, result.plain_text),
1023            Err(_) => DocumentFragment::new(),
1024        }
1025    }
1026
1027    /// Insert an image at the cursor. Replaces selection if any.
1028    ///
1029    /// `name` keys the image's bytes in the document's resource table (see
1030    /// [`crate::TextDocument::add_resource`]); `alt` is its accessible
1031    /// description and its export representation, and may be empty.
1032    pub fn insert_image(&self, name: &str, alt: &str, width: u32, height: u32) -> Result<()> {
1033        let (pos, anchor) = self.read_cursor();
1034        let queued = {
1035            let mut inner = self.doc.lock();
1036
1037            let (insert_pos, removed) = if pos != anchor {
1038                undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
1039                let del_dto = frontend::document_editing::DeleteTextDto {
1040                    position: to_i64(pos),
1041                    anchor: to_i64(anchor),
1042                };
1043                let del_result = document_editing_commands::delete_text(
1044                    &inner.ctx,
1045                    Some(inner.stack_id),
1046                    &del_dto,
1047                )?;
1048                (
1049                    to_usize(del_result.new_position),
1050                    pos.max(anchor) - pos.min(anchor),
1051                )
1052            } else {
1053                (pos, 0)
1054            };
1055
1056            let dto = frontend::document_editing::InsertImageDto {
1057                position: to_i64(insert_pos),
1058                anchor: to_i64(insert_pos),
1059                image_name: name.into(),
1060                alt: alt.into(),
1061                width: width as i64,
1062                height: height as i64,
1063                quality: 100,
1064            };
1065            let result =
1066                document_editing_commands::insert_image(&inner.ctx, Some(inner.stack_id), &dto)?;
1067
1068            if pos != anchor {
1069                undo_redo_commands::end_composite(&inner.ctx);
1070            }
1071
1072            let edit_pos = pos.min(anchor);
1073            self.finish_edit_ext(
1074                &mut inner,
1075                edit_pos,
1076                removed,
1077                to_usize(result.new_position),
1078                1,
1079                false,
1080            )
1081        };
1082        crate::inner::dispatch_queued_events(queued);
1083        Ok(())
1084    }
1085
1086    /// Insert a new frame at the cursor.
1087    pub fn insert_frame(&self) -> Result<()> {
1088        let (pos, anchor) = self.read_cursor();
1089        let queued = {
1090            let mut inner = self.doc.lock();
1091            let dto = frontend::document_editing::InsertFrameDto {
1092                position: to_i64(pos),
1093                anchor: to_i64(anchor),
1094            };
1095            document_editing_commands::insert_frame(&inner.ctx, Some(inner.stack_id), &dto)?;
1096            // Frame insertion adds structural content; adjust cursors and emit event.
1097            // The backend doesn't return a new_position, so the cursor stays put.
1098            inner.modified = true;
1099            inner.invalidate_text_cache();
1100            inner.rehighlight_affected(pos.min(anchor));
1101            inner.queue_event(DocumentEvent::ContentsChanged {
1102                position: pos.min(anchor),
1103                chars_removed: 0,
1104                chars_added: 0,
1105                blocks_affected: 1,
1106            });
1107            inner.check_block_count_changed();
1108            inner.check_flow_changed();
1109            self.queue_undo_redo_event(&mut inner)
1110        };
1111        crate::inner::dispatch_queued_events(queued);
1112        Ok(())
1113    }
1114
1115    /// Insert a table at the cursor position.
1116    ///
1117    /// Creates a `rows × columns` table with empty cells.
1118    /// The cursor moves into the first cell of the table.
1119    /// Returns a handle to the created table.
1120    pub fn insert_table(&self, rows: usize, columns: usize) -> Result<TextTable> {
1121        let (pos, anchor) = self.read_cursor();
1122        let (table_id, queued) = {
1123            let mut inner = self.doc.lock();
1124            let dto = frontend::document_editing::InsertTableDto {
1125                position: to_i64(pos),
1126                anchor: to_i64(anchor),
1127                rows: to_i64(rows),
1128                columns: to_i64(columns),
1129            };
1130            let result =
1131                document_editing_commands::insert_table(&inner.ctx, Some(inner.stack_id), &dto)?;
1132            let new_pos = to_usize(result.new_position);
1133            let table_id = to_usize(result.table_id);
1134            inner.adjust_cursors(pos.min(anchor), 0, new_pos - pos.min(anchor));
1135            {
1136                let mut d = self.data.lock();
1137                d.position = new_pos;
1138                d.anchor = new_pos;
1139            }
1140            inner.modified = true;
1141            inner.invalidate_text_cache();
1142            inner.rehighlight_affected(pos.min(anchor));
1143            inner.queue_event(DocumentEvent::ContentsChanged {
1144                position: pos.min(anchor),
1145                chars_removed: 0,
1146                chars_added: new_pos - pos.min(anchor),
1147                blocks_affected: 1,
1148            });
1149            inner.check_block_count_changed();
1150            inner.check_flow_changed();
1151            (table_id, self.queue_undo_redo_event(&mut inner))
1152        };
1153        crate::inner::dispatch_queued_events(queued);
1154        Ok(TextTable {
1155            doc: self.doc.clone(),
1156            table_id,
1157        })
1158    }
1159
1160    /// Returns the table the cursor is currently inside, if any.
1161    ///
1162    /// Returns `None` if the cursor is in the main document flow
1163    /// (not inside a table cell).
1164    pub fn current_table(&self) -> Option<TextTable> {
1165        self.current_table_cell().map(|c| c.table)
1166    }
1167
1168    /// Returns the table cell the cursor is currently inside, if any.
1169    ///
1170    /// Returns `None` if the cursor is not inside a table cell.
1171    /// When `Some`, provides the table, row, and column.
1172    pub fn current_table_cell(&self) -> Option<TableCellRef> {
1173        let pos = self.position();
1174        let inner = self.doc.lock();
1175        // Find the block at cursor position
1176        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1177            position: to_i64(pos),
1178        };
1179        let block_info =
1180            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1181
1182        // When position < block_start, the cursor sits on the separator between
1183        // the previous block and this one. Visually the cursor belongs to the
1184        // end of the previous block, so look up that block instead.
1185        let block_id = if to_i64(pos) < block_info.block_start && pos > 0 {
1186            let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
1187                position: to_i64(pos - 1),
1188            };
1189            let prev_info =
1190                document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto).ok()?;
1191            prev_info.block_id as usize
1192        } else {
1193            block_info.block_id as usize
1194        };
1195
1196        let block = crate::text_block::TextBlock {
1197            doc: self.doc.clone(),
1198            block_id,
1199        };
1200        // Release inner lock before calling table_cell() which also locks
1201        drop(inner);
1202        block.table_cell()
1203    }
1204
1205    // ── Frame / blockquote queries ──────────
1206
1207    /// The innermost frame enclosing the cursor's current block, or `None`
1208    /// if the cursor sits directly in the root frame (no enclosing
1209    /// sub-frame). The returned `depth` is the nesting level from the root
1210    /// (1 for a direct child of root, 2 for a grandchild, etc.).
1211    pub fn current_frame(&self) -> Option<FrameRef> {
1212        let pos = self.position();
1213        let inner = self.doc.lock();
1214        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1215            position: to_i64(pos),
1216        };
1217        let block_info =
1218            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1219        let block_id = block_info.block_id as u64;
1220        cursor_frame_ref(&inner, block_id)
1221    }
1222
1223    /// True if the cursor's block lives inside any blockquote frame
1224    /// (at any nesting level).
1225    pub fn is_in_blockquote(&self) -> bool {
1226        self.current_blockquote_frame_id().is_some()
1227    }
1228
1229    /// Id of the innermost blockquote frame enclosing the cursor's block,
1230    /// or `None` if not in a blockquote.
1231    pub fn current_blockquote_frame_id(&self) -> Option<usize> {
1232        let pos = self.position();
1233        let inner = self.doc.lock();
1234        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1235            position: to_i64(pos),
1236        };
1237        let block_info =
1238            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1239        innermost_blockquote_frame_id(&inner, block_info.block_id as u64)
1240    }
1241
1242    /// Nesting depth of the cursor inside blockquote frames: 0 = not in
1243    /// any quote, 1 = top-level quote, 2 = quote inside a quote, …
1244    pub fn blockquote_depth_at_cursor(&self) -> usize {
1245        let pos = self.position();
1246        let inner = self.doc.lock();
1247        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1248            position: to_i64(pos),
1249        };
1250        let Some(block_info) =
1251            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()
1252        else {
1253            return 0;
1254        };
1255        blockquote_depth_for_block(&inner, block_info.block_id as u64)
1256    }
1257
1258    /// True iff the cursor's block is the first positive entry in its
1259    /// owning frame's `child_order`. Used by the keyboard handler to
1260    /// decide whether Backspace should unwrap the enclosing frame.
1261    /// A single-block frame returns true for both `is_first_*` and
1262    /// `is_last_*`.
1263    pub fn is_first_block_in_current_frame(&self) -> bool {
1264        matches!(
1265            block_position_in_current_frame(self),
1266            Some(BlockEdge::First) | Some(BlockEdge::OnlyOne)
1267        )
1268    }
1269
1270    /// True iff the cursor's block is the last positive entry in its
1271    /// owning frame's `child_order`. Used by the keyboard handler to
1272    /// decide whether forward Delete should unwrap the enclosing frame.
1273    pub fn is_last_block_in_current_frame(&self) -> bool {
1274        matches!(
1275            block_position_in_current_frame(self),
1276            Some(BlockEdge::Last) | Some(BlockEdge::OnlyOne)
1277        )
1278    }
1279
1280    /// True iff the block at the cursor has no characters of content.
1281    /// Used by the Enter handler to decide whether to exit a blockquote.
1282    pub fn current_block_is_empty(&self) -> bool {
1283        let pos = self.position();
1284        let inner = self.doc.lock();
1285        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1286            position: to_i64(pos),
1287        };
1288        let Some(block_info) =
1289            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()
1290        else {
1291            return false;
1292        };
1293        let store = inner.ctx.db_context.get_store();
1294        let block_entity = store
1295            .blocks
1296            .read()
1297            .get(&(block_info.block_id as common::types::EntityId))
1298            .cloned();
1299        match block_entity {
1300            Some(b) => {
1301                let len = common::database::rope_helpers::block_char_length(&b, store);
1302                len == 0
1303            }
1304            None => false,
1305        }
1306    }
1307
1308    /// True iff the cursor's anchor and head sit in different frames.
1309    /// Used by the toolbar to disable the "toggle blockquote" button on
1310    /// selections that cross frame boundaries.
1311    pub fn selection_spans_multiple_frames(&self) -> bool {
1312        let (pos, anchor) = self.read_cursor();
1313        if pos == anchor {
1314            return false;
1315        }
1316        let inner = self.doc.lock();
1317        let pos_dto = frontend::document_inspection::GetBlockAtPositionDto {
1318            position: to_i64(pos),
1319        };
1320        let anchor_dto = frontend::document_inspection::GetBlockAtPositionDto {
1321            position: to_i64(anchor),
1322        };
1323        let Some(pos_block) =
1324            document_inspection_commands::get_block_at_position(&inner.ctx, &pos_dto).ok()
1325        else {
1326            return false;
1327        };
1328        let Some(anchor_block) =
1329            document_inspection_commands::get_block_at_position(&inner.ctx, &anchor_dto).ok()
1330        else {
1331            return false;
1332        };
1333        let pos_owner = crate::text_block::find_parent_frame(&inner, pos_block.block_id as u64);
1334        let anchor_owner =
1335            crate::text_block::find_parent_frame(&inner, anchor_block.block_id as u64);
1336        pos_owner != anchor_owner
1337    }
1338
1339    // ── Blockquote mutations ──────────
1340
1341    /// Wrap the current block (or the blocks in the current selection)
1342    /// in a new blockquote frame nested inside the cursor's current
1343    /// parent frame. Returns an error if the selection spans multiple
1344    /// frames.
1345    pub fn wrap_selection_in_blockquote(&self) -> Result<()> {
1346        if self.selection_spans_multiple_frames() {
1347            return Err(DocumentError::InvalidArgument(
1348                "Cannot wrap selection in blockquote: selection spans multiple frames".into(),
1349            ));
1350        }
1351        let (start_block_id, end_block_id) = self.resolve_selection_block_range()?;
1352        let dto = frontend::document_editing::WrapBlocksInFrameDto {
1353            start_block_id: start_block_id as i64,
1354            end_block_id: end_block_id as i64,
1355            position: Some(frontend::document_editing::FramePosition::InFlow),
1356            top_margin: None,
1357            bottom_margin: None,
1358            left_margin: None,
1359            right_margin: None,
1360            padding: None,
1361            border: None,
1362            is_blockquote: Some(true),
1363        };
1364        let queued = {
1365            let mut inner = self.doc.lock();
1366            let _result = document_editing_commands::wrap_blocks_in_frame(
1367                &inner.ctx,
1368                Some(inner.stack_id),
1369                &dto,
1370            )?;
1371            inner.modified = true;
1372            // Frame-structure change: blocks didn't move and no text
1373            // changed, but block left-margins shift visually. Fire
1374            // FormatChanged (kind = Block) so the widget triggers a
1375            // paragraph relayout — same pattern as list operations
1376            // ([`add_block_to_list`] et al.). `ContentsChanged` with
1377            // chars_added/removed = 0 was misleading and caused the
1378            // incremental relayout to no-op until the next full repaint.
1379            inner.queue_event(DocumentEvent::FormatChanged {
1380                position: 0,
1381                length: 0,
1382                kind: crate::flow::FormatChangeKind::Block,
1383            });
1384            self.queue_undo_redo_event(&mut inner)
1385        };
1386        crate::inner::dispatch_queued_events(queued);
1387        Ok(())
1388    }
1389
1390    /// Wrap the current block in a new blockquote frame at the current
1391    /// nesting level. Equivalent to `wrap_selection_in_blockquote()`
1392    /// when there is no selection.
1393    pub fn insert_blockquote(&self) -> Result<()> {
1394        self.wrap_selection_in_blockquote()
1395    }
1396
1397    /// If the cursor is inside any blockquote, unwrap the innermost one
1398    /// (lift its blocks into the parent frame and delete the frame).
1399    /// Otherwise, wrap the current block / selection in a new
1400    /// blockquote. Mirrors the toggle behaviour of a toolbar button.
1401    pub fn toggle_blockquote(&self) -> Result<()> {
1402        if let Some(frame_id) = self.current_blockquote_frame_id() {
1403            self.unwrap_frame_by_id(frame_id)
1404        } else {
1405            self.wrap_selection_in_blockquote()
1406        }
1407    }
1408
1409    /// Unwrap the innermost frame enclosing the cursor (any frame, not
1410    /// just blockquotes). Errors if the cursor's block sits in the root
1411    /// frame.
1412    pub fn unwrap_current_frame(&self) -> Result<()> {
1413        let frame_ref = self.current_frame().ok_or_else(|| {
1414            DocumentError::InvalidCursorContext("Cursor is not inside any sub-frame".into())
1415        })?;
1416        self.unwrap_frame_by_id(frame_ref.frame_id)
1417    }
1418
1419    /// Extract the cursor's current block from its innermost enclosing
1420    /// blockquote frame, lifting it one nesting level. If the cursor is
1421    /// not in a blockquote, errors.
1422    pub fn unwrap_current_block_from_blockquote(&self) -> Result<()> {
1423        if self.current_blockquote_frame_id().is_none() {
1424            return Err(DocumentError::InvalidCursorContext(
1425                "Cursor is not inside a blockquote".into(),
1426            ));
1427        }
1428        let block_id = self.current_block_id_for_mutation()?;
1429        let dto = frontend::document_editing::UnwrapBlockFromFrameDto {
1430            block_id: block_id as i64,
1431        };
1432        let queued = {
1433            let mut inner = self.doc.lock();
1434            let _result = document_editing_commands::unwrap_block_from_frame(
1435                &inner.ctx,
1436                Some(inner.stack_id),
1437                &dto,
1438            )?;
1439            inner.modified = true;
1440            // See note in `wrap_selection_in_blockquote` — frame-structure
1441            // change without text mutation; fire FormatChanged so the
1442            // widget relayouts paragraph margins.
1443            inner.queue_event(DocumentEvent::FormatChanged {
1444                position: 0,
1445                length: 0,
1446                kind: crate::flow::FormatChangeKind::Block,
1447            });
1448            self.queue_undo_redo_event(&mut inner)
1449        };
1450        crate::inner::dispatch_queued_events(queued);
1451        Ok(())
1452    }
1453
1454    /// Wrap the current block in a new blockquote frame. If the cursor
1455    /// is already inside a blockquote, this creates a deeper nested
1456    /// quote (depth + 1). If outside, this creates a top-level quote.
1457    pub fn increase_blockquote_depth(&self) -> Result<()> {
1458        self.wrap_selection_in_blockquote()
1459    }
1460
1461    /// Pop the cursor out of one nesting level of blockquotes. If the
1462    /// cursor is in a depth-N quote with multiple blocks, the current
1463    /// block is extracted (splitting the quote if needed). If the
1464    /// current block is the only one in the quote, the whole quote is
1465    /// unwrapped.
1466    pub fn decrease_blockquote_depth(&self) -> Result<()> {
1467        if self.current_blockquote_frame_id().is_none() {
1468            return Err(DocumentError::InvalidCursorContext(
1469                "Cursor is not inside a blockquote to decrease depth".into(),
1470            ));
1471        }
1472        self.unwrap_current_block_from_blockquote()
1473    }
1474
1475    fn unwrap_frame_by_id(&self, frame_id: usize) -> Result<()> {
1476        let dto = frontend::document_editing::UnwrapFrameDto {
1477            frame_id: frame_id as i64,
1478        };
1479        let queued = {
1480            let mut inner = self.doc.lock();
1481            let _result =
1482                document_editing_commands::unwrap_frame(&inner.ctx, Some(inner.stack_id), &dto)?;
1483            inner.modified = true;
1484            // See note in `wrap_selection_in_blockquote` — frame-structure
1485            // change without text mutation; fire FormatChanged so the
1486            // widget relayouts paragraph margins.
1487            inner.queue_event(DocumentEvent::FormatChanged {
1488                position: 0,
1489                length: 0,
1490                kind: crate::flow::FormatChangeKind::Block,
1491            });
1492            self.queue_undo_redo_event(&mut inner)
1493        };
1494        crate::inner::dispatch_queued_events(queued);
1495        Ok(())
1496    }
1497
1498    fn current_block_id_for_mutation(&self) -> Result<usize> {
1499        let pos = self.position();
1500        let inner = self.doc.lock();
1501        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1502            position: to_i64(pos),
1503        };
1504        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1505            .map_err(|e| anyhow::anyhow!("get_block_at_position: {}", e))?;
1506        Ok(block_info.block_id as usize)
1507    }
1508
1509    fn resolve_selection_block_range(&self) -> Result<(usize, usize)> {
1510        let (pos, anchor) = self.read_cursor();
1511        let lo = pos.min(anchor);
1512        let hi = pos.max(anchor);
1513        let inner = self.doc.lock();
1514        let lo_dto = frontend::document_inspection::GetBlockAtPositionDto {
1515            position: to_i64(lo),
1516        };
1517        let hi_dto = frontend::document_inspection::GetBlockAtPositionDto {
1518            position: to_i64(hi),
1519        };
1520        let lo_block = document_inspection_commands::get_block_at_position(&inner.ctx, &lo_dto)
1521            .map_err(|e| anyhow::anyhow!("get_block_at_position(start): {}", e))?;
1522        let hi_block = document_inspection_commands::get_block_at_position(&inner.ctx, &hi_dto)
1523            .map_err(|e| anyhow::anyhow!("get_block_at_position(end): {}", e))?;
1524        Ok((lo_block.block_id as usize, hi_block.block_id as usize))
1525    }
1526
1527    // ── Table structure mutations (explicit-ID) ──────────
1528
1529    /// Remove a table from the document by its ID.
1530    pub fn remove_table(&self, table_id: usize) -> Result<()> {
1531        let queued = {
1532            let mut inner = self.doc.lock();
1533            // ⚠ Snapshotted **before** the command, so the text change it makes
1534            // can be reported as a real diff afterwards. Every structural table
1535            // edit below does the same: they know how many rows or cells they
1536            // touched and not where in the document's text that lands, and
1537            // working that out by hand would be seven chances to be subtly
1538            // wrong in a figure consumers shift their offsets by.
1539            let before = crate::document::capture_block_state(&inner);
1540            let dto = frontend::document_editing::RemoveTableDto {
1541                table_id: to_i64(table_id),
1542            };
1543            document_editing_commands::remove_table(&inner.ctx, Some(inner.stack_id), &dto)?;
1544            inner.modified = true;
1545            inner.invalidate_text_cache();
1546            inner.rehighlight_all();
1547            crate::document::emit_content_change_events(&mut inner, &before);
1548            inner.check_block_count_changed();
1549            inner.check_flow_changed();
1550            self.queue_undo_redo_event(&mut inner)
1551        };
1552        crate::inner::dispatch_queued_events(queued);
1553        Ok(())
1554    }
1555
1556    /// Insert a row into a table at the given index.
1557    pub fn insert_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
1558        let queued = {
1559            let mut inner = self.doc.lock();
1560            let before = crate::document::capture_block_state(&inner);
1561            let dto = frontend::document_editing::InsertTableRowDto {
1562                table_id: to_i64(table_id),
1563                row_index: to_i64(row_index),
1564            };
1565            document_editing_commands::insert_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
1566            inner.modified = true;
1567            inner.invalidate_text_cache();
1568            inner.rehighlight_all();
1569            crate::document::emit_content_change_events(&mut inner, &before);
1570            inner.check_block_count_changed();
1571            self.queue_undo_redo_event(&mut inner)
1572        };
1573        crate::inner::dispatch_queued_events(queued);
1574        Ok(())
1575    }
1576
1577    /// Insert a column into a table at the given index.
1578    pub fn insert_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
1579        let queued = {
1580            let mut inner = self.doc.lock();
1581            let before = crate::document::capture_block_state(&inner);
1582            let dto = frontend::document_editing::InsertTableColumnDto {
1583                table_id: to_i64(table_id),
1584                column_index: to_i64(column_index),
1585            };
1586            document_editing_commands::insert_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
1587            inner.modified = true;
1588            inner.invalidate_text_cache();
1589            inner.rehighlight_all();
1590            crate::document::emit_content_change_events(&mut inner, &before);
1591            inner.check_block_count_changed();
1592            self.queue_undo_redo_event(&mut inner)
1593        };
1594        crate::inner::dispatch_queued_events(queued);
1595        Ok(())
1596    }
1597
1598    /// Remove a row from a table. Fails if only one row remains.
1599    pub fn remove_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
1600        let queued = {
1601            let mut inner = self.doc.lock();
1602            let before = crate::document::capture_block_state(&inner);
1603            let dto = frontend::document_editing::RemoveTableRowDto {
1604                table_id: to_i64(table_id),
1605                row_index: to_i64(row_index),
1606            };
1607            document_editing_commands::remove_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
1608            inner.modified = true;
1609            inner.invalidate_text_cache();
1610            inner.rehighlight_all();
1611            crate::document::emit_content_change_events(&mut inner, &before);
1612            inner.check_block_count_changed();
1613            self.queue_undo_redo_event(&mut inner)
1614        };
1615        crate::inner::dispatch_queued_events(queued);
1616        Ok(())
1617    }
1618
1619    /// Remove a column from a table. Fails if only one column remains.
1620    pub fn remove_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
1621        let queued = {
1622            let mut inner = self.doc.lock();
1623            let before = crate::document::capture_block_state(&inner);
1624            let dto = frontend::document_editing::RemoveTableColumnDto {
1625                table_id: to_i64(table_id),
1626                column_index: to_i64(column_index),
1627            };
1628            document_editing_commands::remove_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
1629            inner.modified = true;
1630            inner.invalidate_text_cache();
1631            inner.rehighlight_all();
1632            crate::document::emit_content_change_events(&mut inner, &before);
1633            inner.check_block_count_changed();
1634            self.queue_undo_redo_event(&mut inner)
1635        };
1636        crate::inner::dispatch_queued_events(queued);
1637        Ok(())
1638    }
1639
1640    /// Merge a rectangular range of cells within a table.
1641    pub fn merge_table_cells(
1642        &self,
1643        table_id: usize,
1644        start_row: usize,
1645        start_column: usize,
1646        end_row: usize,
1647        end_column: usize,
1648    ) -> Result<()> {
1649        let queued = {
1650            let mut inner = self.doc.lock();
1651            let before = crate::document::capture_block_state(&inner);
1652            let dto = frontend::document_editing::MergeTableCellsDto {
1653                table_id: to_i64(table_id),
1654                start_row: to_i64(start_row),
1655                start_column: to_i64(start_column),
1656                end_row: to_i64(end_row),
1657                end_column: to_i64(end_column),
1658            };
1659            document_editing_commands::merge_table_cells(&inner.ctx, Some(inner.stack_id), &dto)?;
1660            inner.modified = true;
1661            inner.invalidate_text_cache();
1662            inner.rehighlight_all();
1663            crate::document::emit_content_change_events(&mut inner, &before);
1664            inner.check_block_count_changed();
1665            self.queue_undo_redo_event(&mut inner)
1666        };
1667        crate::inner::dispatch_queued_events(queued);
1668        Ok(())
1669    }
1670
1671    /// Split a previously merged cell.
1672    pub fn split_table_cell(
1673        &self,
1674        cell_id: usize,
1675        split_rows: usize,
1676        split_columns: usize,
1677    ) -> Result<()> {
1678        let queued = {
1679            let mut inner = self.doc.lock();
1680            let before = crate::document::capture_block_state(&inner);
1681            let dto = frontend::document_editing::SplitTableCellDto {
1682                cell_id: to_i64(cell_id),
1683                split_rows: to_i64(split_rows),
1684                split_columns: to_i64(split_columns),
1685            };
1686            document_editing_commands::split_table_cell(&inner.ctx, Some(inner.stack_id), &dto)?;
1687            inner.modified = true;
1688            inner.invalidate_text_cache();
1689            inner.rehighlight_all();
1690            crate::document::emit_content_change_events(&mut inner, &before);
1691            inner.check_block_count_changed();
1692            self.queue_undo_redo_event(&mut inner)
1693        };
1694        crate::inner::dispatch_queued_events(queued);
1695        Ok(())
1696    }
1697
1698    // ── Table formatting (explicit-ID) ───────────────────
1699
1700    /// Set formatting on a table.
1701    pub fn set_table_format(
1702        &self,
1703        table_id: usize,
1704        format: &crate::flow::TableFormat,
1705    ) -> Result<()> {
1706        let queued = {
1707            let mut inner = self.doc.lock();
1708            let dto = format.to_set_dto(table_id);
1709            document_formatting_commands::set_table_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1710            inner.modified = true;
1711            inner.queue_event(DocumentEvent::FormatChanged {
1712                position: 0,
1713                length: 0,
1714                kind: crate::flow::FormatChangeKind::Block,
1715            });
1716            self.queue_undo_redo_event(&mut inner)
1717        };
1718        crate::inner::dispatch_queued_events(queued);
1719        Ok(())
1720    }
1721
1722    /// Set formatting on a table cell.
1723    pub fn set_table_cell_format(
1724        &self,
1725        cell_id: usize,
1726        format: &crate::flow::CellFormat,
1727    ) -> Result<()> {
1728        let queued = {
1729            let mut inner = self.doc.lock();
1730            let dto = format.to_set_dto(cell_id);
1731            document_formatting_commands::set_table_cell_format(
1732                &inner.ctx,
1733                Some(inner.stack_id),
1734                &dto,
1735            )?;
1736            inner.modified = true;
1737            inner.queue_event(DocumentEvent::FormatChanged {
1738                position: 0,
1739                length: 0,
1740                kind: crate::flow::FormatChangeKind::Block,
1741            });
1742            self.queue_undo_redo_event(&mut inner)
1743        };
1744        crate::inner::dispatch_queued_events(queued);
1745        Ok(())
1746    }
1747
1748    // ── Table convenience (position-based) ───────────────
1749
1750    /// Remove the table the cursor is currently inside.
1751    /// Returns an error if the cursor is not inside a table.
1752    pub fn remove_current_table(&self) -> Result<()> {
1753        let table = self.current_table().ok_or_else(|| {
1754            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1755        })?;
1756        self.remove_table(table.id())
1757    }
1758
1759    /// Insert a row above the cursor's current row.
1760    /// Returns an error if the cursor is not inside a table.
1761    pub fn insert_row_above(&self) -> Result<()> {
1762        let cell_ref = self.current_table_cell().ok_or_else(|| {
1763            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1764        })?;
1765        self.insert_table_row(cell_ref.table.id(), cell_ref.row)
1766    }
1767
1768    /// Insert a row below the cursor's current row.
1769    /// Returns an error if the cursor is not inside a table.
1770    pub fn insert_row_below(&self) -> Result<()> {
1771        let cell_ref = self.current_table_cell().ok_or_else(|| {
1772            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1773        })?;
1774        self.insert_table_row(cell_ref.table.id(), cell_ref.row + 1)
1775    }
1776
1777    /// Insert a column before the cursor's current column.
1778    /// Returns an error if the cursor is not inside a table.
1779    pub fn insert_column_before(&self) -> Result<()> {
1780        let cell_ref = self.current_table_cell().ok_or_else(|| {
1781            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1782        })?;
1783        self.insert_table_column(cell_ref.table.id(), cell_ref.column)
1784    }
1785
1786    /// Insert a column after the cursor's current column.
1787    /// Returns an error if the cursor is not inside a table.
1788    pub fn insert_column_after(&self) -> Result<()> {
1789        let cell_ref = self.current_table_cell().ok_or_else(|| {
1790            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1791        })?;
1792        self.insert_table_column(cell_ref.table.id(), cell_ref.column + 1)
1793    }
1794
1795    /// Remove the row at the cursor's current position.
1796    /// Returns an error if the cursor is not inside a table.
1797    pub fn remove_current_row(&self) -> Result<()> {
1798        let cell_ref = self.current_table_cell().ok_or_else(|| {
1799            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1800        })?;
1801        self.remove_table_row(cell_ref.table.id(), cell_ref.row)
1802    }
1803
1804    /// Remove the column at the cursor's current position.
1805    /// Returns an error if the cursor is not inside a table.
1806    pub fn remove_current_column(&self) -> Result<()> {
1807        let cell_ref = self.current_table_cell().ok_or_else(|| {
1808            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1809        })?;
1810        self.remove_table_column(cell_ref.table.id(), cell_ref.column)
1811    }
1812
1813    /// Merge cells spanned by the current selection.
1814    ///
1815    /// Both cursor position and anchor must be inside the same table.
1816    /// The cell range is derived from the cells at position and anchor.
1817    /// Returns an error if the cursor is not inside a table or position
1818    /// and anchor are in different tables.
1819    pub fn merge_selected_cells(&self) -> Result<()> {
1820        let pos_cell = self.current_table_cell().ok_or_else(|| {
1821            DocumentError::InvalidCursorContext("cursor position is not inside a table".into())
1822        })?;
1823
1824        // Get anchor cell
1825        let (_pos, anchor) = self.read_cursor();
1826        let anchor_cell = {
1827            // Create a temporary block handle at the anchor position
1828            let inner = self.doc.lock();
1829            let dto = frontend::document_inspection::GetBlockAtPositionDto {
1830                position: to_i64(anchor),
1831            };
1832            let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1833                .map_err(|_| {
1834                    DocumentError::InvalidCursorContext(
1835                        "cursor anchor is not inside a table".into(),
1836                    )
1837                })?;
1838            let block = crate::text_block::TextBlock {
1839                doc: self.doc.clone(),
1840                block_id: block_info.block_id as usize,
1841            };
1842            drop(inner);
1843            block.table_cell().ok_or_else(|| {
1844                DocumentError::InvalidCursorContext("cursor anchor is not inside a table".into())
1845            })?
1846        };
1847
1848        if pos_cell.table.id() != anchor_cell.table.id() {
1849            return Err(DocumentError::InvalidArgument(
1850                "position and anchor are in different tables".into(),
1851            ));
1852        }
1853
1854        let start_row = pos_cell.row.min(anchor_cell.row);
1855        let start_col = pos_cell.column.min(anchor_cell.column);
1856        let end_row = pos_cell.row.max(anchor_cell.row);
1857        let end_col = pos_cell.column.max(anchor_cell.column);
1858
1859        self.merge_table_cells(pos_cell.table.id(), start_row, start_col, end_row, end_col)
1860    }
1861
1862    /// Split the cell at the cursor's current position.
1863    /// Returns an error if the cursor is not inside a table.
1864    pub fn split_current_cell(&self, split_rows: usize, split_columns: usize) -> Result<()> {
1865        let cell_ref = self.current_table_cell().ok_or_else(|| {
1866            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1867        })?;
1868        // Get the cell entity ID from the table handle
1869        let cell = cell_ref
1870            .table
1871            .cell(cell_ref.row, cell_ref.column)
1872            .ok_or_else(|| DocumentError::NotFound("cell not found".into()))?;
1873        // TextTableCell stores cell_id
1874        self.split_table_cell(cell.id(), split_rows, split_columns)
1875    }
1876
1877    /// Set formatting on the table the cursor is currently inside.
1878    /// Returns an error if the cursor is not inside a table.
1879    pub fn set_current_table_format(&self, format: &crate::flow::TableFormat) -> Result<()> {
1880        let table = self.current_table().ok_or_else(|| {
1881            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1882        })?;
1883        self.set_table_format(table.id(), format)
1884    }
1885
1886    /// Set formatting on the cell the cursor is currently inside.
1887    /// Returns an error if the cursor is not inside a table.
1888    pub fn set_current_cell_format(&self, format: &crate::flow::CellFormat) -> Result<()> {
1889        let cell_ref = self.current_table_cell().ok_or_else(|| {
1890            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1891        })?;
1892        let cell = cell_ref
1893            .table
1894            .cell(cell_ref.row, cell_ref.column)
1895            .ok_or_else(|| DocumentError::NotFound("cell not found".into()))?;
1896        self.set_table_cell_format(cell.id(), format)
1897    }
1898
1899    // ── Cell selection queries ────────────────────────────────
1900
1901    /// Determine the kind of selection the cursor currently has.
1902    ///
1903    /// Returns [`Cells`](crate::SelectionKind::Cells) when position and anchor are in
1904    /// different cells of the same table (rectangular cell selection), or
1905    /// when an explicit cell-selection override is active.
1906    pub fn selection_kind(&self) -> crate::flow::SelectionKind {
1907        use crate::flow::{CellRange, SelectionKind};
1908
1909        // Check override first
1910        {
1911            let d = self.data.lock();
1912            if let Some(ref range) = d.cell_selection_override {
1913                return SelectionKind::Cells(range.clone());
1914            }
1915            if d.position == d.anchor {
1916                return SelectionKind::None;
1917            }
1918        }
1919
1920        let (pos, anchor) = self.read_cursor();
1921
1922        // Look up table cell for position and anchor
1923        let pos_cell = self.table_cell_at(pos);
1924        let anchor_cell = self.table_cell_at(anchor);
1925
1926        match (&pos_cell, &anchor_cell) {
1927            (None, None) => {
1928                // Both endpoints are outside tables. Check whether a table
1929                // sits between them — if so, all its cells must be selected
1930                // (Word behaviour).
1931                let (start, end) = (pos.min(anchor), pos.max(anchor));
1932                if let Some(t) = self.find_table_between(start, end) {
1933                    let table_id = t.id();
1934                    let rows = t.rows();
1935                    let cols = t.columns();
1936                    let range = CellRange {
1937                        table_id,
1938                        start_row: 0,
1939                        start_col: 0,
1940                        end_row: if rows > 0 { rows - 1 } else { 0 },
1941                        end_col: if cols > 0 { cols - 1 } else { 0 },
1942                    };
1943                    let spans = self.collect_cell_spans(table_id);
1944                    SelectionKind::Mixed {
1945                        cell_range: range.expand_for_spans(&spans),
1946                        text_before: true,
1947                        text_after: true,
1948                    }
1949                } else {
1950                    SelectionKind::Text
1951                }
1952            }
1953            (Some(pc), Some(ac)) => {
1954                if pc.table.id() != ac.table.id() {
1955                    // Different tables — treat as text (whole tables selected between them)
1956                    return SelectionKind::Text;
1957                }
1958                if pc.row == ac.row && pc.column == ac.column {
1959                    // Same cell — text selection within one cell
1960                    return SelectionKind::Text;
1961                }
1962                // Different cells, same table — rectangular cell selection
1963                let range = CellRange {
1964                    table_id: pc.table.id(),
1965                    start_row: pc.row.min(ac.row),
1966                    start_col: pc.column.min(ac.column),
1967                    end_row: pc.row.max(ac.row),
1968                    end_col: pc.column.max(ac.column),
1969                };
1970                let spans = self.collect_cell_spans(pc.table.id());
1971                SelectionKind::Cells(range.expand_for_spans(&spans))
1972            }
1973            (Some(tc), None) | (None, Some(tc)) => {
1974                // One endpoint inside a table, the other outside — mixed
1975                // selection.  Following Word behaviour, select ALL cells in
1976                // the table (not just from the entry edge to the cursor row).
1977                let table_id = tc.table.id();
1978                let rows = tc.table.rows();
1979                let cols = tc.table.columns();
1980
1981                let inside_pos = if pos_cell.is_some() { pos } else { anchor };
1982                let outside_pos = if pos_cell.is_some() { anchor } else { pos };
1983
1984                let text_before = outside_pos < inside_pos;
1985                let text_after = !text_before;
1986
1987                let range = CellRange {
1988                    table_id,
1989                    start_row: 0,
1990                    start_col: 0,
1991                    end_row: if rows > 0 { rows - 1 } else { 0 },
1992                    end_col: if cols > 0 { cols - 1 } else { 0 },
1993                };
1994                let spans = self.collect_cell_spans(table_id);
1995                SelectionKind::Mixed {
1996                    cell_range: range.expand_for_spans(&spans),
1997                    text_before,
1998                    text_after,
1999                }
2000            }
2001        }
2002    }
2003
2004    /// Returns `true` when the current selection involves whole-cell selection.
2005    pub fn is_cell_selection(&self) -> bool {
2006        matches!(
2007            self.selection_kind(),
2008            crate::flow::SelectionKind::Cells(_) | crate::flow::SelectionKind::Mixed { .. }
2009        )
2010    }
2011
2012    /// Returns the rectangular cell range if the cursor has a cell selection.
2013    pub fn selected_cell_range(&self) -> Option<crate::flow::CellRange> {
2014        match self.selection_kind() {
2015            crate::flow::SelectionKind::Cells(r) => Some(r),
2016            crate::flow::SelectionKind::Mixed { cell_range, .. } => Some(cell_range),
2017            _ => None,
2018        }
2019    }
2020
2021    /// Returns all cells in the selected rectangular range.
2022    pub fn selected_cells(&self) -> Vec<TableCellRef> {
2023        let range = match self.selected_cell_range() {
2024            Some(r) => r,
2025            None => return Vec::new(),
2026        };
2027        let table = TextTable {
2028            doc: self.doc.clone(),
2029            table_id: range.table_id,
2030        };
2031        let mut cells = Vec::new();
2032        for row in range.start_row..=range.end_row {
2033            for col in range.start_col..=range.end_col {
2034                if table.cell(row, col).is_some() {
2035                    cells.push(TableCellRef {
2036                        table: table.clone(),
2037                        row,
2038                        column: col,
2039                    });
2040                }
2041            }
2042        }
2043        cells
2044    }
2045
2046    // ── Explicit cell selection ─────────────────────────────
2047
2048    /// Set an explicit single-cell selection override.
2049    pub fn select_table_cell(&self, table_id: usize, row: usize, col: usize) {
2050        let mut d = self.data.lock();
2051        d.cell_selection_override = Some(crate::flow::CellRange {
2052            table_id,
2053            start_row: row,
2054            start_col: col,
2055            end_row: row,
2056            end_col: col,
2057        });
2058    }
2059
2060    /// Set an explicit rectangular cell-range selection override.
2061    pub fn select_cell_range(
2062        &self,
2063        table_id: usize,
2064        start_row: usize,
2065        start_col: usize,
2066        end_row: usize,
2067        end_col: usize,
2068    ) {
2069        let range = crate::flow::CellRange {
2070            table_id,
2071            start_row,
2072            start_col,
2073            end_row,
2074            end_col,
2075        };
2076        let spans = self.collect_cell_spans(table_id);
2077        let mut d = self.data.lock();
2078        d.cell_selection_override = Some(range.expand_for_spans(&spans));
2079    }
2080
2081    /// Clear any cell-selection override without changing position/anchor.
2082    pub fn clear_cell_selection(&self) {
2083        let mut d = self.data.lock();
2084        d.cell_selection_override = None;
2085    }
2086
2087    /// Compute (min_position, max_position) spanning all blocks in a cell range.
2088    /// Returns `None` if the table or cells cannot be found.
2089    fn cell_range_positions(&self, range: &CellRange) -> Option<(usize, usize)> {
2090        let inner = self.doc.lock();
2091        let main_frame_id = get_main_frame_id(&inner);
2092        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
2093        drop(inner);
2094
2095        // Find the table matching the range's table_id
2096        let table = flow.into_iter().find_map(|e| match e {
2097            FlowElement::Table(t) if t.id() == range.table_id => Some(t),
2098            _ => None,
2099        })?;
2100
2101        let mut min_pos = usize::MAX;
2102        let mut max_pos = 0usize;
2103
2104        for row in range.start_row..=range.end_row {
2105            for col in range.start_col..=range.end_col {
2106                if let Some(cell) = table.cell(row, col) {
2107                    for block in cell.blocks() {
2108                        let bp = block.position();
2109                        let bl = block.length();
2110                        min_pos = min_pos.min(bp);
2111                        max_pos = max_pos.max(bp + bl);
2112                    }
2113                }
2114            }
2115        }
2116
2117        if min_pos == usize::MAX {
2118            return None;
2119        }
2120
2121        // Extend max_pos past the last block to ensure cross-cell detection
2122        Some((min_pos, max_pos + 1))
2123    }
2124
2125    // ── Cell selection helpers (private) ─────────────────────
2126
2127    /// Look up which table cell contains the given document position, if any.
2128    fn table_cell_at(&self, position: usize) -> Option<TableCellRef> {
2129        let inner = self.doc.lock();
2130        let dto = frontend::document_inspection::GetBlockAtPositionDto {
2131            position: to_i64(position),
2132        };
2133        let block_info =
2134            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
2135
2136        let block_id = if to_i64(position) < block_info.block_start && position > 0 {
2137            let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
2138                position: to_i64(position - 1),
2139            };
2140            let prev_info =
2141                document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto).ok()?;
2142            prev_info.block_id as usize
2143        } else {
2144            block_info.block_id as usize
2145        };
2146
2147        let block = crate::text_block::TextBlock {
2148            doc: self.doc.clone(),
2149            block_id,
2150        };
2151        drop(inner);
2152        block.table_cell()
2153    }
2154
2155    /// Find the document position at the boundary of the block adjacent to a
2156    /// table. Used by the table-trap logic in [`set_position`](Self::set_position).
2157    ///
2158    /// - `before == true`: returns the last position of the block immediately
2159    ///   before the table (i.e. `block.position() + block.length()`).
2160    /// - `before == false`: returns the first position of the block immediately
2161    ///   after the table.
2162    ///
2163    /// Returns `None` when no adjacent block exists (table is first or last
2164    /// element in the flow).
2165    fn table_boundary_position(&self, table_id: usize, before: bool) -> Option<usize> {
2166        let inner = self.doc.lock();
2167        let main_frame_id = get_main_frame_id(&inner);
2168        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
2169        drop(inner);
2170
2171        // Find the table in the flow and peek at the adjacent element.
2172        let idx = flow
2173            .iter()
2174            .position(|e| matches!(e, FlowElement::Table(t) if t.id() == table_id))?;
2175
2176        if before {
2177            // Walk backwards to find the nearest Block.
2178            for i in (0..idx).rev() {
2179                if let FlowElement::Block(b) = &flow[i] {
2180                    return Some(b.position() + b.length());
2181                }
2182            }
2183        } else {
2184            // Walk forwards to find the nearest Block.
2185            for item in flow.iter().skip(idx + 1) {
2186                if let FlowElement::Block(b) = item {
2187                    return Some(b.position());
2188                }
2189            }
2190        }
2191        None
2192    }
2193
2194    /// Find the first table whose cell blocks fall within the range `(start, end)`.
2195    fn find_table_between(&self, start: usize, end: usize) -> Option<TextTable> {
2196        let inner = self.doc.lock();
2197        let main_frame_id = get_main_frame_id(&inner);
2198        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
2199        drop(inner);
2200
2201        for elem in flow {
2202            if let FlowElement::Table(t) = elem {
2203                // Check whether the first cell's block position is between
2204                // the two endpoints (i.e. the table is inside the range).
2205                if let Some(first_cell) = t.cell(0, 0) {
2206                    let blocks = first_cell.blocks();
2207                    if let Some(fb) = blocks.first() {
2208                        let p = fb.position();
2209                        if p > start && p < end {
2210                            return Some(t);
2211                        }
2212                    }
2213                }
2214            }
2215        }
2216        None
2217    }
2218
2219    /// Collect `(row, col, row_span, col_span)` tuples for all cells in a table.
2220    fn collect_cell_spans(&self, table_id: usize) -> Vec<(usize, usize, usize, usize)> {
2221        let inner = self.doc.lock();
2222        let table_dto =
2223            match frontend::commands::table_commands::get_table(&inner.ctx, &(table_id as u64))
2224                .ok()
2225                .flatten()
2226            {
2227                Some(t) => t,
2228                None => return Vec::new(),
2229            };
2230
2231        let mut spans = Vec::with_capacity(table_dto.cells.len());
2232        for &cell_id in &table_dto.cells {
2233            if let Some(cell) =
2234                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &cell_id)
2235                    .ok()
2236                    .flatten()
2237            {
2238                spans.push((
2239                    cell.row as usize,
2240                    cell.column as usize,
2241                    cell.row_span.max(1) as usize,
2242                    cell.column_span.max(1) as usize,
2243                ));
2244            }
2245        }
2246        spans
2247    }
2248
2249    /// Delete the character after the cursor (Delete key).
2250    pub fn delete_char(&self) -> Result<()> {
2251        let (pos, anchor) = self.read_cursor();
2252        let (del_pos, del_anchor) = if pos != anchor {
2253            (pos, anchor)
2254        } else {
2255            // No-op at end of document (symmetric with delete_previous_char at start)
2256            let end = {
2257                let inner = self.doc.lock();
2258                document_inspection_commands::get_document_stats(&inner.ctx)
2259                    .map(|s| max_cursor_position(&s))
2260                    .unwrap_or(0)
2261            };
2262            if pos >= end {
2263                return Ok(());
2264            }
2265            // Delete the whole grapheme cluster after the cursor so a
2266            // single Delete on `👋🏻` or `e\u{0301}` removes the
2267            // user-perceived character, not just its first scalar.
2268            let to = self.next_grapheme_boundary(pos);
2269            if to == pos {
2270                return Ok(());
2271            }
2272            (pos, to)
2273        };
2274        self.do_delete(del_pos, del_anchor)
2275    }
2276
2277    /// Delete the character before the cursor (Backspace key).
2278    pub fn delete_previous_char(&self) -> Result<()> {
2279        let (pos, anchor) = self.read_cursor();
2280        let (del_pos, del_anchor) = if pos != anchor {
2281            (pos, anchor)
2282        } else if pos > 0 {
2283            let from = self.prev_grapheme_boundary(pos);
2284            if from == pos {
2285                return Ok(());
2286            }
2287            (from, pos)
2288        } else {
2289            return Ok(());
2290        };
2291        self.do_delete(del_pos, del_anchor)
2292    }
2293
2294    /// Delete the selected text. Returns the deleted text. No-op if no selection.
2295    pub fn remove_selected_text(&self) -> Result<String> {
2296        let (pos, anchor) = self.read_cursor();
2297        if pos == anchor {
2298            return Ok(String::new());
2299        }
2300        let queued = {
2301            let mut inner = self.doc.lock();
2302            let dto = frontend::document_editing::DeleteTextDto {
2303                position: to_i64(pos),
2304                anchor: to_i64(anchor),
2305            };
2306            let result =
2307                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
2308            let edit_pos = pos.min(anchor);
2309            let removed = pos.max(anchor) - edit_pos;
2310            let new_pos = to_usize(result.new_position);
2311            inner.adjust_cursors(edit_pos, removed, 0);
2312            {
2313                let mut d = self.data.lock();
2314                d.position = new_pos;
2315                d.anchor = new_pos;
2316            }
2317            inner.modified = true;
2318            inner.invalidate_text_cache();
2319            inner.rehighlight_affected(edit_pos);
2320            inner.queue_event(DocumentEvent::ContentsChanged {
2321                position: edit_pos,
2322                chars_removed: removed,
2323                chars_added: 0,
2324                blocks_affected: 1,
2325            });
2326            inner.check_block_count_changed();
2327            inner.check_flow_changed();
2328            // Return the deleted text alongside the queued events
2329            (result.deleted_text, self.queue_undo_redo_event(&mut inner))
2330        };
2331        crate::inner::dispatch_queued_events(queued.1);
2332        Ok(queued.0)
2333    }
2334
2335    // ── List operations ──────────────────────────────────────
2336
2337    /// Returns the list that the block at the cursor position belongs to,
2338    /// or `None` if the current block is not a list item.
2339    pub fn current_list(&self) -> Option<crate::TextList> {
2340        let pos = self.position();
2341        let inner = self.doc.lock();
2342        let dto = frontend::document_inspection::GetBlockAtPositionDto {
2343            position: to_i64(pos),
2344        };
2345        let block_info =
2346            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
2347        let block = crate::text_block::TextBlock {
2348            doc: self.doc.clone(),
2349            block_id: block_info.block_id as usize,
2350        };
2351        drop(inner);
2352        block.list()
2353    }
2354
2355    /// Turn the block(s) in the selection into a list.
2356    pub fn create_list(&self, style: ListStyle) -> Result<()> {
2357        let (pos, anchor) = self.read_cursor();
2358        let queued = {
2359            let mut inner = self.doc.lock();
2360            let dto = frontend::document_editing::CreateListDto {
2361                position: to_i64(pos),
2362                anchor: to_i64(anchor),
2363                style: style.clone(),
2364            };
2365            document_editing_commands::create_list(&inner.ctx, Some(inner.stack_id), &dto)?;
2366            inner.modified = true;
2367            inner.rehighlight_affected(pos.min(anchor));
2368            inner.queue_event(DocumentEvent::ContentsChanged {
2369                position: pos.min(anchor),
2370                chars_removed: 0,
2371                chars_added: 0,
2372                blocks_affected: 1,
2373            });
2374            self.queue_undo_redo_event(&mut inner)
2375        };
2376        crate::inner::dispatch_queued_events(queued);
2377        Ok(())
2378    }
2379
2380    /// Insert a new list item at the cursor position.
2381    pub fn insert_list(&self, style: ListStyle) -> Result<()> {
2382        let (pos, anchor) = self.read_cursor();
2383        let queued = {
2384            let mut inner = self.doc.lock();
2385            let dto = frontend::document_editing::InsertListDto {
2386                position: to_i64(pos),
2387                anchor: to_i64(anchor),
2388                style: style.clone(),
2389            };
2390            let result =
2391                document_editing_commands::insert_list(&inner.ctx, Some(inner.stack_id), &dto)?;
2392            let edit_pos = pos.min(anchor);
2393            let removed = pos.max(anchor) - edit_pos;
2394            self.finish_edit_ext(
2395                &mut inner,
2396                edit_pos,
2397                removed,
2398                to_usize(result.new_position),
2399                1,
2400                false,
2401            )
2402        };
2403        crate::inner::dispatch_queued_events(queued);
2404        Ok(())
2405    }
2406
2407    /// Set formatting on a list by its ID.
2408    pub fn set_list_format(&self, list_id: usize, format: &crate::ListFormat) -> Result<()> {
2409        let queued = {
2410            let mut inner = self.doc.lock();
2411            let dto = format.to_set_dto(list_id);
2412            document_formatting_commands::set_list_format(&inner.ctx, Some(inner.stack_id), &dto)?;
2413            inner.modified = true;
2414            inner.queue_event(DocumentEvent::FormatChanged {
2415                position: 0,
2416                length: 0,
2417                kind: crate::flow::FormatChangeKind::List,
2418            });
2419            self.queue_undo_redo_event(&mut inner)
2420        };
2421        crate::inner::dispatch_queued_events(queued);
2422        Ok(())
2423    }
2424
2425    /// Set formatting on the list that the current block belongs to.
2426    /// Returns an error if the cursor is not inside a list item.
2427    pub fn set_current_list_format(&self, format: &crate::ListFormat) -> Result<()> {
2428        let list = self.current_list().ok_or_else(|| {
2429            DocumentError::InvalidCursorContext("cursor is not inside a list".into())
2430        })?;
2431        self.set_list_format(list.id(), format)
2432    }
2433
2434    /// Add a block to a list by their IDs.
2435    pub fn add_block_to_list(&self, block_id: usize, list_id: usize) -> Result<()> {
2436        let queued = {
2437            let mut inner = self.doc.lock();
2438            let dto = frontend::document_editing::AddBlockToListDto {
2439                block_id: to_i64(block_id),
2440                list_id: to_i64(list_id),
2441            };
2442            document_editing_commands::add_block_to_list(&inner.ctx, Some(inner.stack_id), &dto)?;
2443            inner.modified = true;
2444            // List membership is a formatting/layout concern, not a text
2445            // change — fire FormatChanged so consumers re-layout (the
2446            // block's horizontal position and list marker depend on
2447            // its list assignment). ContentsChanged with position=0
2448            // was misleading and caused incremental relayouts to
2449            // re-shape the wrong block.
2450            inner.queue_event(DocumentEvent::FormatChanged {
2451                position: 0,
2452                length: 0,
2453                kind: crate::flow::FormatChangeKind::List,
2454            });
2455            self.queue_undo_redo_event(&mut inner)
2456        };
2457        crate::inner::dispatch_queued_events(queued);
2458        Ok(())
2459    }
2460
2461    /// Add the block at the cursor position to a list.
2462    pub fn add_current_block_to_list(&self, list_id: usize) -> Result<()> {
2463        let pos = self.position();
2464        let inner = self.doc.lock();
2465        let dto = frontend::document_inspection::GetBlockAtPositionDto {
2466            position: to_i64(pos),
2467        };
2468        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
2469        drop(inner);
2470        self.add_block_to_list(block_info.block_id as usize, list_id)
2471    }
2472
2473    /// Remove a block from its list by block ID.
2474    pub fn remove_block_from_list(&self, block_id: usize) -> Result<()> {
2475        let queued = {
2476            let mut inner = self.doc.lock();
2477            let dto = frontend::document_editing::RemoveBlockFromListDto {
2478                block_id: to_i64(block_id),
2479            };
2480            document_editing_commands::remove_block_from_list(
2481                &inner.ctx,
2482                Some(inner.stack_id),
2483                &dto,
2484            )?;
2485            inner.modified = true;
2486            // See `add_block_to_list` — list-membership is a
2487            // formatting/layout change, not a text content change.
2488            inner.queue_event(DocumentEvent::FormatChanged {
2489                position: 0,
2490                length: 0,
2491                kind: crate::flow::FormatChangeKind::List,
2492            });
2493            self.queue_undo_redo_event(&mut inner)
2494        };
2495        crate::inner::dispatch_queued_events(queued);
2496        Ok(())
2497    }
2498
2499    /// Remove the block at the cursor position from its list.
2500    /// Returns an error if the current block is not a list item.
2501    pub fn remove_current_block_from_list(&self) -> Result<()> {
2502        let pos = self.position();
2503        let inner = self.doc.lock();
2504        let dto = frontend::document_inspection::GetBlockAtPositionDto {
2505            position: to_i64(pos),
2506        };
2507        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
2508        drop(inner);
2509        self.remove_block_from_list(block_info.block_id as usize)
2510    }
2511
2512    /// Remove a list item by index within the list.
2513    /// Resolves the index to a block, then removes it from the list.
2514    pub fn remove_list_item(&self, list_id: usize, index: usize) -> Result<()> {
2515        let list = crate::text_list::TextList {
2516            doc: self.doc.clone(),
2517            list_id,
2518        };
2519        let block = list.item(index).ok_or_else(|| {
2520            DocumentError::OutOfRange(format!("list item index {index} out of range"))
2521        })?;
2522        self.remove_block_from_list(block.id())
2523    }
2524
2525    // ── Format queries ───────────────────────────────────────
2526
2527    /// Get the character format at the cursor position. Reads the
2528    /// covering `FormatRun` (or image anchor) directly from the store.
2529    pub fn char_format(&self) -> Result<TextFormat> {
2530        let pos = self.position();
2531        let inner = self.doc.lock();
2532
2533        // Locate the block containing the cursor.
2534        let dto = frontend::document_inspection::GetBlockAtPositionDto {
2535            position: to_i64(pos),
2536        };
2537        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
2538        let block_id = block_info.block_id as u64;
2539        let mut block_dto =
2540            frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
2541                .ok_or_else(|| DocumentError::NotFound("block not found at position".into()))?;
2542        let store = inner.ctx.db_context.get_store();
2543        crate::inner::refresh_block_position(&mut block_dto, store);
2544
2545        // Convert document-wide char position to a byte offset within
2546        // the block's content (read from the rope).
2547        let local_char = pos.saturating_sub(block_dto.document_position as usize);
2548        let entity: common::entities::Block = block_dto.clone().into();
2549        let plain_owned = common::database::rope_helpers::block_content_via_store(&entity, store);
2550        let plain: &str = &plain_owned;
2551        let byte_offset: u32 = plain
2552            .char_indices()
2553            .nth(local_char)
2554            .map(|(b, _)| b as u32)
2555            .unwrap_or(plain.len() as u32);
2556
2557        // If there's an image anchor at this exact byte position, use
2558        // its format.
2559        let images = store
2560            .block_images
2561            .read()
2562            .get(&block_id)
2563            .cloned()
2564            .unwrap_or_default();
2565        if let Some(img) = images.iter().find(|i| i.byte_offset == byte_offset) {
2566            return Ok(TextFormat::from(&img.format));
2567        }
2568
2569        // Otherwise find the FormatRun covering the byte position.
2570        let runs = store
2571            .format_runs
2572            .read()
2573            .get(&block_id)
2574            .cloned()
2575            .unwrap_or_default();
2576        let fmt = runs
2577            .iter()
2578            .find(|r| r.byte_start <= byte_offset && byte_offset < r.byte_end)
2579            .map(|r| TextFormat::from(&r.format))
2580            .unwrap_or_default();
2581        Ok(fmt)
2582    }
2583
2584    /// Get the block format of the block containing the cursor.
2585    ///
2586    /// Resolved with caret semantics, as "containing the cursor" says: a cursor at the end of
2587    /// a paragraph is still in it. Reading the character-index answer instead reported the
2588    /// NEXT paragraph's format there — which is what the editor's format panel showed for the
2589    /// whole time the caret sat at the end of the line being typed.
2590    pub fn block_format(&self) -> Result<BlockFormat> {
2591        let pos = self.position();
2592        let inner = self.doc.lock();
2593        let block_info = crate::inner::block_at_caret_dto(&inner.ctx, pos)?;
2594        let block_id = block_info.block_id as u64;
2595        let block = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
2596            .ok_or_else(|| DocumentError::NotFound("block not found".into()))?;
2597        Ok(BlockFormat::from(&block))
2598    }
2599
2600    // ── Format application ───────────────────────────────────
2601
2602    /// Set the character format for the selection.
2603    pub fn set_char_format(&self, format: &TextFormat) -> Result<()> {
2604        let (pos, anchor) = self.read_cursor();
2605        let queued = {
2606            let mut inner = self.doc.lock();
2607            let dto = format.to_set_dto(pos, anchor);
2608            document_formatting_commands::set_text_format(&inner.ctx, Some(inner.stack_id), &dto)?;
2609            let start = pos.min(anchor);
2610            let length = pos.max(anchor) - start;
2611            inner.modified = true;
2612            inner.queue_event(DocumentEvent::FormatChanged {
2613                position: start,
2614                length,
2615                kind: crate::flow::FormatChangeKind::Character,
2616            });
2617            self.queue_undo_redo_event(&mut inner)
2618        };
2619        crate::inner::dispatch_queued_events(queued);
2620        Ok(())
2621    }
2622
2623    /// The hyperlink the cursor sits in, with its full reach.
2624    ///
2625    /// A link has no identity of its own — it is a stretch of runs agreeing on
2626    /// a destination — so this reports the *extent*, coalesced across any runs
2627    /// an inner bold or italic split it into. See [`LinkExtent`].
2628    ///
2629    /// `None` when the cursor is not on a link. Uses the same caret semantics
2630    /// as [`block_format`](Self::block_format): a cursor at the end of a link
2631    /// is still in it.
2632    pub fn link_at_caret(&self) -> Option<LinkExtent> {
2633        let pos = self.position();
2634        let block_id = {
2635            let inner = self.doc.lock();
2636            crate::inner::block_at_caret_dto(&inner.ctx, pos)
2637                .ok()?
2638                .block_id as usize
2639        };
2640        let block = TextBlock {
2641            doc: self.doc.clone(),
2642            block_id,
2643        };
2644        crate::link_extent::link_extent_at(&block, pos)
2645    }
2646
2647    /// Remove the hyperlink from the selection.
2648    ///
2649    /// Not expressible through [`merge_char_format`](Self::merge_char_format):
2650    /// its fields merge, so `anchor_href: None` means "leave the link alone",
2651    /// never "take it off". Which is why the removal is its own verb, and why
2652    /// [`TextFormat::clear_link`](crate::TextFormat::clear_link) exists as a
2653    /// flag rather than as an absent field.
2654    ///
2655    /// Note the selection must be non-empty — a zero-width range formats
2656    /// nothing, here as everywhere else. Callers editing an existing link
2657    /// should select its [`LinkExtent`] first.
2658    pub fn clear_char_anchor(&self) -> Result<()> {
2659        self.merge_char_format(&TextFormat {
2660            clear_link: true,
2661            ..Default::default()
2662        })
2663    }
2664
2665    /// Merge a character format into the selection.
2666    pub fn merge_char_format(&self, format: &TextFormat) -> Result<()> {
2667        let (pos, anchor) = self.read_cursor();
2668        let queued = {
2669            let mut inner = self.doc.lock();
2670            let dto = format.to_merge_dto(pos, anchor);
2671            document_formatting_commands::merge_text_format(
2672                &inner.ctx,
2673                Some(inner.stack_id),
2674                &dto,
2675            )?;
2676            let start = pos.min(anchor);
2677            let length = pos.max(anchor) - start;
2678            inner.modified = true;
2679            inner.queue_event(DocumentEvent::FormatChanged {
2680                position: start,
2681                length,
2682                kind: crate::flow::FormatChangeKind::Character,
2683            });
2684            self.queue_undo_redo_event(&mut inner)
2685        };
2686        crate::inner::dispatch_queued_events(queued);
2687        Ok(())
2688    }
2689
2690    /// Set the block format for the current block (or all blocks in selection).
2691    pub fn set_block_format(&self, format: &BlockFormat) -> Result<()> {
2692        let (pos, anchor) = self.read_cursor();
2693        let queued = {
2694            let mut inner = self.doc.lock();
2695            let dto = format.to_set_dto(pos, anchor);
2696            document_formatting_commands::set_block_format(&inner.ctx, Some(inner.stack_id), &dto)?;
2697            let start = pos.min(anchor);
2698            let length = pos.max(anchor) - start;
2699            inner.modified = true;
2700            inner.queue_event(DocumentEvent::FormatChanged {
2701                position: start,
2702                length,
2703                kind: crate::flow::FormatChangeKind::Block,
2704            });
2705            self.queue_undo_redo_event(&mut inner)
2706        };
2707        crate::inner::dispatch_queued_events(queued);
2708        Ok(())
2709    }
2710
2711    /// Set the frame format.
2712    pub fn set_frame_format(&self, frame_id: usize, format: &FrameFormat) -> Result<()> {
2713        let (pos, anchor) = self.read_cursor();
2714        let queued = {
2715            let mut inner = self.doc.lock();
2716            let dto = format.to_set_dto(pos, anchor, frame_id);
2717            document_formatting_commands::set_frame_format(&inner.ctx, Some(inner.stack_id), &dto)?;
2718            let start = pos.min(anchor);
2719            let length = pos.max(anchor) - start;
2720            inner.modified = true;
2721            inner.queue_event(DocumentEvent::FormatChanged {
2722                position: start,
2723                length,
2724                kind: crate::flow::FormatChangeKind::Block,
2725            });
2726            self.queue_undo_redo_event(&mut inner)
2727        };
2728        crate::inner::dispatch_queued_events(queued);
2729        Ok(())
2730    }
2731
2732    // ── Edit blocks (composite undo) ─────────────────────────
2733
2734    /// Begin a group of operations that will be undone as a single unit.
2735    pub fn begin_edit_block(&self) {
2736        let inner = self.doc.lock();
2737        undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
2738    }
2739
2740    /// End the current edit block.
2741    pub fn end_edit_block(&self) {
2742        let inner = self.doc.lock();
2743        undo_redo_commands::end_composite(&inner.ctx);
2744    }
2745
2746    /// Alias for [`begin_edit_block`](Self::begin_edit_block).
2747    ///
2748    /// Semantically indicates that the new composite should be merged with
2749    /// the previous one (e.g., consecutive keystrokes grouped into a single
2750    /// undo unit). The current backend treats this identically to
2751    /// `begin_edit_block`; future versions may implement automatic merging.
2752    pub fn join_previous_edit_block(&self) {
2753        self.begin_edit_block();
2754    }
2755
2756    // ── Private helpers ─────────────────────────────────────
2757
2758    /// Queue an `UndoRedoChanged` event and return all queued events for dispatch.
2759    fn queue_undo_redo_event(&self, inner: &mut TextDocumentInner) -> QueuedEvents {
2760        let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
2761        let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
2762        inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
2763        inner.take_queued_events()
2764    }
2765
2766    fn do_delete(&self, pos: usize, anchor: usize) -> Result<()> {
2767        let queued = {
2768            let mut inner = self.doc.lock();
2769            let dto = frontend::document_editing::DeleteTextDto {
2770                position: to_i64(pos),
2771                anchor: to_i64(anchor),
2772            };
2773            let result =
2774                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
2775            let edit_pos = pos.min(anchor);
2776            let removed = pos.max(anchor) - edit_pos;
2777            let new_pos = to_usize(result.new_position);
2778            inner.adjust_cursors(edit_pos, removed, 0);
2779            {
2780                let mut d = self.data.lock();
2781                d.position = new_pos;
2782                d.anchor = new_pos;
2783            }
2784            inner.modified = true;
2785            inner.invalidate_text_cache();
2786            inner.rehighlight_affected(edit_pos);
2787            inner.queue_event(DocumentEvent::ContentsChanged {
2788                position: edit_pos,
2789                chars_removed: removed,
2790                chars_added: 0,
2791                blocks_affected: 1,
2792            });
2793            inner.check_block_count_changed();
2794            inner.check_flow_changed();
2795            self.queue_undo_redo_event(&mut inner)
2796        };
2797        crate::inner::dispatch_queued_events(queued);
2798        Ok(())
2799    }
2800
2801    /// Resolve a MoveOperation to a concrete position.
2802    fn resolve_move(&self, op: MoveOperation, n: usize) -> usize {
2803        let pos = self.position();
2804        match op {
2805            MoveOperation::NoMove => pos,
2806            MoveOperation::Start => 0,
2807            MoveOperation::End => {
2808                let inner = self.doc.lock();
2809                document_inspection_commands::get_document_stats(&inner.ctx)
2810                    .map(|s| max_cursor_position(&s))
2811                    .unwrap_or(pos)
2812            }
2813            MoveOperation::NextCharacter | MoveOperation::Right => {
2814                let mut cur = pos;
2815                for _ in 0..n {
2816                    let next = self.next_grapheme_boundary(cur);
2817                    if next == cur {
2818                        break;
2819                    }
2820                    cur = next;
2821                }
2822                cur
2823            }
2824            MoveOperation::PreviousCharacter | MoveOperation::Left => {
2825                let mut cur = pos;
2826                for _ in 0..n {
2827                    let prev = self.prev_grapheme_boundary(cur);
2828                    if prev == cur {
2829                        break;
2830                    }
2831                    cur = prev;
2832                }
2833                cur
2834            }
2835            MoveOperation::StartOfBlock | MoveOperation::StartOfLine => {
2836                let inner = self.doc.lock();
2837                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2838                    position: to_i64(pos),
2839                };
2840                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2841                    .map(|info| to_usize(info.block_start))
2842                    .unwrap_or(pos)
2843            }
2844            MoveOperation::EndOfBlock | MoveOperation::EndOfLine => {
2845                let inner = self.doc.lock();
2846                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2847                    position: to_i64(pos),
2848                };
2849                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2850                    .map(|info| to_usize(info.block_start) + to_usize(info.block_length))
2851                    .unwrap_or(pos)
2852            }
2853            MoveOperation::NextBlock => {
2854                let inner = self.doc.lock();
2855                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2856                    position: to_i64(pos),
2857                };
2858                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2859                    .map(|info| {
2860                        // Move past current block + 1 (block separator)
2861                        to_usize(info.block_start) + to_usize(info.block_length) + 1
2862                    })
2863                    .unwrap_or(pos)
2864            }
2865            MoveOperation::PreviousBlock => {
2866                let inner = self.doc.lock();
2867                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2868                    position: to_i64(pos),
2869                };
2870                let block_start =
2871                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2872                        .map(|info| to_usize(info.block_start))
2873                        .unwrap_or(pos);
2874                if block_start >= 2 {
2875                    // Skip past the block separator (which maps to the current block)
2876                    let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
2877                        position: to_i64(block_start - 2),
2878                    };
2879                    document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto)
2880                        .map(|info| to_usize(info.block_start))
2881                        .unwrap_or(0)
2882                } else {
2883                    0
2884                }
2885            }
2886            MoveOperation::NextWord | MoveOperation::EndOfWord | MoveOperation::WordRight => {
2887                let (_, end) = self.find_word_boundaries(pos);
2888                // Move past the word end to the next word
2889                if end == pos {
2890                    // Already at a boundary, skip whitespace
2891                    let inner = self.doc.lock();
2892                    let max_pos = document_inspection_commands::get_document_stats(&inner.ctx)
2893                        .map(|s| max_cursor_position(&s))
2894                        .unwrap_or(0);
2895                    let scan_len = max_pos.saturating_sub(pos).min(64);
2896                    if scan_len == 0 {
2897                        return pos;
2898                    }
2899                    let dto = frontend::document_inspection::GetTextAtPositionDto {
2900                        position: to_i64(pos),
2901                        length: to_i64(scan_len),
2902                    };
2903                    if let Ok(r) =
2904                        document_inspection_commands::get_text_at_position(&inner.ctx, &dto)
2905                    {
2906                        for (i, ch) in r.text.chars().enumerate() {
2907                            if ch.is_alphanumeric() || ch == '_' {
2908                                // Found start of next word, find its end
2909                                let word_pos = pos + i;
2910                                drop(inner);
2911                                let (_, word_end) = self.find_word_boundaries(word_pos);
2912                                return word_end;
2913                            }
2914                        }
2915                    }
2916                    pos + scan_len
2917                } else {
2918                    end
2919                }
2920            }
2921            MoveOperation::PreviousWord | MoveOperation::StartOfWord | MoveOperation::WordLeft => {
2922                let (start, _) = self.find_word_boundaries(pos);
2923                if start < pos {
2924                    start
2925                } else if pos > 0 {
2926                    // Cursor is at a word start or on whitespace — scan backwards
2927                    // to find the start of the previous word.
2928                    let mut search = pos - 1;
2929                    loop {
2930                        let (ws, we) = self.find_word_boundaries(search);
2931                        if ws < we {
2932                            // Found a word; return its start
2933                            break ws;
2934                        }
2935                        // Still on whitespace/non-word; keep scanning
2936                        if search == 0 {
2937                            break 0;
2938                        }
2939                        search -= 1;
2940                    }
2941                } else {
2942                    0
2943                }
2944            }
2945            MoveOperation::StartOfSentence | MoveOperation::PreviousSentence => {
2946                let mut cur = pos;
2947                for _ in 0..n.max(1) {
2948                    let start = match self.find_sentence_boundaries(cur) {
2949                        Some((start, _)) => start,
2950                        None => break,
2951                    };
2952                    // Already at the start (or `PreviousSentence`, which always steps): resolve
2953                    // again from just before it to reach the sentence before this one.
2954                    if start < cur && op == MoveOperation::StartOfSentence {
2955                        cur = start;
2956                    } else if cur > 0 {
2957                        match self.find_sentence_boundaries(cur - 1) {
2958                            Some((prev, _)) if prev < cur => cur = prev,
2959                            // Nothing but whitespace behind: fall back to the block edge rather
2960                            // than stalling, so a repeated keystroke still makes progress.
2961                            _ => cur = cur.saturating_sub(1),
2962                        }
2963                    } else {
2964                        break;
2965                    }
2966                }
2967                cur
2968            }
2969            MoveOperation::EndOfSentence => {
2970                let mut cur = pos;
2971                for _ in 0..n.max(1) {
2972                    let end = match self.find_sentence_boundaries(cur) {
2973                        Some((_, end)) => end,
2974                        None => break,
2975                    };
2976                    if end > cur {
2977                        cur = end;
2978                    } else {
2979                        match self.find_sentence_boundaries(cur + 1) {
2980                            Some((_, next)) if next > cur => cur = next,
2981                            _ => break,
2982                        }
2983                    }
2984                }
2985                cur
2986            }
2987            MoveOperation::NextSentence => {
2988                let mut cur = pos;
2989                for _ in 0..n.max(1) {
2990                    // Step past this sentence's end, then take the start of whatever follows —
2991                    // which skips the whitespace between them.
2992                    let end = match self.find_sentence_boundaries(cur) {
2993                        Some((_, end)) => end,
2994                        None => break,
2995                    };
2996                    match self.find_sentence_boundaries(end + 1) {
2997                        Some((start, _)) if start > cur => cur = start,
2998                        _ => {
2999                            if end > cur {
3000                                cur = end;
3001                            } else {
3002                                break;
3003                            }
3004                        }
3005                    }
3006                }
3007                cur
3008            }
3009            MoveOperation::Up | MoveOperation::Down => {
3010                // Up/Down are visual operations that depend on line wrapping.
3011                // Without layout info, treat as PreviousBlock/NextBlock.
3012                if matches!(op, MoveOperation::Up) {
3013                    self.resolve_move(MoveOperation::PreviousBlock, 1)
3014                } else {
3015                    self.resolve_move(MoveOperation::NextBlock, 1)
3016                }
3017            }
3018        }
3019    }
3020
3021    /// Snap the cursor's current position to the nearest grapheme
3022    /// cluster boundary, moving forward if currently mid-cluster.
3023    /// No-op when already at a boundary.
3024    ///
3025    /// Applied automatically by `cursor_at` and `set_position` so a
3026    /// caller passing an arbitrary scalar index never lands inside a
3027    /// cluster — without this, a round-trip such as
3028    /// `NextCharacter → PreviousCharacter` would stop at the cluster
3029    /// start rather than the start position, because the pre-advance
3030    /// state wasn't a boundary to begin with.
3031    pub(crate) fn snap_position_to_grapheme_boundary(&self) {
3032        let pos = {
3033            let data = self.data.lock();
3034            data.position
3035        };
3036        let snapped = self.forward_grapheme_boundary_at_or_after(pos);
3037        if snapped != pos {
3038            let mut data = self.data.lock();
3039            data.position = snapped;
3040            if data.anchor == pos {
3041                data.anchor = snapped;
3042            }
3043        }
3044    }
3045
3046    /// Return `pos` if it sits at a grapheme cluster boundary within
3047    /// its block; otherwise return the end position of the containing
3048    /// cluster (snap forward). Block separators are always treated as
3049    /// boundaries.
3050    ///
3051    /// Leaves out-of-range positions (`pos > max_cursor_position`)
3052    /// unchanged — the snap must never silently upgrade an out-of-
3053    /// range cursor to a valid one, because edit ops rely on the
3054    /// out-of-range check to stay no-ops.
3055    fn forward_grapheme_boundary_at_or_after(&self, pos: usize) -> usize {
3056        let inner = self.doc.lock();
3057        let end = document_inspection_commands::get_document_stats(&inner.ctx)
3058            .map(|s| max_cursor_position(&s))
3059            .unwrap_or(pos);
3060        if pos >= end {
3061            return pos;
3062        }
3063        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
3064            position: to_i64(pos),
3065        };
3066        let Ok(block_info) =
3067            document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto)
3068        else {
3069            return pos;
3070        };
3071        let block_start = to_usize(block_info.block_start);
3072        let block_length = to_usize(block_info.block_length);
3073        let offset_in_block = pos.saturating_sub(block_start);
3074        // Block boundaries (start / end) are always cluster boundaries.
3075        if offset_in_block == 0 || offset_in_block >= block_length {
3076            return pos;
3077        }
3078        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
3079            position: to_i64(block_start),
3080            length: to_i64(block_length),
3081        };
3082        let Ok(r) = document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto)
3083        else {
3084            return pos;
3085        };
3086        let text = r.text;
3087        drop(inner);
3088        // Walk grapheme clusters, accumulating char counts. The first
3089        // boundary >= offset_in_block is the snap target.
3090        let mut acc = 0usize;
3091        for g in text.graphemes(true) {
3092            if acc >= offset_in_block {
3093                return block_start + acc;
3094            }
3095            acc += g.chars().count();
3096        }
3097        block_start + acc
3098    }
3099
3100    /// Return the cursor position after advancing one extended grapheme
3101    /// cluster from `pos`. A grapheme cluster is what a user perceives
3102    /// as a single character — decomposed accents (`e` + `U+0301`),
3103    /// skin-tone emoji, ZWJ sequences, and regional-indicator flags
3104    /// are all single clusters even though they contain multiple
3105    /// Unicode scalars.
3106    ///
3107    /// Block separators (the single scalar between blocks in the
3108    /// cursor-position space) are treated as their own unit: advancing
3109    /// from the end of a block goes to the start of the next block
3110    /// (one scalar forward) without touching the grapheme path.
3111    /// Returns `pos` unchanged when already at the document end.
3112    fn next_grapheme_boundary(&self, pos: usize) -> usize {
3113        let inner = self.doc.lock();
3114        let end = document_inspection_commands::get_document_stats(&inner.ctx)
3115            .map(|s| max_cursor_position(&s))
3116            .unwrap_or(pos);
3117        if pos >= end {
3118            return pos;
3119        }
3120        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
3121            position: to_i64(pos),
3122        };
3123        let block_info =
3124            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
3125                Ok(info) => info,
3126                Err(_) => return pos + 1,
3127            };
3128        let block_start = to_usize(block_info.block_start);
3129        let block_length = to_usize(block_info.block_length);
3130        let offset_in_block = pos.saturating_sub(block_start);
3131        if offset_in_block >= block_length {
3132            // At block end — advance across the separator into the
3133            // next block.
3134            return (pos + 1).min(end);
3135        }
3136        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
3137            position: to_i64(pos),
3138            length: to_i64(block_length - offset_in_block),
3139        };
3140        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto) {
3141            Ok(r) => r.text,
3142            Err(_) => return pos + 1,
3143        };
3144        drop(inner);
3145        match text.graphemes(true).next() {
3146            Some(g) if !g.is_empty() => (pos + g.chars().count()).min(end),
3147            _ => (pos + 1).min(end),
3148        }
3149    }
3150
3151    /// Return the cursor position before the extended grapheme cluster
3152    /// that ends at `pos`. Counterpart to [`Self::next_grapheme_boundary`].
3153    /// Crosses block separators one scalar at a time.
3154    fn prev_grapheme_boundary(&self, pos: usize) -> usize {
3155        if pos == 0 {
3156            return 0;
3157        }
3158        let inner = self.doc.lock();
3159        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
3160            position: to_i64(pos.saturating_sub(1)),
3161        };
3162        let block_info =
3163            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
3164                Ok(info) => info,
3165                Err(_) => return pos - 1,
3166            };
3167        let block_start = to_usize(block_info.block_start);
3168        let block_length = to_usize(block_info.block_length);
3169        let block_end = block_start + block_length;
3170        // If `pos` sits past the block text (on a separator), step back
3171        // one scalar rather than running grapheme analysis across a
3172        // boundary.
3173        if pos > block_end {
3174            return pos - 1;
3175        }
3176        if block_length == 0 || pos <= block_start {
3177            return pos.saturating_sub(1);
3178        }
3179        let scan_len = pos - block_start;
3180        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
3181            position: to_i64(block_start),
3182            length: to_i64(scan_len),
3183        };
3184        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto) {
3185            Ok(r) => r.text,
3186            Err(_) => return pos - 1,
3187        };
3188        drop(inner);
3189        match text.graphemes(true).next_back() {
3190            Some(g) if !g.is_empty() => pos - g.chars().count(),
3191            _ => pos - 1,
3192        }
3193    }
3194
3195    /// Find the word boundaries around `pos`. Returns (start, end).
3196    /// Uses Unicode word segmentation for correct handling of non-ASCII text.
3197    ///
3198    /// Single-pass: tracks the last word seen to avoid a second iteration
3199    /// when the cursor is at the end of the last word (ISSUE-18).
3200    fn find_word_boundaries(&self, pos: usize) -> (usize, usize) {
3201        let inner = self.doc.lock();
3202        // Get block info so we can fetch the full block text
3203        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
3204            position: to_i64(pos),
3205        };
3206        let block_info =
3207            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
3208                Ok(info) => info,
3209                Err(_) => return (pos, pos),
3210            };
3211
3212        let block_start = to_usize(block_info.block_start);
3213        let block_length = to_usize(block_info.block_length);
3214        if block_length == 0 {
3215            return (pos, pos);
3216        }
3217
3218        let dto = frontend::document_inspection::GetTextAtPositionDto {
3219            position: to_i64(block_start),
3220            length: to_i64(block_length),
3221        };
3222        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &dto) {
3223            Ok(r) => r.text,
3224            Err(_) => return (pos, pos),
3225        };
3226
3227        // cursor_offset is the char offset within the block text
3228        let cursor_offset = pos.saturating_sub(block_start);
3229
3230        // Single pass: track the last word seen for end-of-last-word check
3231        let mut last_char_start = 0;
3232        let mut last_char_end = 0;
3233
3234        for (word_byte_start, word) in text.unicode_word_indices() {
3235            // Convert byte offset to char offset
3236            let word_char_start = text[..word_byte_start].chars().count();
3237            let word_char_len = word.chars().count();
3238            let word_char_end = word_char_start + word_char_len;
3239
3240            last_char_start = word_char_start;
3241            last_char_end = word_char_end;
3242
3243            if cursor_offset >= word_char_start && cursor_offset < word_char_end {
3244                return (block_start + word_char_start, block_start + word_char_end);
3245            }
3246        }
3247
3248        // Check if cursor is exactly at the end of the last word
3249        if cursor_offset == last_char_end && last_char_start < last_char_end {
3250            return (block_start + last_char_start, block_start + last_char_end);
3251        }
3252
3253        (pos, pos)
3254    }
3255
3256    /// The sentence boundaries around `pos`, as absolute char offsets, in this cursor's
3257    /// [`content_locale`](Self::content_locale). `None` when the block holds no sentence.
3258    ///
3259    /// Block-scoped like [`find_word_boundaries`](Self::find_word_boundaries) — the whole point
3260    /// of a paragraph break is that it ends a sentence.
3261    fn find_sentence_boundaries(&self, pos: usize) -> Option<(usize, usize)> {
3262        let locale = self.data.lock().content_locale.clone();
3263
3264        let inner = self.doc.lock();
3265        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
3266            position: to_i64(pos),
3267        };
3268        let block_info =
3269            document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto).ok()?;
3270        let block_start = to_usize(block_info.block_start);
3271        let block_length = to_usize(block_info.block_length);
3272        if block_length == 0 {
3273            return None;
3274        }
3275        let dto = frontend::document_inspection::GetTextAtPositionDto {
3276            position: to_i64(block_start),
3277            length: to_i64(block_length),
3278        };
3279        let text = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)
3280            .ok()?
3281            .text;
3282        drop(inner);
3283
3284        let offset = pos.saturating_sub(block_start);
3285        let (start, end) =
3286            frontend::common::parser_tools::sentence_bounds(&text, offset, locale.as_deref())?;
3287        Some((block_start + start, block_start + end))
3288    }
3289}
3290
3291// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3292// Frame-awareness helpers used by the public Cursor methods above.
3293// Each takes the locked TextDocumentInner so callers can reuse one
3294// store snapshot.
3295// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3296
3297#[derive(Clone, Copy, PartialEq, Eq)]
3298enum BlockEdge {
3299    First,
3300    Middle,
3301    Last,
3302    OnlyOne,
3303}
3304
3305/// Build a `FrameRef` for the innermost non-root frame containing
3306/// `block_id`. Returns `None` if the block sits directly in the root
3307/// frame (i.e. the only enclosing frame is the root).
3308fn cursor_frame_ref(inner: &TextDocumentInner, block_id: u64) -> Option<FrameRef> {
3309    let parent = crate::text_block::find_parent_frame(inner, block_id)?;
3310    let store = inner.ctx.db_context.get_store();
3311    let frames = store.frames.read();
3312    let frame = frames.get(&parent)?.clone();
3313    frame.parent_frame?;
3314    let is_blockquote = frame.fmt_is_blockquote.unwrap_or(false);
3315
3316    let mut depth = 0;
3317    let mut current = Some(parent);
3318    while let Some(id) = current {
3319        let Some(f) = frames.get(&id) else {
3320            break;
3321        };
3322        if f.parent_frame.is_none() {
3323            break;
3324        }
3325        depth += 1;
3326        current = f.parent_frame;
3327    }
3328
3329    Some(FrameRef {
3330        frame_id: frame.id as usize,
3331        parent_frame_id: frame.parent_frame.map(|id| id as usize),
3332        is_blockquote,
3333        depth,
3334    })
3335}
3336
3337/// Walk up the parent_frame chain from the block's immediate parent and
3338/// return the first blockquote frame found (innermost). `None` if no
3339/// enclosing frame is a blockquote.
3340fn innermost_blockquote_frame_id(inner: &TextDocumentInner, block_id: u64) -> Option<usize> {
3341    let mut current = crate::text_block::find_parent_frame(inner, block_id);
3342    let store = inner.ctx.db_context.get_store();
3343    let frames = store.frames.read();
3344    while let Some(id) = current {
3345        let f = frames.get(&id)?;
3346        if f.fmt_is_blockquote == Some(true) {
3347            return Some(f.id as usize);
3348        }
3349        current = f.parent_frame;
3350    }
3351    None
3352}
3353
3354/// Count how many blockquote frames sit on the parent_frame chain above
3355/// `block_id`. 0 if no enclosing frame is a blockquote.
3356fn blockquote_depth_for_block(inner: &TextDocumentInner, block_id: u64) -> usize {
3357    let mut current = crate::text_block::find_parent_frame(inner, block_id);
3358    let store = inner.ctx.db_context.get_store();
3359    let frames = store.frames.read();
3360    let mut count = 0;
3361    while let Some(id) = current {
3362        let Some(f) = frames.get(&id) else {
3363            break;
3364        };
3365        if f.fmt_is_blockquote == Some(true) {
3366            count += 1;
3367        }
3368        current = f.parent_frame;
3369    }
3370    count
3371}
3372
3373/// Resolve the cursor's block, find its immediate parent frame, and
3374/// determine the block's edge position within that frame's `child_order`
3375/// (counting only positive entries — sub-frames are skipped because they
3376/// are structurally different elements). Returns `None` if the cursor's
3377/// block has no entry in any frame's `child_order`.
3378fn block_position_in_current_frame(cursor: &TextCursor) -> Option<BlockEdge> {
3379    let pos = cursor.position();
3380    let inner = cursor.doc.lock();
3381    let dto = frontend::document_inspection::GetBlockAtPositionDto {
3382        position: to_i64(pos),
3383    };
3384    let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
3385    let block_id = block_info.block_id as common::types::EntityId;
3386    let parent_id = crate::text_block::find_parent_frame(&inner, block_info.block_id as u64)?;
3387    let store = inner.ctx.db_context.get_store();
3388    let frames = store.frames.read();
3389    let frame = frames.get(&parent_id)?;
3390    let block_positions: Vec<usize> = frame
3391        .child_order
3392        .iter()
3393        .enumerate()
3394        .filter_map(|(i, &e)| {
3395            if e > 0 {
3396                Some((i, e as common::types::EntityId))
3397            } else {
3398                None
3399            }
3400        })
3401        .filter(|(_, id)| *id == block_id)
3402        .map(|(i, _)| i)
3403        .collect();
3404    let block_idx = *block_positions.first()?;
3405    let positive_entries: Vec<usize> = frame
3406        .child_order
3407        .iter()
3408        .enumerate()
3409        .filter_map(|(i, &e)| if e > 0 { Some(i) } else { None })
3410        .collect();
3411    let first_pos = *positive_entries.first()?;
3412    let last_pos = *positive_entries.last()?;
3413    let is_first = block_idx == first_pos;
3414    let is_last = block_idx == last_pos;
3415    let edge = match (is_first, is_last, positive_entries.len()) {
3416        (_, _, 1) => BlockEdge::OnlyOne,
3417        (true, _, _) => BlockEdge::First,
3418        (_, true, _) => BlockEdge::Last,
3419        _ => BlockEdge::Middle,
3420    };
3421    Some(edge)
3422}