Skip to main content

text_document/
cursor.rs

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