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::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
25/// Compute the maximum valid cursor position from document stats.
26///
27/// Cursor positions include block separators (one between each pair of adjacent
28/// blocks), but `character_count` does not. The max position is therefore
29/// `character_count + (block_count - 1)`.
30fn max_cursor_position(stats: &frontend::document_inspection::DocumentStatsDto) -> usize {
31    let chars = to_usize(stats.character_count);
32    let blocks = to_usize(stats.block_count);
33    if blocks > 1 {
34        chars + blocks - 1
35    } else {
36        chars
37    }
38}
39
40/// A cursor into a [`TextDocument`](crate::TextDocument).
41///
42/// Multiple cursors can coexist on the same document (like Qt's `QTextCursor`).
43/// When any cursor edits text, all other cursors' positions are automatically
44/// adjusted by the document.
45///
46/// Cloning a cursor creates an **independent** cursor at the same position.
47pub struct TextCursor {
48    pub(crate) doc: Arc<Mutex<TextDocumentInner>>,
49    pub(crate) data: Arc<Mutex<CursorData>>,
50}
51
52impl Clone for TextCursor {
53    fn clone(&self) -> Self {
54        let (position, anchor) = {
55            let d = self.data.lock();
56            (d.position, d.anchor)
57        };
58        let data = {
59            let mut inner = self.doc.lock();
60            let data = Arc::new(Mutex::new(CursorData { position, anchor }));
61            inner.cursors.push(Arc::downgrade(&data));
62            data
63        };
64        TextCursor {
65            doc: self.doc.clone(),
66            data,
67        }
68    }
69}
70
71impl TextCursor {
72    // ── Helpers (called while doc lock is NOT held) ──────────
73
74    fn read_cursor(&self) -> (usize, usize) {
75        let d = self.data.lock();
76        (d.position, d.anchor)
77    }
78
79    /// Common post-edit bookkeeping: adjust all cursors, set this cursor to
80    /// `new_pos`, mark modified, invalidate text cache, queue a
81    /// `ContentsChanged` event, and return the queued events for dispatch.
82    fn finish_edit(
83        &self,
84        inner: &mut TextDocumentInner,
85        edit_pos: usize,
86        removed: usize,
87        new_pos: usize,
88        blocks_affected: usize,
89    ) -> QueuedEvents {
90        let added = new_pos - edit_pos;
91        inner.adjust_cursors(edit_pos, removed, added);
92        {
93            let mut d = self.data.lock();
94            d.position = new_pos;
95            d.anchor = new_pos;
96        }
97        inner.modified = true;
98        inner.invalidate_text_cache();
99        inner.rehighlight_affected(edit_pos);
100        inner.queue_event(DocumentEvent::ContentsChanged {
101            position: edit_pos,
102            chars_removed: removed,
103            chars_added: added,
104            blocks_affected,
105        });
106        inner.check_block_count_changed();
107        inner.check_flow_changed();
108        self.queue_undo_redo_event(inner)
109    }
110
111    // ── Position & selection ─────────────────────────────────
112
113    /// Current cursor position (between characters).
114    pub fn position(&self) -> usize {
115        self.data.lock().position
116    }
117
118    /// Anchor position. Equal to `position()` when no selection.
119    pub fn anchor(&self) -> usize {
120        self.data.lock().anchor
121    }
122
123    /// Returns true if there is a selection.
124    pub fn has_selection(&self) -> bool {
125        let d = self.data.lock();
126        d.position != d.anchor
127    }
128
129    /// Start of the selection (min of position and anchor).
130    pub fn selection_start(&self) -> usize {
131        let d = self.data.lock();
132        d.position.min(d.anchor)
133    }
134
135    /// End of the selection (max of position and anchor).
136    pub fn selection_end(&self) -> usize {
137        let d = self.data.lock();
138        d.position.max(d.anchor)
139    }
140
141    /// Get the selected text. Returns empty string if no selection.
142    pub fn selected_text(&self) -> Result<String> {
143        let (pos, anchor) = self.read_cursor();
144        if pos == anchor {
145            return Ok(String::new());
146        }
147        let start = pos.min(anchor);
148        let len = pos.max(anchor) - start;
149        let inner = self.doc.lock();
150        let dto = frontend::document_inspection::GetTextAtPositionDto {
151            position: to_i64(start),
152            length: to_i64(len),
153        };
154        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
155        Ok(result.text)
156    }
157
158    /// Collapse the selection by moving anchor to position.
159    pub fn clear_selection(&self) {
160        let mut d = self.data.lock();
161        d.anchor = d.position;
162    }
163
164    // ── Boundary queries ─────────────────────────────────────
165
166    /// True if the cursor is at the start of a block.
167    pub fn at_block_start(&self) -> bool {
168        let pos = self.position();
169        let inner = self.doc.lock();
170        let dto = frontend::document_inspection::GetBlockAtPositionDto {
171            position: to_i64(pos),
172        };
173        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
174            pos == to_usize(info.block_start)
175        } else {
176            false
177        }
178    }
179
180    /// True if the cursor is at the end of a block.
181    pub fn at_block_end(&self) -> bool {
182        let pos = self.position();
183        let inner = self.doc.lock();
184        let dto = frontend::document_inspection::GetBlockAtPositionDto {
185            position: to_i64(pos),
186        };
187        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
188            pos == to_usize(info.block_start) + to_usize(info.block_length)
189        } else {
190            false
191        }
192    }
193
194    /// True if the cursor is at position 0.
195    pub fn at_start(&self) -> bool {
196        self.data.lock().position == 0
197    }
198
199    /// True if the cursor is at the very end of the document.
200    pub fn at_end(&self) -> bool {
201        let pos = self.position();
202        let inner = self.doc.lock();
203        let stats = document_inspection_commands::get_document_stats(&inner.ctx).unwrap_or({
204            frontend::document_inspection::DocumentStatsDto {
205                character_count: 0,
206                word_count: 0,
207                block_count: 0,
208                frame_count: 0,
209                image_count: 0,
210                list_count: 0,
211                table_count: 0,
212            }
213        });
214        pos >= max_cursor_position(&stats)
215    }
216
217    /// The block number (0-indexed) containing the cursor.
218    pub fn block_number(&self) -> usize {
219        let pos = self.position();
220        let inner = self.doc.lock();
221        let dto = frontend::document_inspection::GetBlockAtPositionDto {
222            position: to_i64(pos),
223        };
224        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
225            .map(|info| to_usize(info.block_number))
226            .unwrap_or(0)
227    }
228
229    /// The cursor's column within the current block (0-indexed).
230    pub fn position_in_block(&self) -> usize {
231        let pos = self.position();
232        let inner = self.doc.lock();
233        let dto = frontend::document_inspection::GetBlockAtPositionDto {
234            position: to_i64(pos),
235        };
236        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
237            .map(|info| pos.saturating_sub(to_usize(info.block_start)))
238            .unwrap_or(0)
239    }
240
241    // ── Movement ─────────────────────────────────────────────
242
243    /// Set the cursor to an absolute position.
244    pub fn set_position(&self, position: usize, mode: MoveMode) {
245        // Clamp to max document position (includes block separators)
246        let end = {
247            let inner = self.doc.lock();
248            document_inspection_commands::get_document_stats(&inner.ctx)
249                .map(|s| max_cursor_position(&s))
250                .unwrap_or(0)
251        };
252        let pos = position.min(end);
253        let mut d = self.data.lock();
254        d.position = pos;
255        if mode == MoveMode::MoveAnchor {
256            d.anchor = pos;
257        }
258    }
259
260    /// Move the cursor by a semantic operation.
261    ///
262    /// `n` is used as a repeat count for character-level movements
263    /// (`NextCharacter`, `PreviousCharacter`, `Left`, `Right`).
264    /// For all other operations it is ignored. Returns `true` if the cursor moved.
265    pub fn move_position(&self, operation: MoveOperation, mode: MoveMode, n: usize) -> bool {
266        let old_pos = self.position();
267        let target = self.resolve_move(operation, n);
268        self.set_position(target, mode);
269        self.position() != old_pos
270    }
271
272    /// Select a region relative to the cursor position.
273    pub fn select(&self, selection: SelectionType) {
274        match selection {
275            SelectionType::Document => {
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 d = self.data.lock();
283                d.anchor = 0;
284                d.position = end;
285            }
286            SelectionType::BlockUnderCursor | SelectionType::LineUnderCursor => {
287                let pos = self.position();
288                let inner = self.doc.lock();
289                let dto = frontend::document_inspection::GetBlockAtPositionDto {
290                    position: to_i64(pos),
291                };
292                if let Ok(info) =
293                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
294                {
295                    let start = to_usize(info.block_start);
296                    let end = start + to_usize(info.block_length);
297                    drop(inner);
298                    let mut d = self.data.lock();
299                    d.anchor = start;
300                    d.position = end;
301                }
302            }
303            SelectionType::WordUnderCursor => {
304                let pos = self.position();
305                let (word_start, word_end) = self.find_word_boundaries(pos);
306                let mut d = self.data.lock();
307                d.anchor = word_start;
308                d.position = word_end;
309            }
310        }
311    }
312
313    // ── Text editing ─────────────────────────────────────────
314
315    /// Insert plain text at the cursor. Replaces selection if any.
316    pub fn insert_text(&self, text: &str) -> Result<()> {
317        let (pos, anchor) = self.read_cursor();
318
319        // Try direct insert first (handles same-block selection and no-selection cases)
320        let dto = frontend::document_editing::InsertTextDto {
321            position: to_i64(pos),
322            anchor: to_i64(anchor),
323            text: text.into(),
324        };
325
326        let queued = {
327            let mut inner = self.doc.lock();
328            let result = match document_editing_commands::insert_text(
329                &inner.ctx,
330                Some(inner.stack_id),
331                &dto,
332            ) {
333                Ok(r) => r,
334                Err(_) if pos != anchor => {
335                    // Cross-block selection: compose delete + insert as a single undo unit
336                    undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
337
338                    let del_dto = frontend::document_editing::DeleteTextDto {
339                        position: to_i64(pos),
340                        anchor: to_i64(anchor),
341                    };
342                    let del_result = document_editing_commands::delete_text(
343                        &inner.ctx,
344                        Some(inner.stack_id),
345                        &del_dto,
346                    )?;
347                    let del_pos = to_usize(del_result.new_position);
348
349                    let ins_dto = frontend::document_editing::InsertTextDto {
350                        position: to_i64(del_pos),
351                        anchor: to_i64(del_pos),
352                        text: text.into(),
353                    };
354                    let ins_result = document_editing_commands::insert_text(
355                        &inner.ctx,
356                        Some(inner.stack_id),
357                        &ins_dto,
358                    )?;
359
360                    undo_redo_commands::end_composite(&inner.ctx);
361                    ins_result
362                }
363                Err(e) => return Err(e),
364            };
365
366            let edit_pos = pos.min(anchor);
367            let removed = pos.max(anchor) - edit_pos;
368            self.finish_edit(
369                &mut inner,
370                edit_pos,
371                removed,
372                to_usize(result.new_position),
373                to_usize(result.blocks_affected),
374            )
375        };
376        crate::inner::dispatch_queued_events(queued);
377        Ok(())
378    }
379
380    /// Insert text with a specific character format. Replaces selection if any.
381    pub fn insert_formatted_text(&self, text: &str, format: &TextFormat) -> Result<()> {
382        let (pos, anchor) = self.read_cursor();
383        let queued = {
384            let mut inner = self.doc.lock();
385            let dto = frontend::document_editing::InsertFormattedTextDto {
386                position: to_i64(pos),
387                anchor: to_i64(anchor),
388                text: text.into(),
389                font_family: format.font_family.clone().unwrap_or_default(),
390                font_point_size: format.font_point_size.map(|v| v as i64).unwrap_or(0),
391                font_bold: format.font_bold.unwrap_or(false),
392                font_italic: format.font_italic.unwrap_or(false),
393                font_underline: format.font_underline.unwrap_or(false),
394                font_strikeout: format.font_strikeout.unwrap_or(false),
395            };
396            let result = document_editing_commands::insert_formatted_text(
397                &inner.ctx,
398                Some(inner.stack_id),
399                &dto,
400            )?;
401            let edit_pos = pos.min(anchor);
402            let removed = pos.max(anchor) - edit_pos;
403            self.finish_edit(
404                &mut inner,
405                edit_pos,
406                removed,
407                to_usize(result.new_position),
408                1,
409            )
410        };
411        crate::inner::dispatch_queued_events(queued);
412        Ok(())
413    }
414
415    /// Insert a block break (new paragraph). Replaces selection if any.
416    pub fn insert_block(&self) -> Result<()> {
417        let (pos, anchor) = self.read_cursor();
418        let queued = {
419            let mut inner = self.doc.lock();
420            let dto = frontend::document_editing::InsertBlockDto {
421                position: to_i64(pos),
422                anchor: to_i64(anchor),
423            };
424            let result =
425                document_editing_commands::insert_block(&inner.ctx, Some(inner.stack_id), &dto)?;
426            let edit_pos = pos.min(anchor);
427            let removed = pos.max(anchor) - edit_pos;
428            self.finish_edit(
429                &mut inner,
430                edit_pos,
431                removed,
432                to_usize(result.new_position),
433                2,
434            )
435        };
436        crate::inner::dispatch_queued_events(queued);
437        Ok(())
438    }
439
440    /// Insert an HTML fragment at the cursor position. Replaces selection if any.
441    pub fn insert_html(&self, html: &str) -> Result<()> {
442        let (pos, anchor) = self.read_cursor();
443        let queued = {
444            let mut inner = self.doc.lock();
445            let dto = frontend::document_editing::InsertHtmlAtPositionDto {
446                position: to_i64(pos),
447                anchor: to_i64(anchor),
448                html: html.into(),
449            };
450            let result = document_editing_commands::insert_html_at_position(
451                &inner.ctx,
452                Some(inner.stack_id),
453                &dto,
454            )?;
455            let edit_pos = pos.min(anchor);
456            let removed = pos.max(anchor) - edit_pos;
457            self.finish_edit(
458                &mut inner,
459                edit_pos,
460                removed,
461                to_usize(result.new_position),
462                to_usize(result.blocks_added),
463            )
464        };
465        crate::inner::dispatch_queued_events(queued);
466        Ok(())
467    }
468
469    /// Insert a Markdown fragment at the cursor position. Replaces selection if any.
470    pub fn insert_markdown(&self, markdown: &str) -> Result<()> {
471        let (pos, anchor) = self.read_cursor();
472        let queued = {
473            let mut inner = self.doc.lock();
474            let dto = frontend::document_editing::InsertMarkdownAtPositionDto {
475                position: to_i64(pos),
476                anchor: to_i64(anchor),
477                markdown: markdown.into(),
478            };
479            let result = document_editing_commands::insert_markdown_at_position(
480                &inner.ctx,
481                Some(inner.stack_id),
482                &dto,
483            )?;
484            let edit_pos = pos.min(anchor);
485            let removed = pos.max(anchor) - edit_pos;
486            self.finish_edit(
487                &mut inner,
488                edit_pos,
489                removed,
490                to_usize(result.new_position),
491                to_usize(result.blocks_added),
492            )
493        };
494        crate::inner::dispatch_queued_events(queued);
495        Ok(())
496    }
497
498    /// Insert a document fragment at the cursor. Replaces selection if any.
499    pub fn insert_fragment(&self, fragment: &DocumentFragment) -> Result<()> {
500        let (pos, anchor) = self.read_cursor();
501        let queued = {
502            let mut inner = self.doc.lock();
503            let dto = frontend::document_editing::InsertFragmentDto {
504                position: to_i64(pos),
505                anchor: to_i64(anchor),
506                fragment_data: fragment.raw_data().into(),
507            };
508            let result =
509                document_editing_commands::insert_fragment(&inner.ctx, Some(inner.stack_id), &dto)?;
510            let edit_pos = pos.min(anchor);
511            let removed = pos.max(anchor) - edit_pos;
512            self.finish_edit(
513                &mut inner,
514                edit_pos,
515                removed,
516                to_usize(result.new_position),
517                to_usize(result.blocks_added),
518            )
519        };
520        crate::inner::dispatch_queued_events(queued);
521        Ok(())
522    }
523
524    /// Extract the current selection as a [`DocumentFragment`].
525    pub fn selection(&self) -> DocumentFragment {
526        let (pos, anchor) = self.read_cursor();
527        if pos == anchor {
528            return DocumentFragment::new();
529        }
530        let inner = self.doc.lock();
531        let dto = frontend::document_inspection::ExtractFragmentDto {
532            position: to_i64(pos),
533            anchor: to_i64(anchor),
534        };
535        match document_inspection_commands::extract_fragment(&inner.ctx, &dto) {
536            Ok(result) => DocumentFragment::from_raw(result.fragment_data, result.plain_text),
537            Err(_) => DocumentFragment::new(),
538        }
539    }
540
541    /// Insert an image at the cursor.
542    pub fn insert_image(&self, name: &str, width: u32, height: u32) -> Result<()> {
543        let (pos, anchor) = self.read_cursor();
544        let queued = {
545            let mut inner = self.doc.lock();
546            let dto = frontend::document_editing::InsertImageDto {
547                position: to_i64(pos),
548                anchor: to_i64(anchor),
549                image_name: name.into(),
550                width: width as i64,
551                height: height as i64,
552            };
553            let result =
554                document_editing_commands::insert_image(&inner.ctx, Some(inner.stack_id), &dto)?;
555            let edit_pos = pos.min(anchor);
556            let removed = pos.max(anchor) - edit_pos;
557            self.finish_edit(
558                &mut inner,
559                edit_pos,
560                removed,
561                to_usize(result.new_position),
562                1,
563            )
564        };
565        crate::inner::dispatch_queued_events(queued);
566        Ok(())
567    }
568
569    /// Insert a new frame at the cursor.
570    pub fn insert_frame(&self) -> Result<()> {
571        let (pos, anchor) = self.read_cursor();
572        let queued = {
573            let mut inner = self.doc.lock();
574            let dto = frontend::document_editing::InsertFrameDto {
575                position: to_i64(pos),
576                anchor: to_i64(anchor),
577            };
578            document_editing_commands::insert_frame(&inner.ctx, Some(inner.stack_id), &dto)?;
579            // Frame insertion adds structural content; adjust cursors and emit event.
580            // The backend doesn't return a new_position, so the cursor stays put.
581            inner.modified = true;
582            inner.invalidate_text_cache();
583            inner.rehighlight_affected(pos.min(anchor));
584            inner.queue_event(DocumentEvent::ContentsChanged {
585                position: pos.min(anchor),
586                chars_removed: 0,
587                chars_added: 0,
588                blocks_affected: 1,
589            });
590            inner.check_block_count_changed();
591            inner.check_flow_changed();
592            self.queue_undo_redo_event(&mut inner)
593        };
594        crate::inner::dispatch_queued_events(queued);
595        Ok(())
596    }
597
598    /// Insert a table at the cursor position.
599    ///
600    /// Creates a `rows × columns` table with empty cells.
601    /// The cursor moves to after the table.
602    /// Returns a handle to the created table.
603    pub fn insert_table(&self, rows: usize, columns: usize) -> Result<TextTable> {
604        let (pos, anchor) = self.read_cursor();
605        let (table_id, queued) = {
606            let mut inner = self.doc.lock();
607            let dto = frontend::document_editing::InsertTableDto {
608                position: to_i64(pos),
609                anchor: to_i64(anchor),
610                rows: to_i64(rows),
611                columns: to_i64(columns),
612            };
613            let result =
614                document_editing_commands::insert_table(&inner.ctx, Some(inner.stack_id), &dto)?;
615            let new_pos = to_usize(result.new_position);
616            let table_id = to_usize(result.table_id);
617            inner.adjust_cursors(pos.min(anchor), 0, new_pos - pos.min(anchor));
618            {
619                let mut d = self.data.lock();
620                d.position = new_pos;
621                d.anchor = new_pos;
622            }
623            inner.modified = true;
624            inner.invalidate_text_cache();
625            inner.rehighlight_affected(pos.min(anchor));
626            inner.queue_event(DocumentEvent::ContentsChanged {
627                position: pos.min(anchor),
628                chars_removed: 0,
629                chars_added: new_pos - pos.min(anchor),
630                blocks_affected: 1,
631            });
632            inner.check_block_count_changed();
633            inner.check_flow_changed();
634            (table_id, self.queue_undo_redo_event(&mut inner))
635        };
636        crate::inner::dispatch_queued_events(queued);
637        Ok(TextTable {
638            doc: self.doc.clone(),
639            table_id,
640        })
641    }
642
643    /// Returns the table the cursor is currently inside, if any.
644    ///
645    /// Returns `None` if the cursor is in the main document flow
646    /// (not inside a table cell).
647    pub fn current_table(&self) -> Option<TextTable> {
648        self.current_table_cell().map(|c| c.table)
649    }
650
651    /// Returns the table cell the cursor is currently inside, if any.
652    ///
653    /// Returns `None` if the cursor is not inside a table cell.
654    /// When `Some`, provides the table, row, and column.
655    pub fn current_table_cell(&self) -> Option<TableCellRef> {
656        let pos = self.position();
657        let inner = self.doc.lock();
658        // Find the block at cursor position
659        let dto = frontend::document_inspection::GetBlockAtPositionDto {
660            position: to_i64(pos),
661        };
662        let block_info =
663            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
664        let block = crate::text_block::TextBlock {
665            doc: self.doc.clone(),
666            block_id: block_info.block_id as usize,
667        };
668        // Release inner lock before calling table_cell() which also locks
669        drop(inner);
670        block.table_cell()
671    }
672
673    // ── Table structure mutations (explicit-ID) ──────────
674
675    /// Remove a table from the document by its ID.
676    pub fn remove_table(&self, table_id: usize) -> Result<()> {
677        let queued = {
678            let mut inner = self.doc.lock();
679            let dto = frontend::document_editing::RemoveTableDto {
680                table_id: to_i64(table_id),
681            };
682            document_editing_commands::remove_table(&inner.ctx, Some(inner.stack_id), &dto)?;
683            inner.modified = true;
684            inner.invalidate_text_cache();
685            inner.rehighlight_all();
686            inner.check_block_count_changed();
687            inner.check_flow_changed();
688            self.queue_undo_redo_event(&mut inner)
689        };
690        crate::inner::dispatch_queued_events(queued);
691        Ok(())
692    }
693
694    /// Insert a row into a table at the given index.
695    pub fn insert_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
696        let queued = {
697            let mut inner = self.doc.lock();
698            let dto = frontend::document_editing::InsertTableRowDto {
699                table_id: to_i64(table_id),
700                row_index: to_i64(row_index),
701            };
702            document_editing_commands::insert_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
703            inner.modified = true;
704            inner.invalidate_text_cache();
705            inner.rehighlight_all();
706            inner.check_block_count_changed();
707            self.queue_undo_redo_event(&mut inner)
708        };
709        crate::inner::dispatch_queued_events(queued);
710        Ok(())
711    }
712
713    /// Insert a column into a table at the given index.
714    pub fn insert_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
715        let queued = {
716            let mut inner = self.doc.lock();
717            let dto = frontend::document_editing::InsertTableColumnDto {
718                table_id: to_i64(table_id),
719                column_index: to_i64(column_index),
720            };
721            document_editing_commands::insert_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
722            inner.modified = true;
723            inner.invalidate_text_cache();
724            inner.rehighlight_all();
725            inner.check_block_count_changed();
726            self.queue_undo_redo_event(&mut inner)
727        };
728        crate::inner::dispatch_queued_events(queued);
729        Ok(())
730    }
731
732    /// Remove a row from a table. Fails if only one row remains.
733    pub fn remove_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
734        let queued = {
735            let mut inner = self.doc.lock();
736            let dto = frontend::document_editing::RemoveTableRowDto {
737                table_id: to_i64(table_id),
738                row_index: to_i64(row_index),
739            };
740            document_editing_commands::remove_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
741            inner.modified = true;
742            inner.invalidate_text_cache();
743            inner.rehighlight_all();
744            inner.check_block_count_changed();
745            self.queue_undo_redo_event(&mut inner)
746        };
747        crate::inner::dispatch_queued_events(queued);
748        Ok(())
749    }
750
751    /// Remove a column from a table. Fails if only one column remains.
752    pub fn remove_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
753        let queued = {
754            let mut inner = self.doc.lock();
755            let dto = frontend::document_editing::RemoveTableColumnDto {
756                table_id: to_i64(table_id),
757                column_index: to_i64(column_index),
758            };
759            document_editing_commands::remove_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
760            inner.modified = true;
761            inner.invalidate_text_cache();
762            inner.rehighlight_all();
763            inner.check_block_count_changed();
764            self.queue_undo_redo_event(&mut inner)
765        };
766        crate::inner::dispatch_queued_events(queued);
767        Ok(())
768    }
769
770    /// Merge a rectangular range of cells within a table.
771    pub fn merge_table_cells(
772        &self,
773        table_id: usize,
774        start_row: usize,
775        start_column: usize,
776        end_row: usize,
777        end_column: usize,
778    ) -> Result<()> {
779        let queued = {
780            let mut inner = self.doc.lock();
781            let dto = frontend::document_editing::MergeTableCellsDto {
782                table_id: to_i64(table_id),
783                start_row: to_i64(start_row),
784                start_column: to_i64(start_column),
785                end_row: to_i64(end_row),
786                end_column: to_i64(end_column),
787            };
788            document_editing_commands::merge_table_cells(&inner.ctx, Some(inner.stack_id), &dto)?;
789            inner.modified = true;
790            inner.invalidate_text_cache();
791            inner.rehighlight_all();
792            inner.check_block_count_changed();
793            self.queue_undo_redo_event(&mut inner)
794        };
795        crate::inner::dispatch_queued_events(queued);
796        Ok(())
797    }
798
799    /// Split a previously merged cell.
800    pub fn split_table_cell(
801        &self,
802        cell_id: usize,
803        split_rows: usize,
804        split_columns: usize,
805    ) -> Result<()> {
806        let queued = {
807            let mut inner = self.doc.lock();
808            let dto = frontend::document_editing::SplitTableCellDto {
809                cell_id: to_i64(cell_id),
810                split_rows: to_i64(split_rows),
811                split_columns: to_i64(split_columns),
812            };
813            document_editing_commands::split_table_cell(&inner.ctx, Some(inner.stack_id), &dto)?;
814            inner.modified = true;
815            inner.invalidate_text_cache();
816            inner.rehighlight_all();
817            inner.check_block_count_changed();
818            self.queue_undo_redo_event(&mut inner)
819        };
820        crate::inner::dispatch_queued_events(queued);
821        Ok(())
822    }
823
824    // ── Table formatting (explicit-ID) ───────────────────
825
826    /// Set formatting on a table.
827    pub fn set_table_format(
828        &self,
829        table_id: usize,
830        format: &crate::flow::TableFormat,
831    ) -> Result<()> {
832        let queued = {
833            let mut inner = self.doc.lock();
834            let dto = format.to_set_dto(table_id);
835            document_formatting_commands::set_table_format(&inner.ctx, Some(inner.stack_id), &dto)?;
836            inner.modified = true;
837            inner.queue_event(DocumentEvent::FormatChanged {
838                position: 0,
839                length: 0,
840                kind: crate::flow::FormatChangeKind::Block,
841            });
842            self.queue_undo_redo_event(&mut inner)
843        };
844        crate::inner::dispatch_queued_events(queued);
845        Ok(())
846    }
847
848    /// Set formatting on a table cell.
849    pub fn set_table_cell_format(
850        &self,
851        cell_id: usize,
852        format: &crate::flow::CellFormat,
853    ) -> Result<()> {
854        let queued = {
855            let mut inner = self.doc.lock();
856            let dto = format.to_set_dto(cell_id);
857            document_formatting_commands::set_table_cell_format(
858                &inner.ctx,
859                Some(inner.stack_id),
860                &dto,
861            )?;
862            inner.modified = true;
863            inner.queue_event(DocumentEvent::FormatChanged {
864                position: 0,
865                length: 0,
866                kind: crate::flow::FormatChangeKind::Block,
867            });
868            self.queue_undo_redo_event(&mut inner)
869        };
870        crate::inner::dispatch_queued_events(queued);
871        Ok(())
872    }
873
874    // ── Table convenience (position-based) ───────────────
875
876    /// Remove the table the cursor is currently inside.
877    /// Returns an error if the cursor is not inside a table.
878    pub fn remove_current_table(&self) -> Result<()> {
879        let table = self
880            .current_table()
881            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
882        self.remove_table(table.id())
883    }
884
885    /// Insert a row above the cursor's current row.
886    /// Returns an error if the cursor is not inside a table.
887    pub fn insert_row_above(&self) -> Result<()> {
888        let cell_ref = self
889            .current_table_cell()
890            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
891        self.insert_table_row(cell_ref.table.id(), cell_ref.row)
892    }
893
894    /// Insert a row below the cursor's current row.
895    /// Returns an error if the cursor is not inside a table.
896    pub fn insert_row_below(&self) -> Result<()> {
897        let cell_ref = self
898            .current_table_cell()
899            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
900        self.insert_table_row(cell_ref.table.id(), cell_ref.row + 1)
901    }
902
903    /// Insert a column before the cursor's current column.
904    /// Returns an error if the cursor is not inside a table.
905    pub fn insert_column_before(&self) -> Result<()> {
906        let cell_ref = self
907            .current_table_cell()
908            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
909        self.insert_table_column(cell_ref.table.id(), cell_ref.column)
910    }
911
912    /// Insert a column after the cursor's current column.
913    /// Returns an error if the cursor is not inside a table.
914    pub fn insert_column_after(&self) -> Result<()> {
915        let cell_ref = self
916            .current_table_cell()
917            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
918        self.insert_table_column(cell_ref.table.id(), cell_ref.column + 1)
919    }
920
921    /// Remove the row at the cursor's current position.
922    /// Returns an error if the cursor is not inside a table.
923    pub fn remove_current_row(&self) -> Result<()> {
924        let cell_ref = self
925            .current_table_cell()
926            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
927        self.remove_table_row(cell_ref.table.id(), cell_ref.row)
928    }
929
930    /// Remove the column at the cursor's current position.
931    /// Returns an error if the cursor is not inside a table.
932    pub fn remove_current_column(&self) -> Result<()> {
933        let cell_ref = self
934            .current_table_cell()
935            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
936        self.remove_table_column(cell_ref.table.id(), cell_ref.column)
937    }
938
939    /// Merge cells spanned by the current selection.
940    ///
941    /// Both cursor position and anchor must be inside the same table.
942    /// The cell range is derived from the cells at position and anchor.
943    /// Returns an error if the cursor is not inside a table or position
944    /// and anchor are in different tables.
945    pub fn merge_selected_cells(&self) -> Result<()> {
946        let pos_cell = self
947            .current_table_cell()
948            .ok_or_else(|| anyhow::anyhow!("cursor position is not inside a table"))?;
949
950        // Get anchor cell
951        let (_pos, anchor) = self.read_cursor();
952        let anchor_cell = {
953            // Create a temporary block handle at the anchor position
954            let inner = self.doc.lock();
955            let dto = frontend::document_inspection::GetBlockAtPositionDto {
956                position: to_i64(anchor),
957            };
958            let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
959                .map_err(|_| anyhow::anyhow!("cursor anchor is not inside a table"))?;
960            let block = crate::text_block::TextBlock {
961                doc: self.doc.clone(),
962                block_id: block_info.block_id as usize,
963            };
964            drop(inner);
965            block
966                .table_cell()
967                .ok_or_else(|| anyhow::anyhow!("cursor anchor is not inside a table"))?
968        };
969
970        if pos_cell.table.id() != anchor_cell.table.id() {
971            return Err(anyhow::anyhow!(
972                "position and anchor are in different tables"
973            ));
974        }
975
976        let start_row = pos_cell.row.min(anchor_cell.row);
977        let start_col = pos_cell.column.min(anchor_cell.column);
978        let end_row = pos_cell.row.max(anchor_cell.row);
979        let end_col = pos_cell.column.max(anchor_cell.column);
980
981        self.merge_table_cells(pos_cell.table.id(), start_row, start_col, end_row, end_col)
982    }
983
984    /// Split the cell at the cursor's current position.
985    /// Returns an error if the cursor is not inside a table.
986    pub fn split_current_cell(&self, split_rows: usize, split_columns: usize) -> Result<()> {
987        let cell_ref = self
988            .current_table_cell()
989            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
990        // Get the cell entity ID from the table handle
991        let cell = cell_ref
992            .table
993            .cell(cell_ref.row, cell_ref.column)
994            .ok_or_else(|| anyhow::anyhow!("cell not found"))?;
995        // TextTableCell stores cell_id
996        self.split_table_cell(cell.id(), split_rows, split_columns)
997    }
998
999    /// Set formatting on the table the cursor is currently inside.
1000    /// Returns an error if the cursor is not inside a table.
1001    pub fn set_current_table_format(&self, format: &crate::flow::TableFormat) -> Result<()> {
1002        let table = self
1003            .current_table()
1004            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1005        self.set_table_format(table.id(), format)
1006    }
1007
1008    /// Set formatting on the cell the cursor is currently inside.
1009    /// Returns an error if the cursor is not inside a table.
1010    pub fn set_current_cell_format(&self, format: &crate::flow::CellFormat) -> Result<()> {
1011        let cell_ref = self
1012            .current_table_cell()
1013            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1014        let cell = cell_ref
1015            .table
1016            .cell(cell_ref.row, cell_ref.column)
1017            .ok_or_else(|| anyhow::anyhow!("cell not found"))?;
1018        self.set_table_cell_format(cell.id(), format)
1019    }
1020
1021    /// Delete the character after the cursor (Delete key).
1022    pub fn delete_char(&self) -> Result<()> {
1023        let (pos, anchor) = self.read_cursor();
1024        let (del_pos, del_anchor) = if pos != anchor {
1025            (pos, anchor)
1026        } else {
1027            (pos, pos + 1)
1028        };
1029        self.do_delete(del_pos, del_anchor)
1030    }
1031
1032    /// Delete the character before the cursor (Backspace key).
1033    pub fn delete_previous_char(&self) -> Result<()> {
1034        let (pos, anchor) = self.read_cursor();
1035        let (del_pos, del_anchor) = if pos != anchor {
1036            (pos, anchor)
1037        } else if pos > 0 {
1038            (pos - 1, pos)
1039        } else {
1040            return Ok(());
1041        };
1042        self.do_delete(del_pos, del_anchor)
1043    }
1044
1045    /// Delete the selected text. Returns the deleted text. No-op if no selection.
1046    pub fn remove_selected_text(&self) -> Result<String> {
1047        let (pos, anchor) = self.read_cursor();
1048        if pos == anchor {
1049            return Ok(String::new());
1050        }
1051        let queued = {
1052            let mut inner = self.doc.lock();
1053            let dto = frontend::document_editing::DeleteTextDto {
1054                position: to_i64(pos),
1055                anchor: to_i64(anchor),
1056            };
1057            let result =
1058                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1059            let edit_pos = pos.min(anchor);
1060            let removed = pos.max(anchor) - edit_pos;
1061            let new_pos = to_usize(result.new_position);
1062            inner.adjust_cursors(edit_pos, removed, 0);
1063            {
1064                let mut d = self.data.lock();
1065                d.position = new_pos;
1066                d.anchor = new_pos;
1067            }
1068            inner.modified = true;
1069            inner.invalidate_text_cache();
1070            inner.rehighlight_affected(edit_pos);
1071            inner.queue_event(DocumentEvent::ContentsChanged {
1072                position: edit_pos,
1073                chars_removed: removed,
1074                chars_added: 0,
1075                blocks_affected: 1,
1076            });
1077            inner.check_block_count_changed();
1078            inner.check_flow_changed();
1079            // Return the deleted text alongside the queued events
1080            (result.deleted_text, self.queue_undo_redo_event(&mut inner))
1081        };
1082        crate::inner::dispatch_queued_events(queued.1);
1083        Ok(queued.0)
1084    }
1085
1086    // ── List operations ──────────────────────────────────────
1087
1088    /// Turn the block(s) in the selection into a list.
1089    pub fn create_list(&self, style: ListStyle) -> Result<()> {
1090        let (pos, anchor) = self.read_cursor();
1091        let queued = {
1092            let mut inner = self.doc.lock();
1093            let dto = frontend::document_editing::CreateListDto {
1094                position: to_i64(pos),
1095                anchor: to_i64(anchor),
1096                style: style.clone(),
1097            };
1098            document_editing_commands::create_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1099            inner.modified = true;
1100            inner.rehighlight_affected(pos.min(anchor));
1101            inner.queue_event(DocumentEvent::ContentsChanged {
1102                position: pos.min(anchor),
1103                chars_removed: 0,
1104                chars_added: 0,
1105                blocks_affected: 1,
1106            });
1107            self.queue_undo_redo_event(&mut inner)
1108        };
1109        crate::inner::dispatch_queued_events(queued);
1110        Ok(())
1111    }
1112
1113    /// Insert a new list item at the cursor position.
1114    pub fn insert_list(&self, style: ListStyle) -> Result<()> {
1115        let (pos, anchor) = self.read_cursor();
1116        let queued = {
1117            let mut inner = self.doc.lock();
1118            let dto = frontend::document_editing::InsertListDto {
1119                position: to_i64(pos),
1120                anchor: to_i64(anchor),
1121                style: style.clone(),
1122            };
1123            let result =
1124                document_editing_commands::insert_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1125            let edit_pos = pos.min(anchor);
1126            let removed = pos.max(anchor) - edit_pos;
1127            self.finish_edit(
1128                &mut inner,
1129                edit_pos,
1130                removed,
1131                to_usize(result.new_position),
1132                1,
1133            )
1134        };
1135        crate::inner::dispatch_queued_events(queued);
1136        Ok(())
1137    }
1138
1139    // ── Format queries ───────────────────────────────────────
1140
1141    /// Get the character format at the cursor position.
1142    pub fn char_format(&self) -> Result<TextFormat> {
1143        let pos = self.position();
1144        let inner = self.doc.lock();
1145        let dto = frontend::document_inspection::GetTextAtPositionDto {
1146            position: to_i64(pos),
1147            length: 1,
1148        };
1149        let text_info = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
1150        let element_id = text_info.element_id as u64;
1151        let element = inline_element_commands::get_inline_element(&inner.ctx, &element_id)?
1152            .ok_or_else(|| anyhow::anyhow!("element not found at position"))?;
1153        Ok(TextFormat::from(&element))
1154    }
1155
1156    /// Get the block format of the block containing the cursor.
1157    pub fn block_format(&self) -> Result<BlockFormat> {
1158        let pos = self.position();
1159        let inner = self.doc.lock();
1160        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1161            position: to_i64(pos),
1162        };
1163        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1164        let block_id = block_info.block_id as u64;
1165        let block = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
1166            .ok_or_else(|| anyhow::anyhow!("block not found"))?;
1167        Ok(BlockFormat::from(&block))
1168    }
1169
1170    // ── Format application ───────────────────────────────────
1171
1172    /// Set the character format for the selection.
1173    pub fn set_char_format(&self, format: &TextFormat) -> Result<()> {
1174        let (pos, anchor) = self.read_cursor();
1175        let queued = {
1176            let mut inner = self.doc.lock();
1177            let dto = format.to_set_dto(pos, anchor);
1178            document_formatting_commands::set_text_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1179            let start = pos.min(anchor);
1180            let length = pos.max(anchor) - start;
1181            inner.modified = true;
1182            inner.queue_event(DocumentEvent::FormatChanged {
1183                position: start,
1184                length,
1185                kind: crate::flow::FormatChangeKind::Character,
1186            });
1187            self.queue_undo_redo_event(&mut inner)
1188        };
1189        crate::inner::dispatch_queued_events(queued);
1190        Ok(())
1191    }
1192
1193    /// Merge a character format into the selection.
1194    pub fn merge_char_format(&self, format: &TextFormat) -> Result<()> {
1195        let (pos, anchor) = self.read_cursor();
1196        let queued = {
1197            let mut inner = self.doc.lock();
1198            let dto = format.to_merge_dto(pos, anchor);
1199            document_formatting_commands::merge_text_format(
1200                &inner.ctx,
1201                Some(inner.stack_id),
1202                &dto,
1203            )?;
1204            let start = pos.min(anchor);
1205            let length = pos.max(anchor) - start;
1206            inner.modified = true;
1207            inner.queue_event(DocumentEvent::FormatChanged {
1208                position: start,
1209                length,
1210                kind: crate::flow::FormatChangeKind::Character,
1211            });
1212            self.queue_undo_redo_event(&mut inner)
1213        };
1214        crate::inner::dispatch_queued_events(queued);
1215        Ok(())
1216    }
1217
1218    /// Set the block format for the current block (or all blocks in selection).
1219    pub fn set_block_format(&self, format: &BlockFormat) -> Result<()> {
1220        let (pos, anchor) = self.read_cursor();
1221        let queued = {
1222            let mut inner = self.doc.lock();
1223            let dto = format.to_set_dto(pos, anchor);
1224            document_formatting_commands::set_block_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1225            let start = pos.min(anchor);
1226            let length = pos.max(anchor) - start;
1227            inner.modified = true;
1228            inner.queue_event(DocumentEvent::FormatChanged {
1229                position: start,
1230                length,
1231                kind: crate::flow::FormatChangeKind::Block,
1232            });
1233            self.queue_undo_redo_event(&mut inner)
1234        };
1235        crate::inner::dispatch_queued_events(queued);
1236        Ok(())
1237    }
1238
1239    /// Set the frame format.
1240    pub fn set_frame_format(&self, frame_id: usize, format: &FrameFormat) -> Result<()> {
1241        let (pos, anchor) = self.read_cursor();
1242        let queued = {
1243            let mut inner = self.doc.lock();
1244            let dto = format.to_set_dto(pos, anchor, frame_id);
1245            document_formatting_commands::set_frame_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1246            let start = pos.min(anchor);
1247            let length = pos.max(anchor) - start;
1248            inner.modified = true;
1249            inner.queue_event(DocumentEvent::FormatChanged {
1250                position: start,
1251                length,
1252                kind: crate::flow::FormatChangeKind::Block,
1253            });
1254            self.queue_undo_redo_event(&mut inner)
1255        };
1256        crate::inner::dispatch_queued_events(queued);
1257        Ok(())
1258    }
1259
1260    // ── Edit blocks (composite undo) ─────────────────────────
1261
1262    /// Begin a group of operations that will be undone as a single unit.
1263    pub fn begin_edit_block(&self) {
1264        let inner = self.doc.lock();
1265        undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
1266    }
1267
1268    /// End the current edit block.
1269    pub fn end_edit_block(&self) {
1270        let inner = self.doc.lock();
1271        undo_redo_commands::end_composite(&inner.ctx);
1272    }
1273
1274    /// Alias for [`begin_edit_block`](Self::begin_edit_block).
1275    ///
1276    /// Semantically indicates that the new composite should be merged with
1277    /// the previous one (e.g., consecutive keystrokes grouped into a single
1278    /// undo unit). The current backend treats this identically to
1279    /// `begin_edit_block`; future versions may implement automatic merging.
1280    pub fn join_previous_edit_block(&self) {
1281        self.begin_edit_block();
1282    }
1283
1284    // ── Private helpers ─────────────────────────────────────
1285
1286    /// Queue an `UndoRedoChanged` event and return all queued events for dispatch.
1287    fn queue_undo_redo_event(&self, inner: &mut TextDocumentInner) -> QueuedEvents {
1288        let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1289        let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1290        inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1291        inner.take_queued_events()
1292    }
1293
1294    fn do_delete(&self, pos: usize, anchor: usize) -> Result<()> {
1295        let queued = {
1296            let mut inner = self.doc.lock();
1297            let dto = frontend::document_editing::DeleteTextDto {
1298                position: to_i64(pos),
1299                anchor: to_i64(anchor),
1300            };
1301            let result =
1302                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1303            let edit_pos = pos.min(anchor);
1304            let removed = pos.max(anchor) - edit_pos;
1305            let new_pos = to_usize(result.new_position);
1306            inner.adjust_cursors(edit_pos, removed, 0);
1307            {
1308                let mut d = self.data.lock();
1309                d.position = new_pos;
1310                d.anchor = new_pos;
1311            }
1312            inner.modified = true;
1313            inner.invalidate_text_cache();
1314            inner.rehighlight_affected(edit_pos);
1315            inner.queue_event(DocumentEvent::ContentsChanged {
1316                position: edit_pos,
1317                chars_removed: removed,
1318                chars_added: 0,
1319                blocks_affected: 1,
1320            });
1321            inner.check_block_count_changed();
1322            inner.check_flow_changed();
1323            self.queue_undo_redo_event(&mut inner)
1324        };
1325        crate::inner::dispatch_queued_events(queued);
1326        Ok(())
1327    }
1328
1329    /// Resolve a MoveOperation to a concrete position.
1330    fn resolve_move(&self, op: MoveOperation, n: usize) -> usize {
1331        let pos = self.position();
1332        match op {
1333            MoveOperation::NoMove => pos,
1334            MoveOperation::Start => 0,
1335            MoveOperation::End => {
1336                let inner = self.doc.lock();
1337                document_inspection_commands::get_document_stats(&inner.ctx)
1338                    .map(|s| max_cursor_position(&s))
1339                    .unwrap_or(pos)
1340            }
1341            MoveOperation::NextCharacter | MoveOperation::Right => pos + n,
1342            MoveOperation::PreviousCharacter | MoveOperation::Left => pos.saturating_sub(n),
1343            MoveOperation::StartOfBlock | MoveOperation::StartOfLine => {
1344                let inner = self.doc.lock();
1345                let dto = frontend::document_inspection::GetBlockAtPositionDto {
1346                    position: to_i64(pos),
1347                };
1348                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1349                    .map(|info| to_usize(info.block_start))
1350                    .unwrap_or(pos)
1351            }
1352            MoveOperation::EndOfBlock | MoveOperation::EndOfLine => {
1353                let inner = self.doc.lock();
1354                let dto = frontend::document_inspection::GetBlockAtPositionDto {
1355                    position: to_i64(pos),
1356                };
1357                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1358                    .map(|info| to_usize(info.block_start) + to_usize(info.block_length))
1359                    .unwrap_or(pos)
1360            }
1361            MoveOperation::NextBlock => {
1362                let inner = self.doc.lock();
1363                let dto = frontend::document_inspection::GetBlockAtPositionDto {
1364                    position: to_i64(pos),
1365                };
1366                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1367                    .map(|info| {
1368                        // Move past current block + 1 (block separator)
1369                        to_usize(info.block_start) + to_usize(info.block_length) + 1
1370                    })
1371                    .unwrap_or(pos)
1372            }
1373            MoveOperation::PreviousBlock => {
1374                let inner = self.doc.lock();
1375                let dto = frontend::document_inspection::GetBlockAtPositionDto {
1376                    position: to_i64(pos),
1377                };
1378                let block_start =
1379                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1380                        .map(|info| to_usize(info.block_start))
1381                        .unwrap_or(pos);
1382                if block_start >= 2 {
1383                    // Skip past the block separator (which maps to the current block)
1384                    let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
1385                        position: to_i64(block_start - 2),
1386                    };
1387                    document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto)
1388                        .map(|info| to_usize(info.block_start))
1389                        .unwrap_or(0)
1390                } else {
1391                    0
1392                }
1393            }
1394            MoveOperation::NextWord | MoveOperation::EndOfWord | MoveOperation::WordRight => {
1395                let (_, end) = self.find_word_boundaries(pos);
1396                // Move past the word end to the next word
1397                if end == pos {
1398                    // Already at a boundary, skip whitespace
1399                    let inner = self.doc.lock();
1400                    let max_pos = document_inspection_commands::get_document_stats(&inner.ctx)
1401                        .map(|s| max_cursor_position(&s))
1402                        .unwrap_or(0);
1403                    let scan_len = max_pos.saturating_sub(pos).min(64);
1404                    if scan_len == 0 {
1405                        return pos;
1406                    }
1407                    let dto = frontend::document_inspection::GetTextAtPositionDto {
1408                        position: to_i64(pos),
1409                        length: to_i64(scan_len),
1410                    };
1411                    if let Ok(r) =
1412                        document_inspection_commands::get_text_at_position(&inner.ctx, &dto)
1413                    {
1414                        for (i, ch) in r.text.chars().enumerate() {
1415                            if ch.is_alphanumeric() || ch == '_' {
1416                                // Found start of next word, find its end
1417                                let word_pos = pos + i;
1418                                drop(inner);
1419                                let (_, word_end) = self.find_word_boundaries(word_pos);
1420                                return word_end;
1421                            }
1422                        }
1423                    }
1424                    pos + scan_len
1425                } else {
1426                    end
1427                }
1428            }
1429            MoveOperation::PreviousWord | MoveOperation::StartOfWord | MoveOperation::WordLeft => {
1430                let (start, _) = self.find_word_boundaries(pos);
1431                if start < pos {
1432                    start
1433                } else if pos > 0 {
1434                    // Cursor is at a word start or on whitespace — scan backwards
1435                    // to find the start of the previous word.
1436                    let mut search = pos - 1;
1437                    loop {
1438                        let (ws, we) = self.find_word_boundaries(search);
1439                        if ws < we {
1440                            // Found a word; return its start
1441                            break ws;
1442                        }
1443                        // Still on whitespace/non-word; keep scanning
1444                        if search == 0 {
1445                            break 0;
1446                        }
1447                        search -= 1;
1448                    }
1449                } else {
1450                    0
1451                }
1452            }
1453            MoveOperation::Up | MoveOperation::Down => {
1454                // Up/Down are visual operations that depend on line wrapping.
1455                // Without layout info, treat as PreviousBlock/NextBlock.
1456                if matches!(op, MoveOperation::Up) {
1457                    self.resolve_move(MoveOperation::PreviousBlock, 1)
1458                } else {
1459                    self.resolve_move(MoveOperation::NextBlock, 1)
1460                }
1461            }
1462        }
1463    }
1464
1465    /// Find the word boundaries around `pos`. Returns (start, end).
1466    /// Uses Unicode word segmentation for correct handling of non-ASCII text.
1467    ///
1468    /// Single-pass: tracks the last word seen to avoid a second iteration
1469    /// when the cursor is at the end of the last word (ISSUE-18).
1470    fn find_word_boundaries(&self, pos: usize) -> (usize, usize) {
1471        let inner = self.doc.lock();
1472        // Get block info so we can fetch the full block text
1473        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
1474            position: to_i64(pos),
1475        };
1476        let block_info =
1477            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
1478                Ok(info) => info,
1479                Err(_) => return (pos, pos),
1480            };
1481
1482        let block_start = to_usize(block_info.block_start);
1483        let block_length = to_usize(block_info.block_length);
1484        if block_length == 0 {
1485            return (pos, pos);
1486        }
1487
1488        let dto = frontend::document_inspection::GetTextAtPositionDto {
1489            position: to_i64(block_start),
1490            length: to_i64(block_length),
1491        };
1492        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &dto) {
1493            Ok(r) => r.text,
1494            Err(_) => return (pos, pos),
1495        };
1496
1497        // cursor_offset is the char offset within the block text
1498        let cursor_offset = pos.saturating_sub(block_start);
1499
1500        // Single pass: track the last word seen for end-of-last-word check
1501        let mut last_char_start = 0;
1502        let mut last_char_end = 0;
1503
1504        for (word_byte_start, word) in text.unicode_word_indices() {
1505            // Convert byte offset to char offset
1506            let word_char_start = text[..word_byte_start].chars().count();
1507            let word_char_len = word.chars().count();
1508            let word_char_end = word_char_start + word_char_len;
1509
1510            last_char_start = word_char_start;
1511            last_char_end = word_char_end;
1512
1513            if cursor_offset >= word_char_start && cursor_offset < word_char_end {
1514                return (block_start + word_char_start, block_start + word_char_end);
1515            }
1516        }
1517
1518        // Check if cursor is exactly at the end of the last word
1519        if cursor_offset == last_char_end && last_char_start < last_char_end {
1520            return (block_start + last_char_start, block_start + last_char_end);
1521        }
1522
1523        (pos, pos)
1524    }
1525}