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