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