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            (pos, pos + 1)
1045        };
1046        self.do_delete(del_pos, del_anchor)
1047    }
1048
1049    /// Delete the character before the cursor (Backspace key).
1050    pub fn delete_previous_char(&self) -> Result<()> {
1051        let (pos, anchor) = self.read_cursor();
1052        let (del_pos, del_anchor) = if pos != anchor {
1053            (pos, anchor)
1054        } else if pos > 0 {
1055            (pos - 1, pos)
1056        } else {
1057            return Ok(());
1058        };
1059        self.do_delete(del_pos, del_anchor)
1060    }
1061
1062    /// Delete the selected text. Returns the deleted text. No-op if no selection.
1063    pub fn remove_selected_text(&self) -> Result<String> {
1064        let (pos, anchor) = self.read_cursor();
1065        if pos == anchor {
1066            return Ok(String::new());
1067        }
1068        let queued = {
1069            let mut inner = self.doc.lock();
1070            let dto = frontend::document_editing::DeleteTextDto {
1071                position: to_i64(pos),
1072                anchor: to_i64(anchor),
1073            };
1074            let result =
1075                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1076            let edit_pos = pos.min(anchor);
1077            let removed = pos.max(anchor) - edit_pos;
1078            let new_pos = to_usize(result.new_position);
1079            inner.adjust_cursors(edit_pos, removed, 0);
1080            {
1081                let mut d = self.data.lock();
1082                d.position = new_pos;
1083                d.anchor = new_pos;
1084            }
1085            inner.modified = true;
1086            inner.invalidate_text_cache();
1087            inner.rehighlight_affected(edit_pos);
1088            inner.queue_event(DocumentEvent::ContentsChanged {
1089                position: edit_pos,
1090                chars_removed: removed,
1091                chars_added: 0,
1092                blocks_affected: 1,
1093            });
1094            inner.check_block_count_changed();
1095            inner.check_flow_changed();
1096            // Return the deleted text alongside the queued events
1097            (result.deleted_text, self.queue_undo_redo_event(&mut inner))
1098        };
1099        crate::inner::dispatch_queued_events(queued.1);
1100        Ok(queued.0)
1101    }
1102
1103    // ── List operations ──────────────────────────────────────
1104
1105    /// Returns the list that the block at the cursor position belongs to,
1106    /// or `None` if the current block is not a list item.
1107    pub fn current_list(&self) -> Option<crate::TextList> {
1108        let pos = self.position();
1109        let inner = self.doc.lock();
1110        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1111            position: to_i64(pos),
1112        };
1113        let block_info =
1114            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1115        let block = crate::text_block::TextBlock {
1116            doc: self.doc.clone(),
1117            block_id: block_info.block_id as usize,
1118        };
1119        drop(inner);
1120        block.list()
1121    }
1122
1123    /// Turn the block(s) in the selection into a list.
1124    pub fn create_list(&self, style: ListStyle) -> Result<()> {
1125        let (pos, anchor) = self.read_cursor();
1126        let queued = {
1127            let mut inner = self.doc.lock();
1128            let dto = frontend::document_editing::CreateListDto {
1129                position: to_i64(pos),
1130                anchor: to_i64(anchor),
1131                style: style.clone(),
1132            };
1133            document_editing_commands::create_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1134            inner.modified = true;
1135            inner.rehighlight_affected(pos.min(anchor));
1136            inner.queue_event(DocumentEvent::ContentsChanged {
1137                position: pos.min(anchor),
1138                chars_removed: 0,
1139                chars_added: 0,
1140                blocks_affected: 1,
1141            });
1142            self.queue_undo_redo_event(&mut inner)
1143        };
1144        crate::inner::dispatch_queued_events(queued);
1145        Ok(())
1146    }
1147
1148    /// Insert a new list item at the cursor position.
1149    pub fn insert_list(&self, style: ListStyle) -> Result<()> {
1150        let (pos, anchor) = self.read_cursor();
1151        let queued = {
1152            let mut inner = self.doc.lock();
1153            let dto = frontend::document_editing::InsertListDto {
1154                position: to_i64(pos),
1155                anchor: to_i64(anchor),
1156                style: style.clone(),
1157            };
1158            let result =
1159                document_editing_commands::insert_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1160            let edit_pos = pos.min(anchor);
1161            let removed = pos.max(anchor) - edit_pos;
1162            self.finish_edit_ext(
1163                &mut inner,
1164                edit_pos,
1165                removed,
1166                to_usize(result.new_position),
1167                1,
1168                false,
1169            )
1170        };
1171        crate::inner::dispatch_queued_events(queued);
1172        Ok(())
1173    }
1174
1175    /// Set formatting on a list by its ID.
1176    pub fn set_list_format(&self, list_id: usize, format: &crate::ListFormat) -> Result<()> {
1177        let queued = {
1178            let mut inner = self.doc.lock();
1179            let dto = format.to_set_dto(list_id);
1180            document_formatting_commands::set_list_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1181            inner.modified = true;
1182            inner.queue_event(DocumentEvent::FormatChanged {
1183                position: 0,
1184                length: 0,
1185                kind: crate::flow::FormatChangeKind::List,
1186            });
1187            self.queue_undo_redo_event(&mut inner)
1188        };
1189        crate::inner::dispatch_queued_events(queued);
1190        Ok(())
1191    }
1192
1193    /// Set formatting on the list that the current block belongs to.
1194    /// Returns an error if the cursor is not inside a list item.
1195    pub fn set_current_list_format(&self, format: &crate::ListFormat) -> Result<()> {
1196        let list = self
1197            .current_list()
1198            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a list"))?;
1199        self.set_list_format(list.id(), format)
1200    }
1201
1202    /// Add a block to a list by their IDs.
1203    pub fn add_block_to_list(&self, block_id: usize, list_id: usize) -> Result<()> {
1204        let queued = {
1205            let mut inner = self.doc.lock();
1206            let dto = frontend::document_editing::AddBlockToListDto {
1207                block_id: to_i64(block_id),
1208                list_id: to_i64(list_id),
1209            };
1210            document_editing_commands::add_block_to_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1211            inner.modified = true;
1212            inner.queue_event(DocumentEvent::ContentsChanged {
1213                position: 0,
1214                chars_removed: 0,
1215                chars_added: 0,
1216                blocks_affected: 1,
1217            });
1218            self.queue_undo_redo_event(&mut inner)
1219        };
1220        crate::inner::dispatch_queued_events(queued);
1221        Ok(())
1222    }
1223
1224    /// Add the block at the cursor position to a list.
1225    pub fn add_current_block_to_list(&self, list_id: usize) -> Result<()> {
1226        let pos = self.position();
1227        let inner = self.doc.lock();
1228        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1229            position: to_i64(pos),
1230        };
1231        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1232        drop(inner);
1233        self.add_block_to_list(block_info.block_id as usize, list_id)
1234    }
1235
1236    /// Remove a block from its list by block ID.
1237    pub fn remove_block_from_list(&self, block_id: usize) -> Result<()> {
1238        let queued = {
1239            let mut inner = self.doc.lock();
1240            let dto = frontend::document_editing::RemoveBlockFromListDto {
1241                block_id: to_i64(block_id),
1242            };
1243            document_editing_commands::remove_block_from_list(
1244                &inner.ctx,
1245                Some(inner.stack_id),
1246                &dto,
1247            )?;
1248            inner.modified = true;
1249            inner.queue_event(DocumentEvent::ContentsChanged {
1250                position: 0,
1251                chars_removed: 0,
1252                chars_added: 0,
1253                blocks_affected: 1,
1254            });
1255            self.queue_undo_redo_event(&mut inner)
1256        };
1257        crate::inner::dispatch_queued_events(queued);
1258        Ok(())
1259    }
1260
1261    /// Remove the block at the cursor position from its list.
1262    /// Returns an error if the current block is not a list item.
1263    pub fn remove_current_block_from_list(&self) -> Result<()> {
1264        let pos = self.position();
1265        let inner = self.doc.lock();
1266        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1267            position: to_i64(pos),
1268        };
1269        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1270        drop(inner);
1271        self.remove_block_from_list(block_info.block_id as usize)
1272    }
1273
1274    /// Remove a list item by index within the list.
1275    /// Resolves the index to a block, then removes it from the list.
1276    pub fn remove_list_item(&self, list_id: usize, index: usize) -> Result<()> {
1277        let list = crate::text_list::TextList {
1278            doc: self.doc.clone(),
1279            list_id,
1280        };
1281        let block = list
1282            .item(index)
1283            .ok_or_else(|| anyhow::anyhow!("list item index {index} out of range"))?;
1284        self.remove_block_from_list(block.id())
1285    }
1286
1287    // ── Format queries ───────────────────────────────────────
1288
1289    /// Get the character format at the cursor position.
1290    pub fn char_format(&self) -> Result<TextFormat> {
1291        let pos = self.position();
1292        let inner = self.doc.lock();
1293        let dto = frontend::document_inspection::GetTextAtPositionDto {
1294            position: to_i64(pos),
1295            length: 1,
1296        };
1297        let text_info = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
1298        let element_id = text_info.element_id as u64;
1299        let element = inline_element_commands::get_inline_element(&inner.ctx, &element_id)?
1300            .ok_or_else(|| anyhow::anyhow!("element not found at position"))?;
1301        Ok(TextFormat::from(&element))
1302    }
1303
1304    /// Get the block format of the block containing the cursor.
1305    pub fn block_format(&self) -> Result<BlockFormat> {
1306        let pos = self.position();
1307        let inner = self.doc.lock();
1308        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1309            position: to_i64(pos),
1310        };
1311        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1312        let block_id = block_info.block_id as u64;
1313        let block = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
1314            .ok_or_else(|| anyhow::anyhow!("block not found"))?;
1315        Ok(BlockFormat::from(&block))
1316    }
1317
1318    // ── Format application ───────────────────────────────────
1319
1320    /// Set the character format for the selection.
1321    pub fn set_char_format(&self, format: &TextFormat) -> Result<()> {
1322        let (pos, anchor) = self.read_cursor();
1323        let queued = {
1324            let mut inner = self.doc.lock();
1325            let dto = format.to_set_dto(pos, anchor);
1326            document_formatting_commands::set_text_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1327            let start = pos.min(anchor);
1328            let length = pos.max(anchor) - start;
1329            inner.modified = true;
1330            inner.queue_event(DocumentEvent::FormatChanged {
1331                position: start,
1332                length,
1333                kind: crate::flow::FormatChangeKind::Character,
1334            });
1335            self.queue_undo_redo_event(&mut inner)
1336        };
1337        crate::inner::dispatch_queued_events(queued);
1338        Ok(())
1339    }
1340
1341    /// Merge a character format into the selection.
1342    pub fn merge_char_format(&self, format: &TextFormat) -> Result<()> {
1343        let (pos, anchor) = self.read_cursor();
1344        let queued = {
1345            let mut inner = self.doc.lock();
1346            let dto = format.to_merge_dto(pos, anchor);
1347            document_formatting_commands::merge_text_format(
1348                &inner.ctx,
1349                Some(inner.stack_id),
1350                &dto,
1351            )?;
1352            let start = pos.min(anchor);
1353            let length = pos.max(anchor) - start;
1354            inner.modified = true;
1355            inner.queue_event(DocumentEvent::FormatChanged {
1356                position: start,
1357                length,
1358                kind: crate::flow::FormatChangeKind::Character,
1359            });
1360            self.queue_undo_redo_event(&mut inner)
1361        };
1362        crate::inner::dispatch_queued_events(queued);
1363        Ok(())
1364    }
1365
1366    /// Set the block format for the current block (or all blocks in selection).
1367    pub fn set_block_format(&self, format: &BlockFormat) -> Result<()> {
1368        let (pos, anchor) = self.read_cursor();
1369        let queued = {
1370            let mut inner = self.doc.lock();
1371            let dto = format.to_set_dto(pos, anchor);
1372            document_formatting_commands::set_block_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1373            let start = pos.min(anchor);
1374            let length = pos.max(anchor) - start;
1375            inner.modified = true;
1376            inner.queue_event(DocumentEvent::FormatChanged {
1377                position: start,
1378                length,
1379                kind: crate::flow::FormatChangeKind::Block,
1380            });
1381            self.queue_undo_redo_event(&mut inner)
1382        };
1383        crate::inner::dispatch_queued_events(queued);
1384        Ok(())
1385    }
1386
1387    /// Set the frame format.
1388    pub fn set_frame_format(&self, frame_id: usize, format: &FrameFormat) -> Result<()> {
1389        let (pos, anchor) = self.read_cursor();
1390        let queued = {
1391            let mut inner = self.doc.lock();
1392            let dto = format.to_set_dto(pos, anchor, frame_id);
1393            document_formatting_commands::set_frame_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1394            let start = pos.min(anchor);
1395            let length = pos.max(anchor) - start;
1396            inner.modified = true;
1397            inner.queue_event(DocumentEvent::FormatChanged {
1398                position: start,
1399                length,
1400                kind: crate::flow::FormatChangeKind::Block,
1401            });
1402            self.queue_undo_redo_event(&mut inner)
1403        };
1404        crate::inner::dispatch_queued_events(queued);
1405        Ok(())
1406    }
1407
1408    // ── Edit blocks (composite undo) ─────────────────────────
1409
1410    /// Begin a group of operations that will be undone as a single unit.
1411    pub fn begin_edit_block(&self) {
1412        let inner = self.doc.lock();
1413        undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
1414    }
1415
1416    /// End the current edit block.
1417    pub fn end_edit_block(&self) {
1418        let inner = self.doc.lock();
1419        undo_redo_commands::end_composite(&inner.ctx);
1420    }
1421
1422    /// Alias for [`begin_edit_block`](Self::begin_edit_block).
1423    ///
1424    /// Semantically indicates that the new composite should be merged with
1425    /// the previous one (e.g., consecutive keystrokes grouped into a single
1426    /// undo unit). The current backend treats this identically to
1427    /// `begin_edit_block`; future versions may implement automatic merging.
1428    pub fn join_previous_edit_block(&self) {
1429        self.begin_edit_block();
1430    }
1431
1432    // ── Private helpers ─────────────────────────────────────
1433
1434    /// Queue an `UndoRedoChanged` event and return all queued events for dispatch.
1435    fn queue_undo_redo_event(&self, inner: &mut TextDocumentInner) -> QueuedEvents {
1436        let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1437        let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1438        inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1439        inner.take_queued_events()
1440    }
1441
1442    fn do_delete(&self, pos: usize, anchor: usize) -> Result<()> {
1443        let queued = {
1444            let mut inner = self.doc.lock();
1445            let dto = frontend::document_editing::DeleteTextDto {
1446                position: to_i64(pos),
1447                anchor: to_i64(anchor),
1448            };
1449            let result =
1450                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1451            let edit_pos = pos.min(anchor);
1452            let removed = pos.max(anchor) - edit_pos;
1453            let new_pos = to_usize(result.new_position);
1454            inner.adjust_cursors(edit_pos, removed, 0);
1455            {
1456                let mut d = self.data.lock();
1457                d.position = new_pos;
1458                d.anchor = new_pos;
1459            }
1460            inner.modified = true;
1461            inner.invalidate_text_cache();
1462            inner.rehighlight_affected(edit_pos);
1463            inner.queue_event(DocumentEvent::ContentsChanged {
1464                position: edit_pos,
1465                chars_removed: removed,
1466                chars_added: 0,
1467                blocks_affected: 1,
1468            });
1469            inner.check_block_count_changed();
1470            inner.check_flow_changed();
1471            self.queue_undo_redo_event(&mut inner)
1472        };
1473        crate::inner::dispatch_queued_events(queued);
1474        Ok(())
1475    }
1476
1477    /// Resolve a MoveOperation to a concrete position.
1478    fn resolve_move(&self, op: MoveOperation, n: usize) -> usize {
1479        let pos = self.position();
1480        match op {
1481            MoveOperation::NoMove => pos,
1482            MoveOperation::Start => 0,
1483            MoveOperation::End => {
1484                let inner = self.doc.lock();
1485                document_inspection_commands::get_document_stats(&inner.ctx)
1486                    .map(|s| max_cursor_position(&s))
1487                    .unwrap_or(pos)
1488            }
1489            MoveOperation::NextCharacter | MoveOperation::Right => pos + n,
1490            MoveOperation::PreviousCharacter | MoveOperation::Left => pos.saturating_sub(n),
1491            MoveOperation::StartOfBlock | MoveOperation::StartOfLine => {
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| to_usize(info.block_start))
1498                    .unwrap_or(pos)
1499            }
1500            MoveOperation::EndOfBlock | MoveOperation::EndOfLine => {
1501                let inner = self.doc.lock();
1502                let dto = frontend::document_inspection::GetBlockAtPositionDto {
1503                    position: to_i64(pos),
1504                };
1505                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1506                    .map(|info| to_usize(info.block_start) + to_usize(info.block_length))
1507                    .unwrap_or(pos)
1508            }
1509            MoveOperation::NextBlock => {
1510                let inner = self.doc.lock();
1511                let dto = frontend::document_inspection::GetBlockAtPositionDto {
1512                    position: to_i64(pos),
1513                };
1514                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1515                    .map(|info| {
1516                        // Move past current block + 1 (block separator)
1517                        to_usize(info.block_start) + to_usize(info.block_length) + 1
1518                    })
1519                    .unwrap_or(pos)
1520            }
1521            MoveOperation::PreviousBlock => {
1522                let inner = self.doc.lock();
1523                let dto = frontend::document_inspection::GetBlockAtPositionDto {
1524                    position: to_i64(pos),
1525                };
1526                let block_start =
1527                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1528                        .map(|info| to_usize(info.block_start))
1529                        .unwrap_or(pos);
1530                if block_start >= 2 {
1531                    // Skip past the block separator (which maps to the current block)
1532                    let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
1533                        position: to_i64(block_start - 2),
1534                    };
1535                    document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto)
1536                        .map(|info| to_usize(info.block_start))
1537                        .unwrap_or(0)
1538                } else {
1539                    0
1540                }
1541            }
1542            MoveOperation::NextWord | MoveOperation::EndOfWord | MoveOperation::WordRight => {
1543                let (_, end) = self.find_word_boundaries(pos);
1544                // Move past the word end to the next word
1545                if end == pos {
1546                    // Already at a boundary, skip whitespace
1547                    let inner = self.doc.lock();
1548                    let max_pos = document_inspection_commands::get_document_stats(&inner.ctx)
1549                        .map(|s| max_cursor_position(&s))
1550                        .unwrap_or(0);
1551                    let scan_len = max_pos.saturating_sub(pos).min(64);
1552                    if scan_len == 0 {
1553                        return pos;
1554                    }
1555                    let dto = frontend::document_inspection::GetTextAtPositionDto {
1556                        position: to_i64(pos),
1557                        length: to_i64(scan_len),
1558                    };
1559                    if let Ok(r) =
1560                        document_inspection_commands::get_text_at_position(&inner.ctx, &dto)
1561                    {
1562                        for (i, ch) in r.text.chars().enumerate() {
1563                            if ch.is_alphanumeric() || ch == '_' {
1564                                // Found start of next word, find its end
1565                                let word_pos = pos + i;
1566                                drop(inner);
1567                                let (_, word_end) = self.find_word_boundaries(word_pos);
1568                                return word_end;
1569                            }
1570                        }
1571                    }
1572                    pos + scan_len
1573                } else {
1574                    end
1575                }
1576            }
1577            MoveOperation::PreviousWord | MoveOperation::StartOfWord | MoveOperation::WordLeft => {
1578                let (start, _) = self.find_word_boundaries(pos);
1579                if start < pos {
1580                    start
1581                } else if pos > 0 {
1582                    // Cursor is at a word start or on whitespace — scan backwards
1583                    // to find the start of the previous word.
1584                    let mut search = pos - 1;
1585                    loop {
1586                        let (ws, we) = self.find_word_boundaries(search);
1587                        if ws < we {
1588                            // Found a word; return its start
1589                            break ws;
1590                        }
1591                        // Still on whitespace/non-word; keep scanning
1592                        if search == 0 {
1593                            break 0;
1594                        }
1595                        search -= 1;
1596                    }
1597                } else {
1598                    0
1599                }
1600            }
1601            MoveOperation::Up | MoveOperation::Down => {
1602                // Up/Down are visual operations that depend on line wrapping.
1603                // Without layout info, treat as PreviousBlock/NextBlock.
1604                if matches!(op, MoveOperation::Up) {
1605                    self.resolve_move(MoveOperation::PreviousBlock, 1)
1606                } else {
1607                    self.resolve_move(MoveOperation::NextBlock, 1)
1608                }
1609            }
1610        }
1611    }
1612
1613    /// Find the word boundaries around `pos`. Returns (start, end).
1614    /// Uses Unicode word segmentation for correct handling of non-ASCII text.
1615    ///
1616    /// Single-pass: tracks the last word seen to avoid a second iteration
1617    /// when the cursor is at the end of the last word (ISSUE-18).
1618    fn find_word_boundaries(&self, pos: usize) -> (usize, usize) {
1619        let inner = self.doc.lock();
1620        // Get block info so we can fetch the full block text
1621        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
1622            position: to_i64(pos),
1623        };
1624        let block_info =
1625            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
1626                Ok(info) => info,
1627                Err(_) => return (pos, pos),
1628            };
1629
1630        let block_start = to_usize(block_info.block_start);
1631        let block_length = to_usize(block_info.block_length);
1632        if block_length == 0 {
1633            return (pos, pos);
1634        }
1635
1636        let dto = frontend::document_inspection::GetTextAtPositionDto {
1637            position: to_i64(block_start),
1638            length: to_i64(block_length),
1639        };
1640        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &dto) {
1641            Ok(r) => r.text,
1642            Err(_) => return (pos, pos),
1643        };
1644
1645        // cursor_offset is the char offset within the block text
1646        let cursor_offset = pos.saturating_sub(block_start);
1647
1648        // Single pass: track the last word seen for end-of-last-word check
1649        let mut last_char_start = 0;
1650        let mut last_char_end = 0;
1651
1652        for (word_byte_start, word) in text.unicode_word_indices() {
1653            // Convert byte offset to char offset
1654            let word_char_start = text[..word_byte_start].chars().count();
1655            let word_char_len = word.chars().count();
1656            let word_char_end = word_char_start + word_char_len;
1657
1658            last_char_start = word_char_start;
1659            last_char_end = word_char_end;
1660
1661            if cursor_offset >= word_char_start && cursor_offset < word_char_end {
1662                return (block_start + word_char_start, block_start + word_char_end);
1663            }
1664        }
1665
1666        // Check if cursor is exactly at the end of the last word
1667        if cursor_offset == last_char_end && last_char_start < last_char_end {
1668            return (block_start + last_char_start, block_start + last_char_end);
1669        }
1670
1671        (pos, pos)
1672    }
1673}