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 anyhow::Result;
8
9use crate::ListStyle;
10use frontend::commands::{
11    document_editing_commands, document_formatting_commands, document_inspection_commands,
12    inline_element_commands, undo_redo_commands,
13};
14
15use unicode_segmentation::UnicodeSegmentation;
16
17use crate::convert::{to_i64, to_usize};
18use crate::events::DocumentEvent;
19use crate::flow::{CellRange, FlowElement, SelectionKind, TableCellRef};
20use crate::fragment::DocumentFragment;
21use crate::inner::{CursorData, QueuedEvents, TextDocumentInner};
22use crate::text_table::TextTable;
23use crate::{BlockFormat, FrameFormat, MoveMode, MoveOperation, SelectionType, TextFormat};
24
25use crate::document::get_main_frame_id;
26
27/// Compute the maximum valid cursor position from document stats.
28///
29/// Cursor positions include block separators (one between each pair of adjacent
30/// blocks), but `character_count` does not. The max position is therefore
31/// `character_count + (block_count - 1)`.
32fn max_cursor_position(stats: &frontend::document_inspection::DocumentStatsDto) -> usize {
33    let chars = to_usize(stats.character_count);
34    let blocks = to_usize(stats.block_count);
35    if blocks > 1 {
36        chars + blocks - 1
37    } else {
38        chars
39    }
40}
41
42/// A cursor into a [`TextDocument`](crate::TextDocument).
43///
44/// Multiple cursors can coexist on the same document (like Qt's `QTextCursor`).
45/// When any cursor edits text, all other cursors' positions are automatically
46/// adjusted by the document.
47///
48/// Cloning a cursor creates an **independent** cursor at the same position.
49pub struct TextCursor {
50    pub(crate) doc: Arc<Mutex<TextDocumentInner>>,
51    pub(crate) data: Arc<Mutex<CursorData>>,
52}
53
54impl Clone for TextCursor {
55    fn clone(&self) -> Self {
56        let (position, anchor) = {
57            let d = self.data.lock();
58            (d.position, d.anchor)
59        };
60        let data = {
61            let mut inner = self.doc.lock();
62            let data = Arc::new(Mutex::new(CursorData {
63                position,
64                anchor,
65                cell_selection_override: None,
66            }));
67            inner.cursors.push(Arc::downgrade(&data));
68            data
69        };
70        TextCursor {
71            doc: self.doc.clone(),
72            data,
73        }
74    }
75}
76
77impl TextCursor {
78    // ── Helpers (called while doc lock is NOT held) ──────────
79
80    fn read_cursor(&self) -> (usize, usize) {
81        let d = self.data.lock();
82        (d.position, d.anchor)
83    }
84
85    /// Common post-edit bookkeeping: adjust all cursors, set this cursor to
86    /// `new_pos`, mark modified, invalidate text cache, queue a
87    /// `ContentsChanged` event, and return the queued events for dispatch.
88    fn finish_edit(
89        &self,
90        inner: &mut TextDocumentInner,
91        edit_pos: usize,
92        removed: usize,
93        new_pos: usize,
94        blocks_affected: usize,
95    ) -> QueuedEvents {
96        self.finish_edit_ext(inner, edit_pos, removed, new_pos, blocks_affected, true)
97    }
98
99    fn finish_edit_ext(
100        &self,
101        inner: &mut TextDocumentInner,
102        edit_pos: usize,
103        removed: usize,
104        new_pos: usize,
105        blocks_affected: usize,
106        flow_may_change: bool,
107    ) -> QueuedEvents {
108        let added = new_pos - edit_pos;
109        inner.adjust_cursors(edit_pos, removed, added);
110        {
111            let mut d = self.data.lock();
112            d.position = new_pos;
113            d.anchor = new_pos;
114        }
115        inner.modified = true;
116        inner.invalidate_text_cache();
117        inner.rehighlight_affected(edit_pos);
118        inner.queue_event(DocumentEvent::ContentsChanged {
119            position: edit_pos,
120            chars_removed: removed,
121            chars_added: added,
122            blocks_affected,
123        });
124        inner.check_block_count_changed();
125        if flow_may_change {
126            inner.check_flow_changed();
127        }
128        self.queue_undo_redo_event(inner)
129    }
130
131    // ── Position & selection ─────────────────────────────────
132
133    /// Current cursor position (between characters).
134    pub fn position(&self) -> usize {
135        self.data.lock().position
136    }
137
138    /// Anchor position. Equal to `position()` when no selection.
139    pub fn anchor(&self) -> usize {
140        self.data.lock().anchor
141    }
142
143    /// Returns true if there is a selection.
144    pub fn has_selection(&self) -> bool {
145        let d = self.data.lock();
146        d.position != d.anchor
147    }
148
149    /// Start of the selection (min of position and anchor).
150    pub fn selection_start(&self) -> usize {
151        let d = self.data.lock();
152        d.position.min(d.anchor)
153    }
154
155    /// End of the selection (max of position and anchor).
156    pub fn selection_end(&self) -> usize {
157        let d = self.data.lock();
158        d.position.max(d.anchor)
159    }
160
161    /// Get the selected text. Returns empty string if no selection.
162    pub fn selected_text(&self) -> Result<String> {
163        let (pos, anchor) = self.read_cursor();
164        if pos == anchor {
165            return Ok(String::new());
166        }
167        let start = pos.min(anchor);
168        let len = pos.max(anchor) - start;
169        let inner = self.doc.lock();
170        let dto = frontend::document_inspection::GetTextAtPositionDto {
171            position: to_i64(start),
172            length: to_i64(len),
173        };
174        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
175        Ok(result.text)
176    }
177
178    /// Collapse the selection by moving anchor to position.
179    pub fn clear_selection(&self) {
180        let mut d = self.data.lock();
181        d.anchor = d.position;
182    }
183
184    // ── Boundary queries ─────────────────────────────────────
185
186    /// True if the cursor is at the start of a block.
187    pub fn at_block_start(&self) -> bool {
188        let pos = self.position();
189        let inner = self.doc.lock();
190        let dto = frontend::document_inspection::GetBlockAtPositionDto {
191            position: to_i64(pos),
192        };
193        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
194            pos == to_usize(info.block_start)
195        } else {
196            false
197        }
198    }
199
200    /// True if the cursor is at the end of a block.
201    pub fn at_block_end(&self) -> bool {
202        let pos = self.position();
203        let inner = self.doc.lock();
204        let dto = frontend::document_inspection::GetBlockAtPositionDto {
205            position: to_i64(pos),
206        };
207        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
208            pos == to_usize(info.block_start) + to_usize(info.block_length)
209        } else {
210            false
211        }
212    }
213
214    /// True if the cursor is at position 0.
215    pub fn at_start(&self) -> bool {
216        self.data.lock().position == 0
217    }
218
219    /// True if the cursor is at the very end of the document.
220    pub fn at_end(&self) -> bool {
221        let pos = self.position();
222        let inner = self.doc.lock();
223        let stats = document_inspection_commands::get_document_stats(&inner.ctx).unwrap_or({
224            frontend::document_inspection::DocumentStatsDto {
225                character_count: 0,
226                word_count: 0,
227                block_count: 0,
228                frame_count: 0,
229                image_count: 0,
230                list_count: 0,
231                table_count: 0,
232            }
233        });
234        pos >= max_cursor_position(&stats)
235    }
236
237    /// The block number (0-indexed) containing the cursor.
238    pub fn block_number(&self) -> usize {
239        let pos = self.position();
240        let inner = self.doc.lock();
241        let dto = frontend::document_inspection::GetBlockAtPositionDto {
242            position: to_i64(pos),
243        };
244        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
245            .map(|info| to_usize(info.block_number))
246            .unwrap_or(0)
247    }
248
249    /// The cursor's column within the current block (0-indexed).
250    pub fn position_in_block(&self) -> usize {
251        let pos = self.position();
252        let inner = self.doc.lock();
253        let dto = frontend::document_inspection::GetBlockAtPositionDto {
254            position: to_i64(pos),
255        };
256        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
257            .map(|info| pos.saturating_sub(to_usize(info.block_start)))
258            .unwrap_or(0)
259    }
260
261    // ── Movement ─────────────────────────────────────────────
262
263    /// Set the cursor to an absolute position.
264    ///
265    /// When extending a selection (`KeepAnchor`) across a table boundary,
266    /// the position is snapped to the adjacent block outside the table so
267    /// the entire table is "trapped" inside the selection range. This
268    /// mirrors LibreOffice's behaviour: partial table selections from
269    /// outside are not allowed; the table is always fully enclosed.
270    ///
271    /// The snap is skipped when:
272    /// - `mode` is `MoveAnchor` (plain click / move without selection)
273    /// - No adjacent block exists (table is first or last in the document)
274    pub fn set_position(&self, position: usize, mode: MoveMode) {
275        // Clamp to max document position (includes block separators)
276        let end = {
277            let inner = self.doc.lock();
278            document_inspection_commands::get_document_stats(&inner.ctx)
279                .map(|s| max_cursor_position(&s))
280                .unwrap_or(0)
281        };
282        let mut pos = position.min(end);
283
284        // Table-trap snap: when extending a selection, if one endpoint is
285        // inside a table and the other is outside, relocate the inside
286        // endpoint to the boundary of the adjacent block.
287        if mode == MoveMode::KeepAnchor {
288            let anchor = self.data.lock().anchor;
289            let pos_cell = self.table_cell_at(pos);
290            let anchor_cell = self.table_cell_at(anchor);
291            match (&pos_cell, &anchor_cell) {
292                (Some(tc), None) => {
293                    // Position is inside a table, anchor is outside.
294                    let before = anchor < pos;
295                    if let Some(boundary) = self.table_boundary_position(tc.table.id(), !before) {
296                        pos = boundary;
297                    }
298                }
299                (None, Some(tc)) => {
300                    // Anchor is inside a table, position is outside.
301                    // Snap the position so the table is enclosed.
302                    let before = pos < anchor;
303                    if let Some(boundary) = self.table_boundary_position(tc.table.id(), !before) {
304                        pos = boundary;
305                    }
306                }
307                _ => {}
308            }
309        }
310
311        let mut d = self.data.lock();
312        d.position = pos;
313        if mode == MoveMode::MoveAnchor {
314            d.anchor = pos;
315        }
316        d.cell_selection_override = None;
317    }
318
319    /// Move the cursor by a semantic operation.
320    ///
321    /// `n` is used as a repeat count for character-level movements
322    /// (`NextCharacter`, `PreviousCharacter`, `Left`, `Right`).
323    /// For all other operations it is ignored. Returns `true` if the cursor moved.
324    pub fn move_position(&self, operation: MoveOperation, mode: MoveMode, n: usize) -> bool {
325        let old_pos = self.position();
326        let target = self.resolve_move(operation, n);
327        self.set_position(target, mode);
328        self.position() != old_pos
329    }
330
331    /// Select a region relative to the cursor position.
332    pub fn select(&self, selection: SelectionType) {
333        match selection {
334            SelectionType::Document => {
335                let end = {
336                    let inner = self.doc.lock();
337                    document_inspection_commands::get_document_stats(&inner.ctx)
338                        .map(|s| max_cursor_position(&s))
339                        .unwrap_or(0)
340                };
341                let mut d = self.data.lock();
342                d.anchor = 0;
343                d.position = end;
344                d.cell_selection_override = None;
345            }
346            SelectionType::BlockUnderCursor | SelectionType::LineUnderCursor => {
347                let pos = self.position();
348                let inner = self.doc.lock();
349                let dto = frontend::document_inspection::GetBlockAtPositionDto {
350                    position: to_i64(pos),
351                };
352                if let Ok(info) =
353                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
354                {
355                    let start = to_usize(info.block_start);
356                    let end = start + to_usize(info.block_length);
357                    drop(inner);
358                    let mut d = self.data.lock();
359                    d.anchor = start;
360                    d.position = end;
361                    d.cell_selection_override = None;
362                }
363            }
364            SelectionType::WordUnderCursor => {
365                let pos = self.position();
366                let (word_start, word_end) = self.find_word_boundaries(pos);
367                let mut d = self.data.lock();
368                d.anchor = word_start;
369                d.position = word_end;
370                d.cell_selection_override = None;
371            }
372        }
373    }
374
375    // ── Text editing ─────────────────────────────────────────
376
377    /// Insert plain text at the cursor. Replaces selection if any.
378    pub fn insert_text(&self, text: &str) -> Result<()> {
379        let (pos, anchor) = self.read_cursor();
380
381        // Try direct insert first (handles same-block selection and no-selection cases)
382        let dto = frontend::document_editing::InsertTextDto {
383            position: to_i64(pos),
384            anchor: to_i64(anchor),
385            text: text.into(),
386        };
387
388        let queued = {
389            let mut inner = self.doc.lock();
390            let result = match document_editing_commands::insert_text(
391                &inner.ctx,
392                Some(inner.stack_id),
393                &dto,
394            ) {
395                Ok(r) => r,
396                Err(_) if pos != anchor => {
397                    // Cross-block selection: compose delete + insert as a single undo unit
398                    undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
399
400                    let del_dto = frontend::document_editing::DeleteTextDto {
401                        position: to_i64(pos),
402                        anchor: to_i64(anchor),
403                    };
404                    let del_result = document_editing_commands::delete_text(
405                        &inner.ctx,
406                        Some(inner.stack_id),
407                        &del_dto,
408                    )?;
409                    let del_pos = to_usize(del_result.new_position);
410
411                    let ins_dto = frontend::document_editing::InsertTextDto {
412                        position: to_i64(del_pos),
413                        anchor: to_i64(del_pos),
414                        text: text.into(),
415                    };
416                    let ins_result = document_editing_commands::insert_text(
417                        &inner.ctx,
418                        Some(inner.stack_id),
419                        &ins_dto,
420                    )?;
421
422                    undo_redo_commands::end_composite(&inner.ctx);
423                    ins_result
424                }
425                Err(e) => return Err(e),
426            };
427
428            let edit_pos = pos.min(anchor);
429            let removed = pos.max(anchor) - edit_pos;
430            self.finish_edit_ext(
431                &mut inner,
432                edit_pos,
433                removed,
434                to_usize(result.new_position),
435                to_usize(result.blocks_affected),
436                false,
437            )
438        };
439        crate::inner::dispatch_queued_events(queued);
440        Ok(())
441    }
442
443    /// Insert text with a specific character format. Replaces selection if any.
444    pub fn insert_formatted_text(&self, text: &str, format: &TextFormat) -> Result<()> {
445        let (pos, anchor) = self.read_cursor();
446
447        let make_dto = |p: usize, a: usize| frontend::document_editing::InsertFormattedTextDto {
448            position: to_i64(p),
449            anchor: to_i64(a),
450            text: text.into(),
451            font_family: format.font_family.clone().unwrap_or_default(),
452            font_point_size: format.font_point_size.map(|v| v as i64).unwrap_or(0),
453            font_bold: format.font_bold.unwrap_or(false),
454            font_italic: format.font_italic.unwrap_or(false),
455            font_underline: format.font_underline.unwrap_or(false),
456            font_strikeout: format.font_strikeout.unwrap_or(false),
457        };
458
459        let queued = {
460            let mut inner = self.doc.lock();
461            let result = match document_editing_commands::insert_formatted_text(
462                &inner.ctx,
463                Some(inner.stack_id),
464                &make_dto(pos, anchor),
465            ) {
466                Ok(r) => r,
467                Err(_) if pos != anchor => {
468                    // Cross-block selection: compose delete + insert as a single undo unit
469                    undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
470
471                    let del_dto = frontend::document_editing::DeleteTextDto {
472                        position: to_i64(pos),
473                        anchor: to_i64(anchor),
474                    };
475                    let del_result = document_editing_commands::delete_text(
476                        &inner.ctx,
477                        Some(inner.stack_id),
478                        &del_dto,
479                    )?;
480                    let del_pos = to_usize(del_result.new_position);
481
482                    let ins_result = document_editing_commands::insert_formatted_text(
483                        &inner.ctx,
484                        Some(inner.stack_id),
485                        &make_dto(del_pos, del_pos),
486                    )?;
487
488                    undo_redo_commands::end_composite(&inner.ctx);
489                    ins_result
490                }
491                Err(e) => return Err(e),
492            };
493
494            let edit_pos = pos.min(anchor);
495            let removed = pos.max(anchor) - edit_pos;
496            self.finish_edit_ext(
497                &mut inner,
498                edit_pos,
499                removed,
500                to_usize(result.new_position),
501                1,
502                false,
503            )
504        };
505        crate::inner::dispatch_queued_events(queued);
506        Ok(())
507    }
508
509    /// Insert a block break (new paragraph). Replaces selection if any.
510    pub fn insert_block(&self) -> Result<()> {
511        let (pos, anchor) = self.read_cursor();
512        let queued = {
513            let mut inner = self.doc.lock();
514
515            let (insert_pos, removed) = if pos != anchor {
516                // Selection active: delete first, then split (Word convention)
517                undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
518                let del_dto = frontend::document_editing::DeleteTextDto {
519                    position: to_i64(pos),
520                    anchor: to_i64(anchor),
521                };
522                let del_result = document_editing_commands::delete_text(
523                    &inner.ctx,
524                    Some(inner.stack_id),
525                    &del_dto,
526                )?;
527                (
528                    to_usize(del_result.new_position),
529                    pos.max(anchor) - pos.min(anchor),
530                )
531            } else {
532                (pos, 0)
533            };
534
535            let dto = frontend::document_editing::InsertBlockDto {
536                position: to_i64(insert_pos),
537                anchor: to_i64(insert_pos),
538            };
539            let result =
540                document_editing_commands::insert_block(&inner.ctx, Some(inner.stack_id), &dto)?;
541
542            if pos != anchor {
543                undo_redo_commands::end_composite(&inner.ctx);
544            }
545
546            let edit_pos = pos.min(anchor);
547            self.finish_edit(
548                &mut inner,
549                edit_pos,
550                removed,
551                to_usize(result.new_position),
552                2,
553            )
554        };
555        crate::inner::dispatch_queued_events(queued);
556        Ok(())
557    }
558
559    /// Insert an HTML fragment at the cursor position. Replaces selection if any.
560    pub fn insert_html(&self, html: &str) -> Result<()> {
561        // Delegate to insert_fragment so table structure is preserved.
562        let frag = DocumentFragment::from_html(html);
563        self.insert_fragment(&frag)
564    }
565
566    /// Insert a Markdown fragment at the cursor position. Replaces selection if any.
567    pub fn insert_markdown(&self, markdown: &str) -> Result<()> {
568        let frag = DocumentFragment::from_markdown(markdown);
569        self.insert_fragment(&frag)
570    }
571
572    /// Insert a document fragment at the cursor. Replaces selection if any.
573    pub fn insert_fragment(&self, fragment: &DocumentFragment) -> Result<()> {
574        let (pos, anchor) = self.read_cursor();
575        let queued = {
576            let mut inner = self.doc.lock();
577
578            let (insert_pos, removed) = if pos != anchor {
579                undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
580                let del_dto = frontend::document_editing::DeleteTextDto {
581                    position: to_i64(pos),
582                    anchor: to_i64(anchor),
583                };
584                let del_result = document_editing_commands::delete_text(
585                    &inner.ctx,
586                    Some(inner.stack_id),
587                    &del_dto,
588                )?;
589                (
590                    to_usize(del_result.new_position),
591                    pos.max(anchor) - pos.min(anchor),
592                )
593            } else {
594                (pos, 0)
595            };
596
597            let dto = frontend::document_editing::InsertFragmentDto {
598                position: to_i64(insert_pos),
599                anchor: to_i64(insert_pos),
600                fragment_data: fragment.raw_data().into(),
601            };
602            let result =
603                document_editing_commands::insert_fragment(&inner.ctx, Some(inner.stack_id), &dto)?;
604
605            if pos != anchor {
606                undo_redo_commands::end_composite(&inner.ctx);
607            }
608
609            let edit_pos = pos.min(anchor);
610            self.finish_edit(
611                &mut inner,
612                edit_pos,
613                removed,
614                to_usize(result.new_position),
615                to_usize(result.blocks_added),
616            )
617        };
618        crate::inner::dispatch_queued_events(queued);
619        Ok(())
620    }
621
622    /// Extract the current selection as a [`DocumentFragment`].
623    pub fn selection(&self) -> DocumentFragment {
624        let (pos, anchor) = self.read_cursor();
625
626        // For cell/mixed selections, compute position/anchor that span the
627        // full cell range so ExtractFragment detects cross-cell correctly.
628        let (extract_pos, extract_anchor) = match self.selection_kind() {
629            SelectionKind::Cells(ref range) => match self.cell_range_positions(range) {
630                Some((start, end)) => (start, end),
631                None => return DocumentFragment::new(),
632            },
633            SelectionKind::Mixed {
634                ref cell_range,
635                text_before,
636                text_after,
637            } => {
638                let (cell_start, cell_end) = match self.cell_range_positions(cell_range) {
639                    Some(p) => p,
640                    None => return DocumentFragment::new(),
641                };
642                let start = if text_before {
643                    pos.min(anchor)
644                } else {
645                    cell_start
646                };
647                let end = if text_after {
648                    pos.max(anchor)
649                } else {
650                    cell_end
651                };
652                (start.min(cell_start), end.max(cell_end))
653            }
654            SelectionKind::None => return DocumentFragment::new(),
655            SelectionKind::Text => (pos, anchor),
656        };
657
658        if extract_pos == extract_anchor {
659            return DocumentFragment::new();
660        }
661
662        let inner = self.doc.lock();
663        let dto = frontend::document_inspection::ExtractFragmentDto {
664            position: to_i64(extract_pos),
665            anchor: to_i64(extract_anchor),
666        };
667        match document_inspection_commands::extract_fragment(&inner.ctx, &dto) {
668            Ok(result) => DocumentFragment::from_raw(result.fragment_data, result.plain_text),
669            Err(_) => DocumentFragment::new(),
670        }
671    }
672
673    /// Insert an image at the cursor. Replaces selection if any.
674    pub fn insert_image(&self, name: &str, width: u32, height: u32) -> Result<()> {
675        let (pos, anchor) = self.read_cursor();
676        let queued = {
677            let mut inner = self.doc.lock();
678
679            let (insert_pos, removed) = if pos != anchor {
680                undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
681                let del_dto = frontend::document_editing::DeleteTextDto {
682                    position: to_i64(pos),
683                    anchor: to_i64(anchor),
684                };
685                let del_result = document_editing_commands::delete_text(
686                    &inner.ctx,
687                    Some(inner.stack_id),
688                    &del_dto,
689                )?;
690                (
691                    to_usize(del_result.new_position),
692                    pos.max(anchor) - pos.min(anchor),
693                )
694            } else {
695                (pos, 0)
696            };
697
698            let dto = frontend::document_editing::InsertImageDto {
699                position: to_i64(insert_pos),
700                anchor: to_i64(insert_pos),
701                image_name: name.into(),
702                width: width as i64,
703                height: height as i64,
704            };
705            let result =
706                document_editing_commands::insert_image(&inner.ctx, Some(inner.stack_id), &dto)?;
707
708            if pos != anchor {
709                undo_redo_commands::end_composite(&inner.ctx);
710            }
711
712            let edit_pos = pos.min(anchor);
713            self.finish_edit_ext(
714                &mut inner,
715                edit_pos,
716                removed,
717                to_usize(result.new_position),
718                1,
719                false,
720            )
721        };
722        crate::inner::dispatch_queued_events(queued);
723        Ok(())
724    }
725
726    /// Insert a new frame at the cursor.
727    pub fn insert_frame(&self) -> Result<()> {
728        let (pos, anchor) = self.read_cursor();
729        let queued = {
730            let mut inner = self.doc.lock();
731            let dto = frontend::document_editing::InsertFrameDto {
732                position: to_i64(pos),
733                anchor: to_i64(anchor),
734            };
735            document_editing_commands::insert_frame(&inner.ctx, Some(inner.stack_id), &dto)?;
736            // Frame insertion adds structural content; adjust cursors and emit event.
737            // The backend doesn't return a new_position, so the cursor stays put.
738            inner.modified = true;
739            inner.invalidate_text_cache();
740            inner.rehighlight_affected(pos.min(anchor));
741            inner.queue_event(DocumentEvent::ContentsChanged {
742                position: pos.min(anchor),
743                chars_removed: 0,
744                chars_added: 0,
745                blocks_affected: 1,
746            });
747            inner.check_block_count_changed();
748            inner.check_flow_changed();
749            self.queue_undo_redo_event(&mut inner)
750        };
751        crate::inner::dispatch_queued_events(queued);
752        Ok(())
753    }
754
755    /// Insert a table at the cursor position.
756    ///
757    /// Creates a `rows × columns` table with empty cells.
758    /// The cursor moves into the first cell of the table.
759    /// Returns a handle to the created table.
760    pub fn insert_table(&self, rows: usize, columns: usize) -> Result<TextTable> {
761        let (pos, anchor) = self.read_cursor();
762        let (table_id, queued) = {
763            let mut inner = self.doc.lock();
764            let dto = frontend::document_editing::InsertTableDto {
765                position: to_i64(pos),
766                anchor: to_i64(anchor),
767                rows: to_i64(rows),
768                columns: to_i64(columns),
769            };
770            let result =
771                document_editing_commands::insert_table(&inner.ctx, Some(inner.stack_id), &dto)?;
772            let new_pos = to_usize(result.new_position);
773            let table_id = to_usize(result.table_id);
774            inner.adjust_cursors(pos.min(anchor), 0, new_pos - pos.min(anchor));
775            {
776                let mut d = self.data.lock();
777                d.position = new_pos;
778                d.anchor = new_pos;
779            }
780            inner.modified = true;
781            inner.invalidate_text_cache();
782            inner.rehighlight_affected(pos.min(anchor));
783            inner.queue_event(DocumentEvent::ContentsChanged {
784                position: pos.min(anchor),
785                chars_removed: 0,
786                chars_added: new_pos - pos.min(anchor),
787                blocks_affected: 1,
788            });
789            inner.check_block_count_changed();
790            inner.check_flow_changed();
791            (table_id, self.queue_undo_redo_event(&mut inner))
792        };
793        crate::inner::dispatch_queued_events(queued);
794        Ok(TextTable {
795            doc: self.doc.clone(),
796            table_id,
797        })
798    }
799
800    /// Returns the table the cursor is currently inside, if any.
801    ///
802    /// Returns `None` if the cursor is in the main document flow
803    /// (not inside a table cell).
804    pub fn current_table(&self) -> Option<TextTable> {
805        self.current_table_cell().map(|c| c.table)
806    }
807
808    /// Returns the table cell the cursor is currently inside, if any.
809    ///
810    /// Returns `None` if the cursor is not inside a table cell.
811    /// When `Some`, provides the table, row, and column.
812    pub fn current_table_cell(&self) -> Option<TableCellRef> {
813        let pos = self.position();
814        let inner = self.doc.lock();
815        // Find the block at cursor position
816        let dto = frontend::document_inspection::GetBlockAtPositionDto {
817            position: to_i64(pos),
818        };
819        let block_info =
820            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
821
822        // When position < block_start, the cursor sits on the separator between
823        // the previous block and this one. Visually the cursor belongs to the
824        // end of the previous block, so look up that block instead.
825        let block_id = if to_i64(pos) < block_info.block_start && pos > 0 {
826            let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
827                position: to_i64(pos - 1),
828            };
829            let prev_info =
830                document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto).ok()?;
831            prev_info.block_id as usize
832        } else {
833            block_info.block_id as usize
834        };
835
836        let block = crate::text_block::TextBlock {
837            doc: self.doc.clone(),
838            block_id,
839        };
840        // Release inner lock before calling table_cell() which also locks
841        drop(inner);
842        block.table_cell()
843    }
844
845    // ── Table structure mutations (explicit-ID) ──────────
846
847    /// Remove a table from the document by its ID.
848    pub fn remove_table(&self, table_id: usize) -> Result<()> {
849        let queued = {
850            let mut inner = self.doc.lock();
851            let dto = frontend::document_editing::RemoveTableDto {
852                table_id: to_i64(table_id),
853            };
854            document_editing_commands::remove_table(&inner.ctx, Some(inner.stack_id), &dto)?;
855            inner.modified = true;
856            inner.invalidate_text_cache();
857            inner.rehighlight_all();
858            inner.check_block_count_changed();
859            inner.check_flow_changed();
860            self.queue_undo_redo_event(&mut inner)
861        };
862        crate::inner::dispatch_queued_events(queued);
863        Ok(())
864    }
865
866    /// Insert a row into a table at the given index.
867    pub fn insert_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
868        let queued = {
869            let mut inner = self.doc.lock();
870            let dto = frontend::document_editing::InsertTableRowDto {
871                table_id: to_i64(table_id),
872                row_index: to_i64(row_index),
873            };
874            document_editing_commands::insert_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
875            inner.modified = true;
876            inner.invalidate_text_cache();
877            inner.rehighlight_all();
878            inner.check_block_count_changed();
879            self.queue_undo_redo_event(&mut inner)
880        };
881        crate::inner::dispatch_queued_events(queued);
882        Ok(())
883    }
884
885    /// Insert a column into a table at the given index.
886    pub fn insert_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
887        let queued = {
888            let mut inner = self.doc.lock();
889            let dto = frontend::document_editing::InsertTableColumnDto {
890                table_id: to_i64(table_id),
891                column_index: to_i64(column_index),
892            };
893            document_editing_commands::insert_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
894            inner.modified = true;
895            inner.invalidate_text_cache();
896            inner.rehighlight_all();
897            inner.check_block_count_changed();
898            self.queue_undo_redo_event(&mut inner)
899        };
900        crate::inner::dispatch_queued_events(queued);
901        Ok(())
902    }
903
904    /// Remove a row from a table. Fails if only one row remains.
905    pub fn remove_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
906        let queued = {
907            let mut inner = self.doc.lock();
908            let dto = frontend::document_editing::RemoveTableRowDto {
909                table_id: to_i64(table_id),
910                row_index: to_i64(row_index),
911            };
912            document_editing_commands::remove_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
913            inner.modified = true;
914            inner.invalidate_text_cache();
915            inner.rehighlight_all();
916            inner.check_block_count_changed();
917            self.queue_undo_redo_event(&mut inner)
918        };
919        crate::inner::dispatch_queued_events(queued);
920        Ok(())
921    }
922
923    /// Remove a column from a table. Fails if only one column remains.
924    pub fn remove_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
925        let queued = {
926            let mut inner = self.doc.lock();
927            let dto = frontend::document_editing::RemoveTableColumnDto {
928                table_id: to_i64(table_id),
929                column_index: to_i64(column_index),
930            };
931            document_editing_commands::remove_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
932            inner.modified = true;
933            inner.invalidate_text_cache();
934            inner.rehighlight_all();
935            inner.check_block_count_changed();
936            self.queue_undo_redo_event(&mut inner)
937        };
938        crate::inner::dispatch_queued_events(queued);
939        Ok(())
940    }
941
942    /// Merge a rectangular range of cells within a table.
943    pub fn merge_table_cells(
944        &self,
945        table_id: usize,
946        start_row: usize,
947        start_column: usize,
948        end_row: usize,
949        end_column: usize,
950    ) -> Result<()> {
951        let queued = {
952            let mut inner = self.doc.lock();
953            let dto = frontend::document_editing::MergeTableCellsDto {
954                table_id: to_i64(table_id),
955                start_row: to_i64(start_row),
956                start_column: to_i64(start_column),
957                end_row: to_i64(end_row),
958                end_column: to_i64(end_column),
959            };
960            document_editing_commands::merge_table_cells(&inner.ctx, Some(inner.stack_id), &dto)?;
961            inner.modified = true;
962            inner.invalidate_text_cache();
963            inner.rehighlight_all();
964            inner.check_block_count_changed();
965            self.queue_undo_redo_event(&mut inner)
966        };
967        crate::inner::dispatch_queued_events(queued);
968        Ok(())
969    }
970
971    /// Split a previously merged cell.
972    pub fn split_table_cell(
973        &self,
974        cell_id: usize,
975        split_rows: usize,
976        split_columns: usize,
977    ) -> Result<()> {
978        let queued = {
979            let mut inner = self.doc.lock();
980            let dto = frontend::document_editing::SplitTableCellDto {
981                cell_id: to_i64(cell_id),
982                split_rows: to_i64(split_rows),
983                split_columns: to_i64(split_columns),
984            };
985            document_editing_commands::split_table_cell(&inner.ctx, Some(inner.stack_id), &dto)?;
986            inner.modified = true;
987            inner.invalidate_text_cache();
988            inner.rehighlight_all();
989            inner.check_block_count_changed();
990            self.queue_undo_redo_event(&mut inner)
991        };
992        crate::inner::dispatch_queued_events(queued);
993        Ok(())
994    }
995
996    // ── Table formatting (explicit-ID) ───────────────────
997
998    /// Set formatting on a table.
999    pub fn set_table_format(
1000        &self,
1001        table_id: usize,
1002        format: &crate::flow::TableFormat,
1003    ) -> Result<()> {
1004        let queued = {
1005            let mut inner = self.doc.lock();
1006            let dto = format.to_set_dto(table_id);
1007            document_formatting_commands::set_table_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1008            inner.modified = true;
1009            inner.queue_event(DocumentEvent::FormatChanged {
1010                position: 0,
1011                length: 0,
1012                kind: crate::flow::FormatChangeKind::Block,
1013            });
1014            self.queue_undo_redo_event(&mut inner)
1015        };
1016        crate::inner::dispatch_queued_events(queued);
1017        Ok(())
1018    }
1019
1020    /// Set formatting on a table cell.
1021    pub fn set_table_cell_format(
1022        &self,
1023        cell_id: usize,
1024        format: &crate::flow::CellFormat,
1025    ) -> Result<()> {
1026        let queued = {
1027            let mut inner = self.doc.lock();
1028            let dto = format.to_set_dto(cell_id);
1029            document_formatting_commands::set_table_cell_format(
1030                &inner.ctx,
1031                Some(inner.stack_id),
1032                &dto,
1033            )?;
1034            inner.modified = true;
1035            inner.queue_event(DocumentEvent::FormatChanged {
1036                position: 0,
1037                length: 0,
1038                kind: crate::flow::FormatChangeKind::Block,
1039            });
1040            self.queue_undo_redo_event(&mut inner)
1041        };
1042        crate::inner::dispatch_queued_events(queued);
1043        Ok(())
1044    }
1045
1046    // ── Table convenience (position-based) ───────────────
1047
1048    /// Remove the table the cursor is currently inside.
1049    /// Returns an error if the cursor is not inside a table.
1050    pub fn remove_current_table(&self) -> Result<()> {
1051        let table = self
1052            .current_table()
1053            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1054        self.remove_table(table.id())
1055    }
1056
1057    /// Insert a row above the cursor's current row.
1058    /// Returns an error if the cursor is not inside a table.
1059    pub fn insert_row_above(&self) -> Result<()> {
1060        let cell_ref = self
1061            .current_table_cell()
1062            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1063        self.insert_table_row(cell_ref.table.id(), cell_ref.row)
1064    }
1065
1066    /// Insert a row below the cursor's current row.
1067    /// Returns an error if the cursor is not inside a table.
1068    pub fn insert_row_below(&self) -> Result<()> {
1069        let cell_ref = self
1070            .current_table_cell()
1071            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1072        self.insert_table_row(cell_ref.table.id(), cell_ref.row + 1)
1073    }
1074
1075    /// Insert a column before the cursor's current column.
1076    /// Returns an error if the cursor is not inside a table.
1077    pub fn insert_column_before(&self) -> Result<()> {
1078        let cell_ref = self
1079            .current_table_cell()
1080            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1081        self.insert_table_column(cell_ref.table.id(), cell_ref.column)
1082    }
1083
1084    /// Insert a column after the cursor's current column.
1085    /// Returns an error if the cursor is not inside a table.
1086    pub fn insert_column_after(&self) -> Result<()> {
1087        let cell_ref = self
1088            .current_table_cell()
1089            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1090        self.insert_table_column(cell_ref.table.id(), cell_ref.column + 1)
1091    }
1092
1093    /// Remove the row at the cursor's current position.
1094    /// Returns an error if the cursor is not inside a table.
1095    pub fn remove_current_row(&self) -> Result<()> {
1096        let cell_ref = self
1097            .current_table_cell()
1098            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1099        self.remove_table_row(cell_ref.table.id(), cell_ref.row)
1100    }
1101
1102    /// Remove the column at the cursor's current position.
1103    /// Returns an error if the cursor is not inside a table.
1104    pub fn remove_current_column(&self) -> Result<()> {
1105        let cell_ref = self
1106            .current_table_cell()
1107            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1108        self.remove_table_column(cell_ref.table.id(), cell_ref.column)
1109    }
1110
1111    /// Merge cells spanned by the current selection.
1112    ///
1113    /// Both cursor position and anchor must be inside the same table.
1114    /// The cell range is derived from the cells at position and anchor.
1115    /// Returns an error if the cursor is not inside a table or position
1116    /// and anchor are in different tables.
1117    pub fn merge_selected_cells(&self) -> Result<()> {
1118        let pos_cell = self
1119            .current_table_cell()
1120            .ok_or_else(|| anyhow::anyhow!("cursor position is not inside a table"))?;
1121
1122        // Get anchor cell
1123        let (_pos, anchor) = self.read_cursor();
1124        let anchor_cell = {
1125            // Create a temporary block handle at the anchor position
1126            let inner = self.doc.lock();
1127            let dto = frontend::document_inspection::GetBlockAtPositionDto {
1128                position: to_i64(anchor),
1129            };
1130            let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1131                .map_err(|_| anyhow::anyhow!("cursor anchor is not inside a table"))?;
1132            let block = crate::text_block::TextBlock {
1133                doc: self.doc.clone(),
1134                block_id: block_info.block_id as usize,
1135            };
1136            drop(inner);
1137            block
1138                .table_cell()
1139                .ok_or_else(|| anyhow::anyhow!("cursor anchor is not inside a table"))?
1140        };
1141
1142        if pos_cell.table.id() != anchor_cell.table.id() {
1143            return Err(anyhow::anyhow!(
1144                "position and anchor are in different tables"
1145            ));
1146        }
1147
1148        let start_row = pos_cell.row.min(anchor_cell.row);
1149        let start_col = pos_cell.column.min(anchor_cell.column);
1150        let end_row = pos_cell.row.max(anchor_cell.row);
1151        let end_col = pos_cell.column.max(anchor_cell.column);
1152
1153        self.merge_table_cells(pos_cell.table.id(), start_row, start_col, end_row, end_col)
1154    }
1155
1156    /// Split the cell at the cursor's current position.
1157    /// Returns an error if the cursor is not inside a table.
1158    pub fn split_current_cell(&self, split_rows: usize, split_columns: usize) -> Result<()> {
1159        let cell_ref = self
1160            .current_table_cell()
1161            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1162        // Get the cell entity ID from the table handle
1163        let cell = cell_ref
1164            .table
1165            .cell(cell_ref.row, cell_ref.column)
1166            .ok_or_else(|| anyhow::anyhow!("cell not found"))?;
1167        // TextTableCell stores cell_id
1168        self.split_table_cell(cell.id(), split_rows, split_columns)
1169    }
1170
1171    /// Set formatting on the table the cursor is currently inside.
1172    /// Returns an error if the cursor is not inside a table.
1173    pub fn set_current_table_format(&self, format: &crate::flow::TableFormat) -> Result<()> {
1174        let table = self
1175            .current_table()
1176            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1177        self.set_table_format(table.id(), format)
1178    }
1179
1180    /// Set formatting on the cell the cursor is currently inside.
1181    /// Returns an error if the cursor is not inside a table.
1182    pub fn set_current_cell_format(&self, format: &crate::flow::CellFormat) -> Result<()> {
1183        let cell_ref = self
1184            .current_table_cell()
1185            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1186        let cell = cell_ref
1187            .table
1188            .cell(cell_ref.row, cell_ref.column)
1189            .ok_or_else(|| anyhow::anyhow!("cell not found"))?;
1190        self.set_table_cell_format(cell.id(), format)
1191    }
1192
1193    // ── Cell selection queries ────────────────────────────────
1194
1195    /// Determine the kind of selection the cursor currently has.
1196    ///
1197    /// Returns [`Cells`](crate::SelectionKind::Cells) when position and anchor are in
1198    /// different cells of the same table (rectangular cell selection), or
1199    /// when an explicit cell-selection override is active.
1200    pub fn selection_kind(&self) -> crate::flow::SelectionKind {
1201        use crate::flow::{CellRange, SelectionKind};
1202
1203        // Check override first
1204        {
1205            let d = self.data.lock();
1206            if let Some(ref range) = d.cell_selection_override {
1207                return SelectionKind::Cells(range.clone());
1208            }
1209            if d.position == d.anchor {
1210                return SelectionKind::None;
1211            }
1212        }
1213
1214        let (pos, anchor) = self.read_cursor();
1215
1216        // Look up table cell for position and anchor
1217        let pos_cell = self.table_cell_at(pos);
1218        let anchor_cell = self.table_cell_at(anchor);
1219
1220        match (&pos_cell, &anchor_cell) {
1221            (None, None) => {
1222                // Both endpoints are outside tables. Check whether a table
1223                // sits between them — if so, all its cells must be selected
1224                // (Word behaviour).
1225                let (start, end) = (pos.min(anchor), pos.max(anchor));
1226                if let Some(t) = self.find_table_between(start, end) {
1227                    let table_id = t.id();
1228                    let rows = t.rows();
1229                    let cols = t.columns();
1230                    let range = CellRange {
1231                        table_id,
1232                        start_row: 0,
1233                        start_col: 0,
1234                        end_row: if rows > 0 { rows - 1 } else { 0 },
1235                        end_col: if cols > 0 { cols - 1 } else { 0 },
1236                    };
1237                    let spans = self.collect_cell_spans(table_id);
1238                    SelectionKind::Mixed {
1239                        cell_range: range.expand_for_spans(&spans),
1240                        text_before: true,
1241                        text_after: true,
1242                    }
1243                } else {
1244                    SelectionKind::Text
1245                }
1246            }
1247            (Some(pc), Some(ac)) => {
1248                if pc.table.id() != ac.table.id() {
1249                    // Different tables — treat as text (whole tables selected between them)
1250                    return SelectionKind::Text;
1251                }
1252                if pc.row == ac.row && pc.column == ac.column {
1253                    // Same cell — text selection within one cell
1254                    return SelectionKind::Text;
1255                }
1256                // Different cells, same table — rectangular cell selection
1257                let range = CellRange {
1258                    table_id: pc.table.id(),
1259                    start_row: pc.row.min(ac.row),
1260                    start_col: pc.column.min(ac.column),
1261                    end_row: pc.row.max(ac.row),
1262                    end_col: pc.column.max(ac.column),
1263                };
1264                let spans = self.collect_cell_spans(pc.table.id());
1265                SelectionKind::Cells(range.expand_for_spans(&spans))
1266            }
1267            (Some(tc), None) | (None, Some(tc)) => {
1268                // One endpoint inside a table, the other outside — mixed
1269                // selection.  Following Word behaviour, select ALL cells in
1270                // the table (not just from the entry edge to the cursor row).
1271                let table_id = tc.table.id();
1272                let rows = tc.table.rows();
1273                let cols = tc.table.columns();
1274
1275                let inside_pos = if pos_cell.is_some() { pos } else { anchor };
1276                let outside_pos = if pos_cell.is_some() { anchor } else { pos };
1277
1278                let text_before = outside_pos < inside_pos;
1279                let text_after = !text_before;
1280
1281                let range = CellRange {
1282                    table_id,
1283                    start_row: 0,
1284                    start_col: 0,
1285                    end_row: if rows > 0 { rows - 1 } else { 0 },
1286                    end_col: if cols > 0 { cols - 1 } else { 0 },
1287                };
1288                let spans = self.collect_cell_spans(table_id);
1289                SelectionKind::Mixed {
1290                    cell_range: range.expand_for_spans(&spans),
1291                    text_before,
1292                    text_after,
1293                }
1294            }
1295        }
1296    }
1297
1298    /// Returns `true` when the current selection involves whole-cell selection.
1299    pub fn is_cell_selection(&self) -> bool {
1300        matches!(
1301            self.selection_kind(),
1302            crate::flow::SelectionKind::Cells(_) | crate::flow::SelectionKind::Mixed { .. }
1303        )
1304    }
1305
1306    /// Returns the rectangular cell range if the cursor has a cell selection.
1307    pub fn selected_cell_range(&self) -> Option<crate::flow::CellRange> {
1308        match self.selection_kind() {
1309            crate::flow::SelectionKind::Cells(r) => Some(r),
1310            crate::flow::SelectionKind::Mixed { cell_range, .. } => Some(cell_range),
1311            _ => None,
1312        }
1313    }
1314
1315    /// Returns all cells in the selected rectangular range.
1316    pub fn selected_cells(&self) -> Vec<TableCellRef> {
1317        let range = match self.selected_cell_range() {
1318            Some(r) => r,
1319            None => return Vec::new(),
1320        };
1321        let table = TextTable {
1322            doc: self.doc.clone(),
1323            table_id: range.table_id,
1324        };
1325        let mut cells = Vec::new();
1326        for row in range.start_row..=range.end_row {
1327            for col in range.start_col..=range.end_col {
1328                if table.cell(row, col).is_some() {
1329                    cells.push(TableCellRef {
1330                        table: table.clone(),
1331                        row,
1332                        column: col,
1333                    });
1334                }
1335            }
1336        }
1337        cells
1338    }
1339
1340    // ── Explicit cell selection ─────────────────────────────
1341
1342    /// Set an explicit single-cell selection override.
1343    pub fn select_table_cell(&self, table_id: usize, row: usize, col: usize) {
1344        let mut d = self.data.lock();
1345        d.cell_selection_override = Some(crate::flow::CellRange {
1346            table_id,
1347            start_row: row,
1348            start_col: col,
1349            end_row: row,
1350            end_col: col,
1351        });
1352    }
1353
1354    /// Set an explicit rectangular cell-range selection override.
1355    pub fn select_cell_range(
1356        &self,
1357        table_id: usize,
1358        start_row: usize,
1359        start_col: usize,
1360        end_row: usize,
1361        end_col: usize,
1362    ) {
1363        let range = crate::flow::CellRange {
1364            table_id,
1365            start_row,
1366            start_col,
1367            end_row,
1368            end_col,
1369        };
1370        let spans = self.collect_cell_spans(table_id);
1371        let mut d = self.data.lock();
1372        d.cell_selection_override = Some(range.expand_for_spans(&spans));
1373    }
1374
1375    /// Clear any cell-selection override without changing position/anchor.
1376    pub fn clear_cell_selection(&self) {
1377        let mut d = self.data.lock();
1378        d.cell_selection_override = None;
1379    }
1380
1381    /// Compute (min_position, max_position) spanning all blocks in a cell range.
1382    /// Returns `None` if the table or cells cannot be found.
1383    fn cell_range_positions(&self, range: &CellRange) -> Option<(usize, usize)> {
1384        let inner = self.doc.lock();
1385        let main_frame_id = get_main_frame_id(&inner);
1386        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
1387        drop(inner);
1388
1389        // Find the table matching the range's table_id
1390        let table = flow.into_iter().find_map(|e| match e {
1391            FlowElement::Table(t) if t.id() == range.table_id => Some(t),
1392            _ => None,
1393        })?;
1394
1395        let mut min_pos = usize::MAX;
1396        let mut max_pos = 0usize;
1397
1398        for row in range.start_row..=range.end_row {
1399            for col in range.start_col..=range.end_col {
1400                if let Some(cell) = table.cell(row, col) {
1401                    for block in cell.blocks() {
1402                        let bp = block.position();
1403                        let bl = block.length();
1404                        min_pos = min_pos.min(bp);
1405                        max_pos = max_pos.max(bp + bl);
1406                    }
1407                }
1408            }
1409        }
1410
1411        if min_pos == usize::MAX {
1412            return None;
1413        }
1414
1415        // Extend max_pos past the last block to ensure cross-cell detection
1416        Some((min_pos, max_pos + 1))
1417    }
1418
1419    // ── Cell selection helpers (private) ─────────────────────
1420
1421    /// Look up which table cell contains the given document position, if any.
1422    fn table_cell_at(&self, position: usize) -> Option<TableCellRef> {
1423        let inner = self.doc.lock();
1424        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1425            position: to_i64(position),
1426        };
1427        let block_info =
1428            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1429
1430        let block_id = if to_i64(position) < block_info.block_start && position > 0 {
1431            let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
1432                position: to_i64(position - 1),
1433            };
1434            let prev_info =
1435                document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto).ok()?;
1436            prev_info.block_id as usize
1437        } else {
1438            block_info.block_id as usize
1439        };
1440
1441        let block = crate::text_block::TextBlock {
1442            doc: self.doc.clone(),
1443            block_id,
1444        };
1445        drop(inner);
1446        block.table_cell()
1447    }
1448
1449    /// Find the document position at the boundary of the block adjacent to a
1450    /// table. Used by the table-trap logic in [`set_position`](Self::set_position).
1451    ///
1452    /// - `before == true`: returns the last position of the block immediately
1453    ///   before the table (i.e. `block.position() + block.length()`).
1454    /// - `before == false`: returns the first position of the block immediately
1455    ///   after the table.
1456    ///
1457    /// Returns `None` when no adjacent block exists (table is first or last
1458    /// element in the flow).
1459    fn table_boundary_position(&self, table_id: usize, before: bool) -> Option<usize> {
1460        let inner = self.doc.lock();
1461        let main_frame_id = get_main_frame_id(&inner);
1462        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
1463        drop(inner);
1464
1465        // Find the table in the flow and peek at the adjacent element.
1466        let idx = flow
1467            .iter()
1468            .position(|e| matches!(e, FlowElement::Table(t) if t.id() == table_id))?;
1469
1470        if before {
1471            // Walk backwards to find the nearest Block.
1472            for i in (0..idx).rev() {
1473                if let FlowElement::Block(b) = &flow[i] {
1474                    return Some(b.position() + b.length());
1475                }
1476            }
1477        } else {
1478            // Walk forwards to find the nearest Block.
1479            for item in flow.iter().skip(idx + 1) {
1480                if let FlowElement::Block(b) = item {
1481                    return Some(b.position());
1482                }
1483            }
1484        }
1485        None
1486    }
1487
1488    /// Find the first table whose cell blocks fall within the range `(start, end)`.
1489    fn find_table_between(&self, start: usize, end: usize) -> Option<TextTable> {
1490        let inner = self.doc.lock();
1491        let main_frame_id = get_main_frame_id(&inner);
1492        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
1493        drop(inner);
1494
1495        for elem in flow {
1496            if let FlowElement::Table(t) = elem {
1497                // Check whether the first cell's block position is between
1498                // the two endpoints (i.e. the table is inside the range).
1499                if let Some(first_cell) = t.cell(0, 0) {
1500                    let blocks = first_cell.blocks();
1501                    if let Some(fb) = blocks.first() {
1502                        let p = fb.position();
1503                        if p > start && p < end {
1504                            return Some(t);
1505                        }
1506                    }
1507                }
1508            }
1509        }
1510        None
1511    }
1512
1513    /// Collect `(row, col, row_span, col_span)` tuples for all cells in a table.
1514    fn collect_cell_spans(&self, table_id: usize) -> Vec<(usize, usize, usize, usize)> {
1515        let inner = self.doc.lock();
1516        let table_dto =
1517            match frontend::commands::table_commands::get_table(&inner.ctx, &(table_id as u64))
1518                .ok()
1519                .flatten()
1520            {
1521                Some(t) => t,
1522                None => return Vec::new(),
1523            };
1524
1525        let mut spans = Vec::with_capacity(table_dto.cells.len());
1526        for &cell_id in &table_dto.cells {
1527            if let Some(cell) =
1528                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &cell_id)
1529                    .ok()
1530                    .flatten()
1531            {
1532                spans.push((
1533                    cell.row as usize,
1534                    cell.column as usize,
1535                    cell.row_span.max(1) as usize,
1536                    cell.column_span.max(1) as usize,
1537                ));
1538            }
1539        }
1540        spans
1541    }
1542
1543    /// Delete the character after the cursor (Delete key).
1544    pub fn delete_char(&self) -> Result<()> {
1545        let (pos, anchor) = self.read_cursor();
1546        let (del_pos, del_anchor) = if pos != anchor {
1547            (pos, anchor)
1548        } else {
1549            // No-op at end of document (symmetric with delete_previous_char at start)
1550            let end = {
1551                let inner = self.doc.lock();
1552                document_inspection_commands::get_document_stats(&inner.ctx)
1553                    .map(|s| max_cursor_position(&s))
1554                    .unwrap_or(0)
1555            };
1556            if pos >= end {
1557                return Ok(());
1558            }
1559            (pos, pos + 1)
1560        };
1561        self.do_delete(del_pos, del_anchor)
1562    }
1563
1564    /// Delete the character before the cursor (Backspace key).
1565    pub fn delete_previous_char(&self) -> Result<()> {
1566        let (pos, anchor) = self.read_cursor();
1567        let (del_pos, del_anchor) = if pos != anchor {
1568            (pos, anchor)
1569        } else if pos > 0 {
1570            (pos - 1, pos)
1571        } else {
1572            return Ok(());
1573        };
1574        self.do_delete(del_pos, del_anchor)
1575    }
1576
1577    /// Delete the selected text. Returns the deleted text. No-op if no selection.
1578    pub fn remove_selected_text(&self) -> Result<String> {
1579        let (pos, anchor) = self.read_cursor();
1580        if pos == anchor {
1581            return Ok(String::new());
1582        }
1583        let queued = {
1584            let mut inner = self.doc.lock();
1585            let dto = frontend::document_editing::DeleteTextDto {
1586                position: to_i64(pos),
1587                anchor: to_i64(anchor),
1588            };
1589            let result =
1590                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1591            let edit_pos = pos.min(anchor);
1592            let removed = pos.max(anchor) - edit_pos;
1593            let new_pos = to_usize(result.new_position);
1594            inner.adjust_cursors(edit_pos, removed, 0);
1595            {
1596                let mut d = self.data.lock();
1597                d.position = new_pos;
1598                d.anchor = new_pos;
1599            }
1600            inner.modified = true;
1601            inner.invalidate_text_cache();
1602            inner.rehighlight_affected(edit_pos);
1603            inner.queue_event(DocumentEvent::ContentsChanged {
1604                position: edit_pos,
1605                chars_removed: removed,
1606                chars_added: 0,
1607                blocks_affected: 1,
1608            });
1609            inner.check_block_count_changed();
1610            inner.check_flow_changed();
1611            // Return the deleted text alongside the queued events
1612            (result.deleted_text, self.queue_undo_redo_event(&mut inner))
1613        };
1614        crate::inner::dispatch_queued_events(queued.1);
1615        Ok(queued.0)
1616    }
1617
1618    // ── List operations ──────────────────────────────────────
1619
1620    /// Returns the list that the block at the cursor position belongs to,
1621    /// or `None` if the current block is not a list item.
1622    pub fn current_list(&self) -> Option<crate::TextList> {
1623        let pos = self.position();
1624        let inner = self.doc.lock();
1625        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1626            position: to_i64(pos),
1627        };
1628        let block_info =
1629            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1630        let block = crate::text_block::TextBlock {
1631            doc: self.doc.clone(),
1632            block_id: block_info.block_id as usize,
1633        };
1634        drop(inner);
1635        block.list()
1636    }
1637
1638    /// Turn the block(s) in the selection into a list.
1639    pub fn create_list(&self, style: ListStyle) -> Result<()> {
1640        let (pos, anchor) = self.read_cursor();
1641        let queued = {
1642            let mut inner = self.doc.lock();
1643            let dto = frontend::document_editing::CreateListDto {
1644                position: to_i64(pos),
1645                anchor: to_i64(anchor),
1646                style: style.clone(),
1647            };
1648            document_editing_commands::create_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1649            inner.modified = true;
1650            inner.rehighlight_affected(pos.min(anchor));
1651            inner.queue_event(DocumentEvent::ContentsChanged {
1652                position: pos.min(anchor),
1653                chars_removed: 0,
1654                chars_added: 0,
1655                blocks_affected: 1,
1656            });
1657            self.queue_undo_redo_event(&mut inner)
1658        };
1659        crate::inner::dispatch_queued_events(queued);
1660        Ok(())
1661    }
1662
1663    /// Insert a new list item at the cursor position.
1664    pub fn insert_list(&self, style: ListStyle) -> Result<()> {
1665        let (pos, anchor) = self.read_cursor();
1666        let queued = {
1667            let mut inner = self.doc.lock();
1668            let dto = frontend::document_editing::InsertListDto {
1669                position: to_i64(pos),
1670                anchor: to_i64(anchor),
1671                style: style.clone(),
1672            };
1673            let result =
1674                document_editing_commands::insert_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1675            let edit_pos = pos.min(anchor);
1676            let removed = pos.max(anchor) - edit_pos;
1677            self.finish_edit_ext(
1678                &mut inner,
1679                edit_pos,
1680                removed,
1681                to_usize(result.new_position),
1682                1,
1683                false,
1684            )
1685        };
1686        crate::inner::dispatch_queued_events(queued);
1687        Ok(())
1688    }
1689
1690    /// Set formatting on a list by its ID.
1691    pub fn set_list_format(&self, list_id: usize, format: &crate::ListFormat) -> Result<()> {
1692        let queued = {
1693            let mut inner = self.doc.lock();
1694            let dto = format.to_set_dto(list_id);
1695            document_formatting_commands::set_list_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1696            inner.modified = true;
1697            inner.queue_event(DocumentEvent::FormatChanged {
1698                position: 0,
1699                length: 0,
1700                kind: crate::flow::FormatChangeKind::List,
1701            });
1702            self.queue_undo_redo_event(&mut inner)
1703        };
1704        crate::inner::dispatch_queued_events(queued);
1705        Ok(())
1706    }
1707
1708    /// Set formatting on the list that the current block belongs to.
1709    /// Returns an error if the cursor is not inside a list item.
1710    pub fn set_current_list_format(&self, format: &crate::ListFormat) -> Result<()> {
1711        let list = self
1712            .current_list()
1713            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a list"))?;
1714        self.set_list_format(list.id(), format)
1715    }
1716
1717    /// Add a block to a list by their IDs.
1718    pub fn add_block_to_list(&self, block_id: usize, list_id: usize) -> Result<()> {
1719        let queued = {
1720            let mut inner = self.doc.lock();
1721            let dto = frontend::document_editing::AddBlockToListDto {
1722                block_id: to_i64(block_id),
1723                list_id: to_i64(list_id),
1724            };
1725            document_editing_commands::add_block_to_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1726            inner.modified = true;
1727            inner.queue_event(DocumentEvent::ContentsChanged {
1728                position: 0,
1729                chars_removed: 0,
1730                chars_added: 0,
1731                blocks_affected: 1,
1732            });
1733            self.queue_undo_redo_event(&mut inner)
1734        };
1735        crate::inner::dispatch_queued_events(queued);
1736        Ok(())
1737    }
1738
1739    /// Add the block at the cursor position to a list.
1740    pub fn add_current_block_to_list(&self, list_id: usize) -> Result<()> {
1741        let pos = self.position();
1742        let inner = self.doc.lock();
1743        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1744            position: to_i64(pos),
1745        };
1746        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1747        drop(inner);
1748        self.add_block_to_list(block_info.block_id as usize, list_id)
1749    }
1750
1751    /// Remove a block from its list by block ID.
1752    pub fn remove_block_from_list(&self, block_id: usize) -> Result<()> {
1753        let queued = {
1754            let mut inner = self.doc.lock();
1755            let dto = frontend::document_editing::RemoveBlockFromListDto {
1756                block_id: to_i64(block_id),
1757            };
1758            document_editing_commands::remove_block_from_list(
1759                &inner.ctx,
1760                Some(inner.stack_id),
1761                &dto,
1762            )?;
1763            inner.modified = true;
1764            inner.queue_event(DocumentEvent::ContentsChanged {
1765                position: 0,
1766                chars_removed: 0,
1767                chars_added: 0,
1768                blocks_affected: 1,
1769            });
1770            self.queue_undo_redo_event(&mut inner)
1771        };
1772        crate::inner::dispatch_queued_events(queued);
1773        Ok(())
1774    }
1775
1776    /// Remove the block at the cursor position from its list.
1777    /// Returns an error if the current block is not a list item.
1778    pub fn remove_current_block_from_list(&self) -> Result<()> {
1779        let pos = self.position();
1780        let inner = self.doc.lock();
1781        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1782            position: to_i64(pos),
1783        };
1784        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1785        drop(inner);
1786        self.remove_block_from_list(block_info.block_id as usize)
1787    }
1788
1789    /// Remove a list item by index within the list.
1790    /// Resolves the index to a block, then removes it from the list.
1791    pub fn remove_list_item(&self, list_id: usize, index: usize) -> Result<()> {
1792        let list = crate::text_list::TextList {
1793            doc: self.doc.clone(),
1794            list_id,
1795        };
1796        let block = list
1797            .item(index)
1798            .ok_or_else(|| anyhow::anyhow!("list item index {index} out of range"))?;
1799        self.remove_block_from_list(block.id())
1800    }
1801
1802    // ── Format queries ───────────────────────────────────────
1803
1804    /// Get the character format at the cursor position.
1805    pub fn char_format(&self) -> Result<TextFormat> {
1806        let pos = self.position();
1807        let inner = self.doc.lock();
1808        let dto = frontend::document_inspection::GetTextAtPositionDto {
1809            position: to_i64(pos),
1810            length: 1,
1811        };
1812        let text_info = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
1813        let element_id = text_info.element_id as u64;
1814        let element = inline_element_commands::get_inline_element(&inner.ctx, &element_id)?
1815            .ok_or_else(|| anyhow::anyhow!("element not found at position"))?;
1816        Ok(TextFormat::from(&element))
1817    }
1818
1819    /// Get the block format of the block containing the cursor.
1820    pub fn block_format(&self) -> Result<BlockFormat> {
1821        let pos = self.position();
1822        let inner = self.doc.lock();
1823        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1824            position: to_i64(pos),
1825        };
1826        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1827        let block_id = block_info.block_id as u64;
1828        let block = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
1829            .ok_or_else(|| anyhow::anyhow!("block not found"))?;
1830        Ok(BlockFormat::from(&block))
1831    }
1832
1833    // ── Format application ───────────────────────────────────
1834
1835    /// Set the character format for the selection.
1836    pub fn set_char_format(&self, format: &TextFormat) -> Result<()> {
1837        let (pos, anchor) = self.read_cursor();
1838        let queued = {
1839            let mut inner = self.doc.lock();
1840            let dto = format.to_set_dto(pos, anchor);
1841            document_formatting_commands::set_text_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1842            let start = pos.min(anchor);
1843            let length = pos.max(anchor) - start;
1844            inner.modified = true;
1845            inner.queue_event(DocumentEvent::FormatChanged {
1846                position: start,
1847                length,
1848                kind: crate::flow::FormatChangeKind::Character,
1849            });
1850            self.queue_undo_redo_event(&mut inner)
1851        };
1852        crate::inner::dispatch_queued_events(queued);
1853        Ok(())
1854    }
1855
1856    /// Merge a character format into the selection.
1857    pub fn merge_char_format(&self, format: &TextFormat) -> Result<()> {
1858        let (pos, anchor) = self.read_cursor();
1859        let queued = {
1860            let mut inner = self.doc.lock();
1861            let dto = format.to_merge_dto(pos, anchor);
1862            document_formatting_commands::merge_text_format(
1863                &inner.ctx,
1864                Some(inner.stack_id),
1865                &dto,
1866            )?;
1867            let start = pos.min(anchor);
1868            let length = pos.max(anchor) - start;
1869            inner.modified = true;
1870            inner.queue_event(DocumentEvent::FormatChanged {
1871                position: start,
1872                length,
1873                kind: crate::flow::FormatChangeKind::Character,
1874            });
1875            self.queue_undo_redo_event(&mut inner)
1876        };
1877        crate::inner::dispatch_queued_events(queued);
1878        Ok(())
1879    }
1880
1881    /// Set the block format for the current block (or all blocks in selection).
1882    pub fn set_block_format(&self, format: &BlockFormat) -> Result<()> {
1883        let (pos, anchor) = self.read_cursor();
1884        let queued = {
1885            let mut inner = self.doc.lock();
1886            let dto = format.to_set_dto(pos, anchor);
1887            document_formatting_commands::set_block_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1888            let start = pos.min(anchor);
1889            let length = pos.max(anchor) - start;
1890            inner.modified = true;
1891            inner.queue_event(DocumentEvent::FormatChanged {
1892                position: start,
1893                length,
1894                kind: crate::flow::FormatChangeKind::Block,
1895            });
1896            self.queue_undo_redo_event(&mut inner)
1897        };
1898        crate::inner::dispatch_queued_events(queued);
1899        Ok(())
1900    }
1901
1902    /// Set the frame format.
1903    pub fn set_frame_format(&self, frame_id: usize, format: &FrameFormat) -> Result<()> {
1904        let (pos, anchor) = self.read_cursor();
1905        let queued = {
1906            let mut inner = self.doc.lock();
1907            let dto = format.to_set_dto(pos, anchor, frame_id);
1908            document_formatting_commands::set_frame_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1909            let start = pos.min(anchor);
1910            let length = pos.max(anchor) - start;
1911            inner.modified = true;
1912            inner.queue_event(DocumentEvent::FormatChanged {
1913                position: start,
1914                length,
1915                kind: crate::flow::FormatChangeKind::Block,
1916            });
1917            self.queue_undo_redo_event(&mut inner)
1918        };
1919        crate::inner::dispatch_queued_events(queued);
1920        Ok(())
1921    }
1922
1923    // ── Edit blocks (composite undo) ─────────────────────────
1924
1925    /// Begin a group of operations that will be undone as a single unit.
1926    pub fn begin_edit_block(&self) {
1927        let inner = self.doc.lock();
1928        undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
1929    }
1930
1931    /// End the current edit block.
1932    pub fn end_edit_block(&self) {
1933        let inner = self.doc.lock();
1934        undo_redo_commands::end_composite(&inner.ctx);
1935    }
1936
1937    /// Alias for [`begin_edit_block`](Self::begin_edit_block).
1938    ///
1939    /// Semantically indicates that the new composite should be merged with
1940    /// the previous one (e.g., consecutive keystrokes grouped into a single
1941    /// undo unit). The current backend treats this identically to
1942    /// `begin_edit_block`; future versions may implement automatic merging.
1943    pub fn join_previous_edit_block(&self) {
1944        self.begin_edit_block();
1945    }
1946
1947    // ── Private helpers ─────────────────────────────────────
1948
1949    /// Queue an `UndoRedoChanged` event and return all queued events for dispatch.
1950    fn queue_undo_redo_event(&self, inner: &mut TextDocumentInner) -> QueuedEvents {
1951        let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1952        let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1953        inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1954        inner.take_queued_events()
1955    }
1956
1957    fn do_delete(&self, pos: usize, anchor: usize) -> Result<()> {
1958        let queued = {
1959            let mut inner = self.doc.lock();
1960            let dto = frontend::document_editing::DeleteTextDto {
1961                position: to_i64(pos),
1962                anchor: to_i64(anchor),
1963            };
1964            let result =
1965                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1966            let edit_pos = pos.min(anchor);
1967            let removed = pos.max(anchor) - edit_pos;
1968            let new_pos = to_usize(result.new_position);
1969            inner.adjust_cursors(edit_pos, removed, 0);
1970            {
1971                let mut d = self.data.lock();
1972                d.position = new_pos;
1973                d.anchor = new_pos;
1974            }
1975            inner.modified = true;
1976            inner.invalidate_text_cache();
1977            inner.rehighlight_affected(edit_pos);
1978            inner.queue_event(DocumentEvent::ContentsChanged {
1979                position: edit_pos,
1980                chars_removed: removed,
1981                chars_added: 0,
1982                blocks_affected: 1,
1983            });
1984            inner.check_block_count_changed();
1985            inner.check_flow_changed();
1986            self.queue_undo_redo_event(&mut inner)
1987        };
1988        crate::inner::dispatch_queued_events(queued);
1989        Ok(())
1990    }
1991
1992    /// Resolve a MoveOperation to a concrete position.
1993    fn resolve_move(&self, op: MoveOperation, n: usize) -> usize {
1994        let pos = self.position();
1995        match op {
1996            MoveOperation::NoMove => pos,
1997            MoveOperation::Start => 0,
1998            MoveOperation::End => {
1999                let inner = self.doc.lock();
2000                document_inspection_commands::get_document_stats(&inner.ctx)
2001                    .map(|s| max_cursor_position(&s))
2002                    .unwrap_or(pos)
2003            }
2004            MoveOperation::NextCharacter | MoveOperation::Right => pos + n,
2005            MoveOperation::PreviousCharacter | MoveOperation::Left => pos.saturating_sub(n),
2006            MoveOperation::StartOfBlock | MoveOperation::StartOfLine => {
2007                let inner = self.doc.lock();
2008                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2009                    position: to_i64(pos),
2010                };
2011                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2012                    .map(|info| to_usize(info.block_start))
2013                    .unwrap_or(pos)
2014            }
2015            MoveOperation::EndOfBlock | MoveOperation::EndOfLine => {
2016                let inner = self.doc.lock();
2017                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2018                    position: to_i64(pos),
2019                };
2020                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2021                    .map(|info| to_usize(info.block_start) + to_usize(info.block_length))
2022                    .unwrap_or(pos)
2023            }
2024            MoveOperation::NextBlock => {
2025                let inner = self.doc.lock();
2026                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2027                    position: to_i64(pos),
2028                };
2029                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2030                    .map(|info| {
2031                        // Move past current block + 1 (block separator)
2032                        to_usize(info.block_start) + to_usize(info.block_length) + 1
2033                    })
2034                    .unwrap_or(pos)
2035            }
2036            MoveOperation::PreviousBlock => {
2037                let inner = self.doc.lock();
2038                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2039                    position: to_i64(pos),
2040                };
2041                let block_start =
2042                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2043                        .map(|info| to_usize(info.block_start))
2044                        .unwrap_or(pos);
2045                if block_start >= 2 {
2046                    // Skip past the block separator (which maps to the current block)
2047                    let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
2048                        position: to_i64(block_start - 2),
2049                    };
2050                    document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto)
2051                        .map(|info| to_usize(info.block_start))
2052                        .unwrap_or(0)
2053                } else {
2054                    0
2055                }
2056            }
2057            MoveOperation::NextWord | MoveOperation::EndOfWord | MoveOperation::WordRight => {
2058                let (_, end) = self.find_word_boundaries(pos);
2059                // Move past the word end to the next word
2060                if end == pos {
2061                    // Already at a boundary, skip whitespace
2062                    let inner = self.doc.lock();
2063                    let max_pos = document_inspection_commands::get_document_stats(&inner.ctx)
2064                        .map(|s| max_cursor_position(&s))
2065                        .unwrap_or(0);
2066                    let scan_len = max_pos.saturating_sub(pos).min(64);
2067                    if scan_len == 0 {
2068                        return pos;
2069                    }
2070                    let dto = frontend::document_inspection::GetTextAtPositionDto {
2071                        position: to_i64(pos),
2072                        length: to_i64(scan_len),
2073                    };
2074                    if let Ok(r) =
2075                        document_inspection_commands::get_text_at_position(&inner.ctx, &dto)
2076                    {
2077                        for (i, ch) in r.text.chars().enumerate() {
2078                            if ch.is_alphanumeric() || ch == '_' {
2079                                // Found start of next word, find its end
2080                                let word_pos = pos + i;
2081                                drop(inner);
2082                                let (_, word_end) = self.find_word_boundaries(word_pos);
2083                                return word_end;
2084                            }
2085                        }
2086                    }
2087                    pos + scan_len
2088                } else {
2089                    end
2090                }
2091            }
2092            MoveOperation::PreviousWord | MoveOperation::StartOfWord | MoveOperation::WordLeft => {
2093                let (start, _) = self.find_word_boundaries(pos);
2094                if start < pos {
2095                    start
2096                } else if pos > 0 {
2097                    // Cursor is at a word start or on whitespace — scan backwards
2098                    // to find the start of the previous word.
2099                    let mut search = pos - 1;
2100                    loop {
2101                        let (ws, we) = self.find_word_boundaries(search);
2102                        if ws < we {
2103                            // Found a word; return its start
2104                            break ws;
2105                        }
2106                        // Still on whitespace/non-word; keep scanning
2107                        if search == 0 {
2108                            break 0;
2109                        }
2110                        search -= 1;
2111                    }
2112                } else {
2113                    0
2114                }
2115            }
2116            MoveOperation::Up | MoveOperation::Down => {
2117                // Up/Down are visual operations that depend on line wrapping.
2118                // Without layout info, treat as PreviousBlock/NextBlock.
2119                if matches!(op, MoveOperation::Up) {
2120                    self.resolve_move(MoveOperation::PreviousBlock, 1)
2121                } else {
2122                    self.resolve_move(MoveOperation::NextBlock, 1)
2123                }
2124            }
2125        }
2126    }
2127
2128    /// Find the word boundaries around `pos`. Returns (start, end).
2129    /// Uses Unicode word segmentation for correct handling of non-ASCII text.
2130    ///
2131    /// Single-pass: tracks the last word seen to avoid a second iteration
2132    /// when the cursor is at the end of the last word (ISSUE-18).
2133    fn find_word_boundaries(&self, pos: usize) -> (usize, usize) {
2134        let inner = self.doc.lock();
2135        // Get block info so we can fetch the full block text
2136        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
2137            position: to_i64(pos),
2138        };
2139        let block_info =
2140            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
2141                Ok(info) => info,
2142                Err(_) => return (pos, pos),
2143            };
2144
2145        let block_start = to_usize(block_info.block_start);
2146        let block_length = to_usize(block_info.block_length);
2147        if block_length == 0 {
2148            return (pos, pos);
2149        }
2150
2151        let dto = frontend::document_inspection::GetTextAtPositionDto {
2152            position: to_i64(block_start),
2153            length: to_i64(block_length),
2154        };
2155        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &dto) {
2156            Ok(r) => r.text,
2157            Err(_) => return (pos, pos),
2158        };
2159
2160        // cursor_offset is the char offset within the block text
2161        let cursor_offset = pos.saturating_sub(block_start);
2162
2163        // Single pass: track the last word seen for end-of-last-word check
2164        let mut last_char_start = 0;
2165        let mut last_char_end = 0;
2166
2167        for (word_byte_start, word) in text.unicode_word_indices() {
2168            // Convert byte offset to char offset
2169            let word_char_start = text[..word_byte_start].chars().count();
2170            let word_char_len = word.chars().count();
2171            let word_char_end = word_char_start + word_char_len;
2172
2173            last_char_start = word_char_start;
2174            last_char_end = word_char_end;
2175
2176            if cursor_offset >= word_char_start && cursor_offset < word_char_end {
2177                return (block_start + word_char_start, block_start + word_char_end);
2178            }
2179        }
2180
2181        // Check if cursor is exactly at the end of the last word
2182        if cursor_offset == last_char_end && last_char_start < last_char_end {
2183            return (block_start + last_char_start, block_start + last_char_end);
2184        }
2185
2186        (pos, pos)
2187    }
2188}