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