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