Skip to main content

text_document/
cursor.rs

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