Skip to main content

text_document/
document.rs

1//! TextDocument implementation.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use anyhow::Result;
8use base64::Engine;
9use base64::engine::general_purpose::STANDARD as BASE64;
10
11use crate::{ResourceType, TextDirection, WrapMode};
12use frontend::commands::{
13    document_commands, document_inspection_commands, document_io_commands,
14    document_search_commands, resource_commands, undo_redo_commands,
15};
16
17use crate::convert::{self, to_i64, to_usize};
18use crate::cursor::TextCursor;
19use crate::events::{self, DocumentEvent, Subscription};
20use crate::inner::TextDocumentInner;
21use crate::operation::{DocxExportResult, HtmlImportResult, MarkdownImportResult, Operation};
22use crate::{BlockFormat, BlockInfo, DocumentStats, FindMatch, FindOptions};
23
24/// A rich text document.
25///
26/// Owns the backend (database, event hub, undo/redo manager) and provides
27/// document-level operations. All cursor-based editing goes through
28/// [`TextCursor`], obtained via [`cursor()`](TextDocument::cursor) or
29/// [`cursor_at()`](TextDocument::cursor_at).
30///
31/// Internally uses `Arc<Mutex<...>>` so that multiple [`TextCursor`]s can
32/// coexist and edit concurrently. Cloning a `TextDocument` creates a new
33/// handle to the **same** underlying document (like Qt's implicit sharing).
34#[derive(Clone)]
35pub struct TextDocument {
36    pub(crate) inner: Arc<Mutex<TextDocumentInner>>,
37}
38
39impl TextDocument {
40    // ── Construction ──────────────────────────────────────────
41
42    /// Create a new, empty document.
43    ///
44    /// # Panics
45    ///
46    /// Panics if the database context cannot be created (e.g. filesystem error).
47    /// Use [`TextDocument::try_new`] for a fallible alternative.
48    pub fn new() -> Self {
49        Self::try_new().expect("failed to initialize document")
50    }
51
52    /// Create a new, empty document, returning an error on failure.
53    pub fn try_new() -> Result<Self> {
54        let ctx = frontend::AppContext::new();
55        let doc_inner = TextDocumentInner::initialize(ctx)?;
56        let inner = Arc::new(Mutex::new(doc_inner));
57
58        // Bridge backend long-operation events to public DocumentEvent.
59        Self::subscribe_long_operation_events(&inner);
60
61        Ok(Self { inner })
62    }
63
64    /// Subscribe to backend long-operation events and bridge them to DocumentEvent.
65    fn subscribe_long_operation_events(inner: &Arc<Mutex<TextDocumentInner>>) {
66        use frontend::common::event::{LongOperationEvent as LOE, Origin};
67
68        let weak = Arc::downgrade(inner);
69        {
70            let locked = inner.lock();
71            // Progress
72            let w = weak.clone();
73            locked
74                .event_client
75                .subscribe(Origin::LongOperation(LOE::Progress), move |event| {
76                    if let Some(inner) = w.upgrade() {
77                        let (op_id, percent, message) = parse_progress_data(&event.data);
78                        let mut inner = inner.lock();
79                        inner.queue_event(DocumentEvent::LongOperationProgress {
80                            operation_id: op_id,
81                            percent,
82                            message,
83                        });
84                    }
85                });
86
87            // Completed
88            let w = weak.clone();
89            locked
90                .event_client
91                .subscribe(Origin::LongOperation(LOE::Completed), move |event| {
92                    if let Some(inner) = w.upgrade() {
93                        let op_id = parse_id_data(&event.data);
94                        let mut inner = inner.lock();
95                        inner.queue_event(DocumentEvent::DocumentReset);
96                        inner.check_block_count_changed();
97                        inner.reset_cached_child_order();
98                        inner.queue_event(DocumentEvent::LongOperationFinished {
99                            operation_id: op_id,
100                            success: true,
101                            error: None,
102                        });
103                    }
104                });
105
106            // Cancelled
107            let w = weak.clone();
108            locked
109                .event_client
110                .subscribe(Origin::LongOperation(LOE::Cancelled), move |event| {
111                    if let Some(inner) = w.upgrade() {
112                        let op_id = parse_id_data(&event.data);
113                        let mut inner = inner.lock();
114                        inner.queue_event(DocumentEvent::LongOperationFinished {
115                            operation_id: op_id,
116                            success: false,
117                            error: Some("cancelled".into()),
118                        });
119                    }
120                });
121
122            // Failed
123            locked
124                .event_client
125                .subscribe(Origin::LongOperation(LOE::Failed), move |event| {
126                    if let Some(inner) = weak.upgrade() {
127                        let (op_id, error) = parse_failed_data(&event.data);
128                        let mut inner = inner.lock();
129                        inner.queue_event(DocumentEvent::LongOperationFinished {
130                            operation_id: op_id,
131                            success: false,
132                            error: Some(error),
133                        });
134                    }
135                });
136        }
137    }
138
139    // ── Whole-document content ────────────────────────────────
140
141    /// Replace the entire document with plain text. Clears undo history.
142    pub fn set_plain_text(&self, text: &str) -> Result<()> {
143        let queued = {
144            let mut inner = self.inner.lock();
145            let dto = frontend::document_io::ImportPlainTextDto {
146                plain_text: text.into(),
147            };
148            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
149            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
150            inner.invalidate_text_cache();
151            inner.queue_event(DocumentEvent::DocumentReset);
152            inner.check_block_count_changed();
153            inner.reset_cached_child_order();
154            inner.queue_event(DocumentEvent::UndoRedoChanged {
155                can_undo: false,
156                can_redo: false,
157            });
158            inner.take_queued_events()
159        };
160        crate::inner::dispatch_queued_events(queued);
161        Ok(())
162    }
163
164    /// Export the entire document as plain text.
165    pub fn to_plain_text(&self) -> Result<String> {
166        let mut inner = self.inner.lock();
167        Ok(inner.plain_text()?.to_string())
168    }
169
170    /// Replace the entire document with Markdown. Clears undo history.
171    ///
172    /// This is a **long operation**. Returns a typed [`Operation`] handle.
173    pub fn set_markdown(&self, markdown: &str) -> Result<Operation<MarkdownImportResult>> {
174        let mut inner = self.inner.lock();
175        inner.invalidate_text_cache();
176        let dto = frontend::document_io::ImportMarkdownDto {
177            markdown_text: markdown.into(),
178        };
179        let op_id = document_io_commands::import_markdown(&inner.ctx, &dto)?;
180        Ok(Operation::new(
181            op_id,
182            &inner.ctx,
183            Box::new(|ctx, id| {
184                document_io_commands::get_import_markdown_result(ctx, id)
185                    .ok()
186                    .flatten()
187                    .map(|r| {
188                        Ok(MarkdownImportResult {
189                            block_count: to_usize(r.block_count),
190                        })
191                    })
192            }),
193        ))
194    }
195
196    /// Export the entire document as Markdown.
197    pub fn to_markdown(&self) -> Result<String> {
198        let inner = self.inner.lock();
199        let dto = document_io_commands::export_markdown(&inner.ctx)?;
200        Ok(dto.markdown_text)
201    }
202
203    /// Replace the entire document with HTML. Clears undo history.
204    ///
205    /// This is a **long operation**. Returns a typed [`Operation`] handle.
206    pub fn set_html(&self, html: &str) -> Result<Operation<HtmlImportResult>> {
207        let mut inner = self.inner.lock();
208        inner.invalidate_text_cache();
209        let dto = frontend::document_io::ImportHtmlDto {
210            html_text: html.into(),
211        };
212        let op_id = document_io_commands::import_html(&inner.ctx, &dto)?;
213        Ok(Operation::new(
214            op_id,
215            &inner.ctx,
216            Box::new(|ctx, id| {
217                document_io_commands::get_import_html_result(ctx, id)
218                    .ok()
219                    .flatten()
220                    .map(|r| {
221                        Ok(HtmlImportResult {
222                            block_count: to_usize(r.block_count),
223                        })
224                    })
225            }),
226        ))
227    }
228
229    /// Export the entire document as HTML.
230    pub fn to_html(&self) -> Result<String> {
231        let inner = self.inner.lock();
232        let dto = document_io_commands::export_html(&inner.ctx)?;
233        Ok(dto.html_text)
234    }
235
236    /// Export the entire document as LaTeX.
237    pub fn to_latex(&self, document_class: &str, include_preamble: bool) -> Result<String> {
238        let inner = self.inner.lock();
239        let dto = frontend::document_io::ExportLatexDto {
240            document_class: document_class.into(),
241            include_preamble,
242        };
243        let result = document_io_commands::export_latex(&inner.ctx, &dto)?;
244        Ok(result.latex_text)
245    }
246
247    /// Export the entire document as DOCX to a file path.
248    ///
249    /// This is a **long operation**. Returns a typed [`Operation`] handle.
250    pub fn to_docx(&self, output_path: &str) -> Result<Operation<DocxExportResult>> {
251        let inner = self.inner.lock();
252        let dto = frontend::document_io::ExportDocxDto {
253            output_path: output_path.into(),
254        };
255        let op_id = document_io_commands::export_docx(&inner.ctx, &dto)?;
256        Ok(Operation::new(
257            op_id,
258            &inner.ctx,
259            Box::new(|ctx, id| {
260                document_io_commands::get_export_docx_result(ctx, id)
261                    .ok()
262                    .flatten()
263                    .map(|r| {
264                        Ok(DocxExportResult {
265                            file_path: r.file_path,
266                            paragraph_count: to_usize(r.paragraph_count),
267                        })
268                    })
269            }),
270        ))
271    }
272
273    /// Clear all document content and reset to an empty state.
274    pub fn clear(&self) -> Result<()> {
275        let queued = {
276            let mut inner = self.inner.lock();
277            let dto = frontend::document_io::ImportPlainTextDto {
278                plain_text: String::new(),
279            };
280            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
281            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
282            inner.invalidate_text_cache();
283            inner.queue_event(DocumentEvent::DocumentReset);
284            inner.check_block_count_changed();
285            inner.reset_cached_child_order();
286            inner.queue_event(DocumentEvent::UndoRedoChanged {
287                can_undo: false,
288                can_redo: false,
289            });
290            inner.take_queued_events()
291        };
292        crate::inner::dispatch_queued_events(queued);
293        Ok(())
294    }
295
296    // ── Cursor factory ───────────────────────────────────────
297
298    /// Create a cursor at position 0.
299    pub fn cursor(&self) -> TextCursor {
300        self.cursor_at(0)
301    }
302
303    /// Create a cursor at the given position.
304    pub fn cursor_at(&self, position: usize) -> TextCursor {
305        let data = {
306            let mut inner = self.inner.lock();
307            inner.register_cursor(position)
308        };
309        TextCursor {
310            doc: self.inner.clone(),
311            data,
312        }
313    }
314
315    // ── Document queries ─────────────────────────────────────
316
317    /// Get document statistics. O(1) — reads cached values.
318    pub fn stats(&self) -> DocumentStats {
319        let inner = self.inner.lock();
320        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
321            .expect("get_document_stats should not fail");
322        DocumentStats::from(&dto)
323    }
324
325    /// Get the total character count. O(1) — reads cached value.
326    pub fn character_count(&self) -> usize {
327        let inner = self.inner.lock();
328        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
329            .expect("get_document_stats should not fail");
330        to_usize(dto.character_count)
331    }
332
333    /// Get the number of blocks (paragraphs). O(1) — reads cached value.
334    pub fn block_count(&self) -> usize {
335        let inner = self.inner.lock();
336        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
337            .expect("get_document_stats should not fail");
338        to_usize(dto.block_count)
339    }
340
341    /// Returns true if the document has no text content.
342    pub fn is_empty(&self) -> bool {
343        self.character_count() == 0
344    }
345
346    /// Get text at a position for a given length.
347    pub fn text_at(&self, position: usize, length: usize) -> Result<String> {
348        let inner = self.inner.lock();
349        let dto = frontend::document_inspection::GetTextAtPositionDto {
350            position: to_i64(position),
351            length: to_i64(length),
352        };
353        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
354        Ok(result.text)
355    }
356
357    /// Get info about the block at a position. O(log n).
358    pub fn block_at(&self, position: usize) -> Result<BlockInfo> {
359        let inner = self.inner.lock();
360        let dto = frontend::document_inspection::GetBlockAtPositionDto {
361            position: to_i64(position),
362        };
363        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
364        Ok(BlockInfo::from(&result))
365    }
366
367    /// Get the block format at a position.
368    pub fn block_format_at(&self, position: usize) -> Result<BlockFormat> {
369        let inner = self.inner.lock();
370        let dto = frontend::document_inspection::GetBlockAtPositionDto {
371            position: to_i64(position),
372        };
373        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
374        let block_id = block_info.block_id;
375        let block_id = block_id as u64;
376        let block_dto = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
377            .ok_or_else(|| anyhow::anyhow!("block not found"))?;
378        Ok(BlockFormat::from(&block_dto))
379    }
380
381    // ── Flow traversal (layout engine API) ─────────────────
382
383    /// Walk the main frame's visual flow in document order.
384    ///
385    /// Returns the top-level flow elements — blocks, tables, and
386    /// sub-frames — in the order defined by the main frame's
387    /// `child_order`. Table cell contents are NOT included here;
388    /// access them through [`TextTableCell::blocks()`](crate::TextTableCell::blocks).
389    ///
390    /// This is the primary entry point for layout initialization.
391    pub fn flow(&self) -> Vec<crate::flow::FlowElement> {
392        let inner = self.inner.lock();
393        let main_frame_id = get_main_frame_id(&inner);
394        crate::text_frame::build_flow_elements(&inner, &self.inner, main_frame_id)
395    }
396
397    /// Get a read-only handle to a block by its entity ID.
398    ///
399    /// Entity IDs are stable across insertions and deletions.
400    /// Returns `None` if no block with this ID exists.
401    pub fn block_by_id(&self, block_id: usize) -> Option<crate::text_block::TextBlock> {
402        let inner = self.inner.lock();
403        let exists = frontend::commands::block_commands::get_block(&inner.ctx, &(block_id as u64))
404            .ok()
405            .flatten()
406            .is_some();
407
408        if exists {
409            Some(crate::text_block::TextBlock {
410                doc: self.inner.clone(),
411                block_id,
412            })
413        } else {
414            None
415        }
416    }
417
418    /// Get a read-only handle to the block containing the given
419    /// character position. Returns `None` if position is out of range.
420    pub fn block_at_position(&self, position: usize) -> Option<crate::text_block::TextBlock> {
421        let inner = self.inner.lock();
422        let dto = frontend::document_inspection::GetBlockAtPositionDto {
423            position: to_i64(position),
424        };
425        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
426        Some(crate::text_block::TextBlock {
427            doc: self.inner.clone(),
428            block_id: result.block_id as usize,
429        })
430    }
431
432    /// Get a read-only handle to a block by its 0-indexed global
433    /// block number.
434    ///
435    /// **O(n)**: requires scanning all blocks sorted by
436    /// `document_position` to find the nth one. Prefer
437    /// [`block_at_position()`](TextDocument::block_at_position) or
438    /// [`block_by_id()`](TextDocument::block_by_id) in
439    /// performance-sensitive paths.
440    pub fn block_by_number(&self, block_number: usize) -> Option<crate::text_block::TextBlock> {
441        let inner = self.inner.lock();
442        let all_blocks = frontend::commands::block_commands::get_all_block(&inner.ctx).ok()?;
443        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
444        sorted.sort_by_key(|b| b.document_position);
445
446        sorted
447            .get(block_number)
448            .map(|b| crate::text_block::TextBlock {
449                doc: self.inner.clone(),
450                block_id: b.id as usize,
451            })
452    }
453
454    /// Snapshot the entire main flow in a single lock acquisition.
455    ///
456    /// Returns a [`FlowSnapshot`](crate::FlowSnapshot) containing snapshots
457    /// for every element in the flow.
458    pub fn snapshot_flow(&self) -> crate::flow::FlowSnapshot {
459        let inner = self.inner.lock();
460        let main_frame_id = get_main_frame_id(&inner);
461        let elements = crate::text_frame::build_flow_snapshot(&inner, main_frame_id);
462        crate::flow::FlowSnapshot { elements }
463    }
464
465    // ── Search ───────────────────────────────────────────────
466
467    /// Find the next (or previous) occurrence. Returns `None` if not found.
468    pub fn find(
469        &self,
470        query: &str,
471        from: usize,
472        options: &FindOptions,
473    ) -> Result<Option<FindMatch>> {
474        let inner = self.inner.lock();
475        let dto = options.to_find_text_dto(query, from);
476        let result = document_search_commands::find_text(&inner.ctx, &dto)?;
477        Ok(convert::find_result_to_match(&result))
478    }
479
480    /// Find all occurrences.
481    pub fn find_all(&self, query: &str, options: &FindOptions) -> Result<Vec<FindMatch>> {
482        let inner = self.inner.lock();
483        let dto = options.to_find_all_dto(query);
484        let result = document_search_commands::find_all(&inner.ctx, &dto)?;
485        Ok(convert::find_all_to_matches(&result))
486    }
487
488    /// Replace occurrences. Returns the number of replacements. Undoable.
489    pub fn replace_text(
490        &self,
491        query: &str,
492        replacement: &str,
493        replace_all: bool,
494        options: &FindOptions,
495    ) -> Result<usize> {
496        let (count, queued) = {
497            let mut inner = self.inner.lock();
498            let dto = options.to_replace_dto(query, replacement, replace_all);
499            let result =
500                document_search_commands::replace_text(&inner.ctx, Some(inner.stack_id), &dto)?;
501            let count = to_usize(result.replacements_count);
502            inner.invalidate_text_cache();
503            if count > 0 {
504                inner.modified = true;
505                // Replacements are scattered across the document — we can't
506                // provide a single position/chars delta. Signal "content changed
507                // from position 0, affecting `count` sites" so the consumer
508                // knows to re-read.
509                inner.queue_event(DocumentEvent::ContentsChanged {
510                    position: 0,
511                    chars_removed: 0,
512                    chars_added: 0,
513                    blocks_affected: count,
514                });
515                inner.check_block_count_changed();
516                inner.check_flow_changed();
517                let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
518                let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
519                inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
520            }
521            (count, inner.take_queued_events())
522        };
523        crate::inner::dispatch_queued_events(queued);
524        Ok(count)
525    }
526
527    // ── Resources ────────────────────────────────────────────
528
529    /// Add a resource (image, stylesheet) to the document.
530    pub fn add_resource(
531        &self,
532        resource_type: ResourceType,
533        name: &str,
534        mime_type: &str,
535        data: &[u8],
536    ) -> Result<()> {
537        let mut inner = self.inner.lock();
538        let dto = frontend::resource::dtos::CreateResourceDto {
539            created_at: Default::default(),
540            updated_at: Default::default(),
541            resource_type,
542            name: name.into(),
543            url: String::new(),
544            mime_type: mime_type.into(),
545            data_base64: BASE64.encode(data),
546        };
547        let created = resource_commands::create_resource(
548            &inner.ctx,
549            Some(inner.stack_id),
550            &dto,
551            inner.document_id,
552            -1,
553        )?;
554        inner.resource_cache.insert(name.to_string(), created.id);
555        Ok(())
556    }
557
558    /// Get a resource by name. Returns `None` if not found.
559    ///
560    /// Uses an internal cache to avoid scanning all resources on repeated lookups.
561    pub fn resource(&self, name: &str) -> Result<Option<Vec<u8>>> {
562        let mut inner = self.inner.lock();
563
564        // Fast path: check the name → ID cache.
565        if let Some(&id) = inner.resource_cache.get(name) {
566            if let Some(r) = resource_commands::get_resource(&inner.ctx, &id)? {
567                let bytes = BASE64.decode(&r.data_base64)?;
568                return Ok(Some(bytes));
569            }
570            // ID was stale — fall through to full scan.
571            inner.resource_cache.remove(name);
572        }
573
574        // Slow path: linear scan, then populate cache for the match.
575        let all = resource_commands::get_all_resource(&inner.ctx)?;
576        for r in &all {
577            if r.name == name {
578                inner.resource_cache.insert(name.to_string(), r.id);
579                let bytes = BASE64.decode(&r.data_base64)?;
580                return Ok(Some(bytes));
581            }
582        }
583        Ok(None)
584    }
585
586    // ── Undo / Redo ──────────────────────────────────────────
587
588    /// Undo the last operation.
589    pub fn undo(&self) -> Result<()> {
590        let queued = {
591            let mut inner = self.inner.lock();
592            let result = undo_redo_commands::undo(&inner.ctx, Some(inner.stack_id));
593            inner.invalidate_text_cache();
594            result?;
595            inner.check_block_count_changed();
596            inner.check_flow_changed();
597            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
598            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
599            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
600            inner.take_queued_events()
601        };
602        crate::inner::dispatch_queued_events(queued);
603        Ok(())
604    }
605
606    /// Redo the last undone operation.
607    pub fn redo(&self) -> Result<()> {
608        let queued = {
609            let mut inner = self.inner.lock();
610            let result = undo_redo_commands::redo(&inner.ctx, Some(inner.stack_id));
611            inner.invalidate_text_cache();
612            result?;
613            inner.check_block_count_changed();
614            inner.check_flow_changed();
615            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
616            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
617            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
618            inner.take_queued_events()
619        };
620        crate::inner::dispatch_queued_events(queued);
621        Ok(())
622    }
623
624    /// Returns true if there are operations that can be undone.
625    pub fn can_undo(&self) -> bool {
626        let inner = self.inner.lock();
627        undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id))
628    }
629
630    /// Returns true if there are operations that can be redone.
631    pub fn can_redo(&self) -> bool {
632        let inner = self.inner.lock();
633        undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id))
634    }
635
636    /// Clear all undo/redo history.
637    pub fn clear_undo_redo(&self) {
638        let inner = self.inner.lock();
639        undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
640    }
641
642    // ── Modified state ───────────────────────────────────────
643
644    /// Returns true if the document has been modified since creation or last reset.
645    pub fn is_modified(&self) -> bool {
646        self.inner.lock().modified
647    }
648
649    /// Set or clear the modified flag.
650    pub fn set_modified(&self, modified: bool) {
651        let queued = {
652            let mut inner = self.inner.lock();
653            if inner.modified != modified {
654                inner.modified = modified;
655                inner.queue_event(DocumentEvent::ModificationChanged(modified));
656            }
657            inner.take_queued_events()
658        };
659        crate::inner::dispatch_queued_events(queued);
660    }
661
662    // ── Document properties ──────────────────────────────────
663
664    /// Get the document title.
665    pub fn title(&self) -> String {
666        let inner = self.inner.lock();
667        document_commands::get_document(&inner.ctx, &inner.document_id)
668            .ok()
669            .flatten()
670            .map(|d| d.title)
671            .unwrap_or_default()
672    }
673
674    /// Set the document title.
675    pub fn set_title(&self, title: &str) -> Result<()> {
676        let inner = self.inner.lock();
677        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
678            .ok_or_else(|| anyhow::anyhow!("document not found"))?;
679        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
680        update.title = title.into();
681        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
682        Ok(())
683    }
684
685    /// Get the text direction.
686    pub fn text_direction(&self) -> TextDirection {
687        let inner = self.inner.lock();
688        document_commands::get_document(&inner.ctx, &inner.document_id)
689            .ok()
690            .flatten()
691            .map(|d| d.text_direction)
692            .unwrap_or(TextDirection::LeftToRight)
693    }
694
695    /// Set the text direction.
696    pub fn set_text_direction(&self, direction: TextDirection) -> Result<()> {
697        let inner = self.inner.lock();
698        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
699            .ok_or_else(|| anyhow::anyhow!("document not found"))?;
700        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
701        update.text_direction = direction;
702        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
703        Ok(())
704    }
705
706    /// Get the default wrap mode.
707    pub fn default_wrap_mode(&self) -> WrapMode {
708        let inner = self.inner.lock();
709        document_commands::get_document(&inner.ctx, &inner.document_id)
710            .ok()
711            .flatten()
712            .map(|d| d.default_wrap_mode)
713            .unwrap_or(WrapMode::WordWrap)
714    }
715
716    /// Set the default wrap mode.
717    pub fn set_default_wrap_mode(&self, mode: WrapMode) -> Result<()> {
718        let inner = self.inner.lock();
719        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
720            .ok_or_else(|| anyhow::anyhow!("document not found"))?;
721        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
722        update.default_wrap_mode = mode;
723        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
724        Ok(())
725    }
726
727    // ── Event subscription ───────────────────────────────────
728
729    /// Subscribe to document events via callback.
730    ///
731    /// Callbacks are invoked **outside** the document lock (after the editing
732    /// operation completes and the lock is released). It is safe to call
733    /// `TextDocument` or `TextCursor` methods from within the callback without
734    /// risk of deadlock. However, keep callbacks lightweight — they run
735    /// synchronously on the calling thread and block the caller until they
736    /// return.
737    ///
738    /// Drop the returned [`Subscription`] to unsubscribe.
739    ///
740    /// # Breaking change (v0.0.6)
741    ///
742    /// The callback bound changed from `Send` to `Send + Sync` in v0.0.6
743    /// to support `Arc`-based dispatch. Callbacks that capture non-`Sync`
744    /// types (e.g., `Rc<T>`, `Cell<T>`) must be wrapped in a `Mutex`.
745    pub fn on_change<F>(&self, callback: F) -> Subscription
746    where
747        F: Fn(DocumentEvent) + Send + Sync + 'static,
748    {
749        let mut inner = self.inner.lock();
750        events::subscribe_inner(&mut inner, callback)
751    }
752
753    /// Return events accumulated since the last `poll_events()` call.
754    ///
755    /// This delivery path is independent of callback dispatch via
756    /// [`on_change`](Self::on_change) — using both simultaneously is safe
757    /// and each path sees every event exactly once.
758    pub fn poll_events(&self) -> Vec<DocumentEvent> {
759        let mut inner = self.inner.lock();
760        inner.drain_poll_events()
761    }
762}
763
764impl Default for TextDocument {
765    fn default() -> Self {
766        Self::new()
767    }
768}
769
770// ── Flow helpers ──────────────────────────────────────────────
771
772/// Get the main frame ID for the document.
773fn get_main_frame_id(inner: &TextDocumentInner) -> frontend::common::types::EntityId {
774    // The document's first frame is the main frame.
775    let frames = frontend::commands::document_commands::get_document_relationship(
776        &inner.ctx,
777        &inner.document_id,
778        &frontend::document::dtos::DocumentRelationshipField::Frames,
779    )
780    .unwrap_or_default();
781
782    frames.first().copied().unwrap_or(0)
783}
784
785// ── Long-operation event data helpers ─────────────────────────
786
787/// Parse progress JSON: `{"id":"...", "percentage": 50.0, "message": "..."}`
788fn parse_progress_data(data: &Option<String>) -> (String, f64, String) {
789    let Some(json) = data else {
790        return (String::new(), 0.0, String::new());
791    };
792    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
793    let id = v["id"].as_str().unwrap_or_default().to_string();
794    let pct = v["percentage"].as_f64().unwrap_or(0.0);
795    let msg = v["message"].as_str().unwrap_or_default().to_string();
796    (id, pct, msg)
797}
798
799/// Parse completed/cancelled JSON: `{"id":"..."}`
800fn parse_id_data(data: &Option<String>) -> String {
801    let Some(json) = data else {
802        return String::new();
803    };
804    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
805    v["id"].as_str().unwrap_or_default().to_string()
806}
807
808/// Parse failed JSON: `{"id":"...", "error":"..."}`
809fn parse_failed_data(data: &Option<String>) -> (String, String) {
810    let Some(json) = data else {
811        return (String::new(), "unknown error".into());
812    };
813    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
814    let id = v["id"].as_str().unwrap_or_default().to_string();
815    let error = v["error"].as_str().unwrap_or("unknown error").to_string();
816    (id, error)
817}