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