text_document/document.rs
1//! TextDocument implementation.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use crate::{DocumentError, Result};
8use base64::Engine;
9use base64::engine::general_purpose::STANDARD as BASE64;
10
11use crate::{
12 DjotExportOptions, DjotImportOptions, MarkdownExportOptions, PlainTextExportOptions,
13 ResourceType, TextDirection, WrapMode,
14};
15use frontend::commands::{
16 block_commands, document_commands, document_inspection_commands, document_io_commands,
17 document_search_commands, frame_commands, resource_commands, table_cell_commands,
18 table_commands, undo_redo_commands,
19};
20
21use crate::HtmlExportOptions;
22use crate::convert::{self, to_i64, to_usize};
23use crate::cursor::TextCursor;
24use crate::events::{self, DocumentEvent, Subscription};
25use crate::flow::FormatChangeKind;
26use crate::inner::TextDocumentInner;
27use crate::operation::{
28 DjotImportResult, DocxExportResult, EpubExportResult, HtmlImportResult, MarkdownImportResult,
29 Operation, PdfExportResult,
30};
31use crate::{BlockFormat, BlockInfo, DocumentStats, FindMatch, FindOptions, ReplaceRange};
32
33/// A rich text document.
34///
35/// Owns the backend (database, event hub, undo/redo manager) and provides
36/// document-level operations. All cursor-based editing goes through
37/// [`TextCursor`], obtained via [`cursor()`](TextDocument::cursor) or
38/// [`cursor_at()`](TextDocument::cursor_at).
39///
40/// Internally uses `Arc<Mutex<...>>` so that multiple [`TextCursor`]s can
41/// coexist and edit concurrently. Cloning a `TextDocument` creates a new
42/// handle to the **same** underlying document (like Qt's implicit sharing).
43#[derive(Clone)]
44pub struct TextDocument {
45 pub(crate) inner: Arc<Mutex<TextDocumentInner>>,
46}
47
48/// Test-only accessor for the underlying rope-backed store. Not part
49/// of the stable public API.
50impl TextDocument {
51 #[doc(hidden)]
52 pub fn rope_store_for_test(&self) -> std::sync::Arc<common::database::Store> {
53 let inner = self.inner.lock();
54 std::sync::Arc::clone(inner.ctx.db_context.get_store())
55 }
56}
57
58impl TextDocument {
59 // ── Construction ──────────────────────────────────────────
60
61 /// Create a new, empty document.
62 ///
63 /// # Panics
64 ///
65 /// Panics if the database context cannot be created (e.g. filesystem error).
66 /// Use [`TextDocument::try_new`] for a fallible alternative.
67 pub fn new() -> Self {
68 Self::try_new().expect("failed to initialize document")
69 }
70
71 /// Create a new, empty document, returning an error on failure.
72 pub fn try_new() -> Result<Self> {
73 let ctx = frontend::AppContext::new();
74 let doc_inner = TextDocumentInner::initialize(ctx)?;
75 let inner = Arc::new(Mutex::new(doc_inner));
76
77 // Bridge backend long-operation events to public DocumentEvent.
78 Self::subscribe_long_operation_events(&inner);
79
80 Ok(Self { inner })
81 }
82
83 /// Subscribe to backend long-operation events and bridge them to DocumentEvent.
84 fn subscribe_long_operation_events(inner: &Arc<Mutex<TextDocumentInner>>) {
85 use frontend::common::event::{LongOperationEvent as LOE, Origin};
86
87 let weak = Arc::downgrade(inner);
88 let mut locked = inner.lock();
89
90 // Progress
91 let w = weak.clone();
92 let progress_tok =
93 locked
94 .event_client
95 .subscribe(Origin::LongOperation(LOE::Progress), move |event| {
96 if let Some(inner) = w.upgrade() {
97 let (op_id, percent, message) = parse_progress_data(&event.data);
98 let mut inner = inner.lock();
99 inner.queue_event(DocumentEvent::LongOperationProgress {
100 operation_id: op_id,
101 percent,
102 message,
103 });
104 }
105 });
106
107 // Completed
108 let w = weak.clone();
109 let completed_tok =
110 locked
111 .event_client
112 .subscribe(Origin::LongOperation(LOE::Completed), move |event| {
113 if let Some(inner) = w.upgrade() {
114 let op_id = parse_id_data(&event.data);
115 let mut inner = inner.lock();
116 inner.queue_event(DocumentEvent::DocumentReset);
117 inner.check_block_count_changed();
118 inner.reset_cached_child_order();
119 inner.queue_event(DocumentEvent::LongOperationFinished {
120 operation_id: op_id,
121 success: true,
122 error: None,
123 });
124 }
125 });
126
127 // Cancelled
128 let w = weak.clone();
129 let cancelled_tok =
130 locked
131 .event_client
132 .subscribe(Origin::LongOperation(LOE::Cancelled), move |event| {
133 if let Some(inner) = w.upgrade() {
134 let op_id = parse_id_data(&event.data);
135 let mut inner = inner.lock();
136 inner.queue_event(DocumentEvent::LongOperationFinished {
137 operation_id: op_id,
138 success: false,
139 error: Some("cancelled".into()),
140 });
141 }
142 });
143
144 // Failed
145 let failed_tok =
146 locked
147 .event_client
148 .subscribe(Origin::LongOperation(LOE::Failed), move |event| {
149 if let Some(inner) = weak.upgrade() {
150 let (op_id, error) = parse_failed_data(&event.data);
151 let mut inner = inner.lock();
152 inner.queue_event(DocumentEvent::LongOperationFinished {
153 operation_id: op_id,
154 success: false,
155 error: Some(error),
156 });
157 }
158 });
159
160 locked.long_op_subscriptions.extend([
161 progress_tok,
162 completed_tok,
163 cancelled_tok,
164 failed_tok,
165 ]);
166 }
167
168 // ── Whole-document content ────────────────────────────────
169
170 /// Replace the entire document with plain text. Clears undo history.
171 pub fn set_plain_text(&self, text: &str) -> Result<()> {
172 let queued = {
173 let mut inner = self.inner.lock();
174 let dto = frontend::document_io::ImportPlainTextDto {
175 plain_text: text.into(),
176 };
177 document_io_commands::import_plain_text(&inner.ctx, &dto)?;
178 undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
179 inner.invalidate_text_cache();
180 inner.rehighlight_all();
181 inner.queue_event(DocumentEvent::DocumentReset);
182 inner.check_block_count_changed();
183 inner.reset_cached_child_order();
184 inner.queue_event(DocumentEvent::UndoRedoChanged {
185 can_undo: false,
186 can_redo: false,
187 });
188 inner.take_queued_events()
189 };
190 crate::inner::dispatch_queued_events(queued);
191 Ok(())
192 }
193
194 /// Export the entire document as plain text, in reading order.
195 ///
196 /// This is the **human-readable** view: prose only. Embedded objects (a table) contribute
197 /// their content but not the `U+FFFC` anchor the document holds where they sit — which is
198 /// what you want for a `cat`-style export, and is why the crate's fast path bails the
199 /// moment a table exists.
200 ///
201 /// **Do not compute offsets from this string.** It is deliberately not
202 /// character-for-character the text a search runs against: that text carries the object
203 /// anchors, so a position taken here is short by two characters per preceding table. For
204 /// an addressable view — one whose offsets [`find_all`](Self::find_all) and
205 /// [`replace_text`](Self::replace_text) agree with — use
206 /// [`djot_to_plain_text`](crate::djot_to_plain_text), which is pinned to match the
207 /// document's own search text exactly.
208 ///
209 /// The two are allowed to differ in that one respect and no other; in particular they
210 /// agree on **order**. They did not always: this export used to hoist every blockquote's
211 /// prose to the end of the document (`"> a0\n\na"` came back as `"a\na0"`), because it
212 /// concatenated frames in creation order instead of sorting all blocks by
213 /// `document_position`. See `plain_text_order_tests`.
214 pub fn to_plain_text(&self) -> Result<String> {
215 let mut inner = self.inner.lock();
216 Ok(inner.plain_text()?.to_string())
217 }
218
219 /// [`to_plain_text`](Self::to_plain_text) for writing an actual `.txt` file: quoted
220 /// blocks are indented four spaces per blockquote level, so an epigraph or a block
221 /// quotation still reads as set-off matter in a format with no markup to say so.
222 ///
223 /// **Not** interchangeable with [`to_plain_text`](Self::to_plain_text), and not cached.
224 /// That one is pinned character-for-character to the document's addressable text — the
225 /// text [`find_all`](Self::find_all) and [`replace_text`](Self::replace_text) compute
226 /// offsets against — so indenting it would shift every offset inside a quote and
227 /// desynchronise search from the document. Use this only for output nobody addresses
228 /// back into the document.
229 pub fn to_plain_text_indented(&self) -> Result<String> {
230 let inner = self.inner.lock();
231 let dto = document_io_commands::export_plain_text_indented(&inner.ctx)?;
232 Ok(dto.plain_text)
233 }
234
235 /// [`to_plain_text`](Self::to_plain_text) with every presentation option chosen
236 /// explicitly — quoted-block indentation, and a `U+000C` form feed before each block
237 /// that asks to start a new page.
238 ///
239 /// Subject to the same warning as [`to_plain_text_indented`](Self::to_plain_text_indented):
240 /// anything other than [`PlainTextExportOptions::addressable`] shifts offsets, so this is
241 /// for files being written out, never for text anyone addresses back into the document.
242 pub fn to_plain_text_with(&self, options: PlainTextExportOptions) -> Result<String> {
243 let inner = self.inner.lock();
244 let dto = document_io_commands::export_plain_text_with(&inner.ctx, options)?;
245 Ok(dto.plain_text)
246 }
247
248 /// Replace the entire document with Markdown. Clears undo history.
249 ///
250 /// This is a **long operation**. Returns a typed [`Operation`] handle.
251 pub fn set_markdown(&self, markdown: &str) -> Result<Operation<MarkdownImportResult>> {
252 let mut inner = self.inner.lock();
253 inner.invalidate_text_cache();
254 let dto = frontend::document_io::ImportMarkdownDto {
255 markdown_text: markdown.into(),
256 };
257 let op_id = document_io_commands::import_markdown(&inner.ctx, &dto)?;
258 Ok(Operation::new(
259 op_id,
260 &inner.ctx,
261 Box::new(|ctx, id| {
262 document_io_commands::get_import_markdown_result(ctx, id)
263 .ok()
264 .flatten()
265 .map(|r| {
266 Ok(MarkdownImportResult {
267 block_count: to_usize(r.block_count),
268 })
269 })
270 }),
271 ))
272 }
273
274 /// Export the entire document as Markdown.
275 pub fn to_markdown(&self) -> Result<String> {
276 let inner = self.inner.lock();
277 let dto = document_io_commands::export_markdown(&inner.ctx)?;
278 Ok(dto.markdown_text)
279 }
280
281 /// [`to_markdown`](Self::to_markdown) with the presentation opt-ins — today, whether a
282 /// block that asks to start a new page gets a raw-HTML page break emitted above it.
283 /// Off by default, because raw HTML is not Markdown.
284 pub fn to_markdown_with(&self, options: MarkdownExportOptions) -> Result<String> {
285 let inner = self.inner.lock();
286 let dto = document_io_commands::export_markdown_with(&inner.ctx, options)?;
287 Ok(dto.markdown_text)
288 }
289
290 /// Replace the entire document with djot markup. Clears undo history.
291 ///
292 /// This is a **long operation**. Returns a typed [`Operation`] handle.
293 pub fn set_djot(&self, djot: &str) -> Result<Operation<DjotImportResult>> {
294 self.set_djot_with_options(djot, DjotImportOptions::default())
295 }
296
297 /// Replace the entire document with djot markup, selecting which optional
298 /// block attributes (alignment, line height, direction, non-breakable
299 /// lines, background color) are applied via `options`. Clears undo history.
300 ///
301 /// This is a **long operation**. Returns a typed [`Operation`] handle.
302 pub fn set_djot_with_options(
303 &self,
304 djot: &str,
305 options: DjotImportOptions,
306 ) -> Result<Operation<DjotImportResult>> {
307 let mut inner = self.inner.lock();
308 inner.invalidate_text_cache();
309 let dto = frontend::document_io::ImportDjotDto {
310 djot_text: djot.into(),
311 options,
312 };
313 let op_id = document_io_commands::import_djot(&inner.ctx, &dto)?;
314 Ok(Operation::new(
315 op_id,
316 &inner.ctx,
317 Box::new(|ctx, id| {
318 document_io_commands::get_import_djot_result(ctx, id)
319 .ok()
320 .flatten()
321 .map(|r| {
322 Ok(DjotImportResult {
323 block_count: to_usize(r.block_count),
324 })
325 })
326 }),
327 ))
328 }
329
330 /// Replace the entire document with djot markup, **synchronously**, on the
331 /// calling thread. Clears undo history.
332 ///
333 /// This is the right call for *loading* a document's initial content — the
334 /// case where the caller is going to block for the result anyway.
335 /// [`set_djot`](Self::set_djot) starts a long operation: it spawns a thread,
336 /// and the caller then blocks in [`Operation::wait`] until that thread
337 /// publishes. That round trip is pure overhead when there is no frame loop to
338 /// keep responsive, and it does not shrink with the input — an *empty*
339 /// document costs the same thread spawn and hand-off as a full one. Loading N
340 /// documents in a loop paid it N times.
341 ///
342 /// Prefer [`set_djot`](Self::set_djot) when the import is genuinely long and
343 /// the caller must stay responsive (it reports progress and can be
344 /// cancelled); prefer this when the caller just wants the content in.
345 ///
346 /// Observationally equivalent to `set_djot(..).wait()` — same import, same
347 /// `DocumentReset`, same cache/block bookkeeping — except that, having no
348 /// operation, it emits no `LongOperation*` events and cannot be cancelled.
349 pub fn set_djot_sync(&self, djot: &str) -> Result<DjotImportResult> {
350 self.set_djot_sync_with_options(djot, DjotImportOptions::default())
351 }
352
353 /// As [`set_djot_sync`](Self::set_djot_sync), selecting which optional block
354 /// attributes are applied via `options`.
355 pub fn set_djot_sync_with_options(
356 &self,
357 djot: &str,
358 options: DjotImportOptions,
359 ) -> Result<DjotImportResult> {
360 let (queued, block_count) = {
361 let mut inner = self.inner.lock();
362 inner.invalidate_text_cache();
363 let dto = frontend::document_io::ImportDjotDto {
364 djot_text: djot.into(),
365 options,
366 };
367 let result = document_io_commands::import_djot_sync(&inner.ctx, &dto)?;
368 // The same settling the async path performs when its operation
369 // completes (see `subscribe_long_operation_events`), done inline here
370 // because there is no completion event to hang it off.
371 inner.queue_event(DocumentEvent::DocumentReset);
372 inner.check_block_count_changed();
373 inner.reset_cached_child_order();
374 (inner.take_queued_events(), result.block_count)
375 };
376 // Dispatch outside the lock — a subscriber is free to call back in.
377 crate::inner::dispatch_queued_events(queued);
378 Ok(DjotImportResult {
379 block_count: to_usize(block_count),
380 })
381 }
382
383 /// Export the entire document as djot markup.
384 pub fn to_djot(&self) -> Result<String> {
385 self.to_djot_with_options(DjotExportOptions::default())
386 }
387
388 /// Export the entire document as djot markup, selecting which optional block
389 /// attributes (alignment, line height, direction, non-breakable lines,
390 /// background color) are emitted via `options`.
391 pub fn to_djot_with_options(&self, options: DjotExportOptions) -> Result<String> {
392 let inner = self.inner.lock();
393 let dto = document_io_commands::export_djot(&inner.ctx, &options)?;
394 Ok(dto.djot_text)
395 }
396
397 /// Replace the entire document with HTML. Clears undo history.
398 ///
399 /// This is a **long operation**. Returns a typed [`Operation`] handle.
400 pub fn set_html(&self, html: &str) -> Result<Operation<HtmlImportResult>> {
401 let mut inner = self.inner.lock();
402 inner.invalidate_text_cache();
403 let dto = frontend::document_io::ImportHtmlDto {
404 html_text: html.into(),
405 };
406 let op_id = document_io_commands::import_html(&inner.ctx, &dto)?;
407 Ok(Operation::new(
408 op_id,
409 &inner.ctx,
410 Box::new(|ctx, id| {
411 document_io_commands::get_import_html_result(ctx, id)
412 .ok()
413 .flatten()
414 .map(|r| {
415 Ok(HtmlImportResult {
416 block_count: to_usize(r.block_count),
417 })
418 })
419 }),
420 ))
421 }
422
423 /// Export the entire document as HTML.
424 ///
425 /// Inline images keep whatever `src` the document stores; placing the files
426 /// those point at is the caller's business. Use
427 /// [`to_html_with_options`](Self::to_html_with_options) to inline them
428 /// instead, or to drop them.
429 pub fn to_html(&self) -> Result<String> {
430 let inner = self.inner.lock();
431 let dto = document_io_commands::export_html(&inner.ctx)?;
432 Ok(dto.html_text)
433 }
434
435 /// Export as HTML, choosing how inline images are represented.
436 pub fn to_html_with_options(&self, options: HtmlExportOptions) -> Result<String> {
437 let inner = self.inner.lock();
438 let dto = document_io_commands::export_html_with_options(&inner.ctx, options)?;
439 Ok(dto.html_text)
440 }
441
442 /// Export the entire document as LaTeX.
443 ///
444 /// Images are emitted as `\includegraphics{src}`, which LaTeX resolves
445 /// against the filesystem at compile time — so the caller is responsible for
446 /// placing those files beside the `.tex`. Use
447 /// [`to_latex_with_options`](Self::to_latex_with_options) to drop them
448 /// instead.
449 pub fn to_latex(&self, document_class: &str, include_preamble: bool) -> Result<String> {
450 self.to_latex_with_options(document_class, include_preamble, false)
451 }
452
453 /// [`to_latex`](Self::to_latex), with the choice of dropping inline images.
454 pub fn to_latex_with_options(
455 &self,
456 document_class: &str,
457 include_preamble: bool,
458 omit_images: bool,
459 ) -> Result<String> {
460 let inner = self.inner.lock();
461 let dto = frontend::document_io::ExportLatexDto {
462 document_class: document_class.into(),
463 include_preamble,
464 omit_images,
465 };
466 let result = document_io_commands::export_latex(&inner.ctx, &dto)?;
467 Ok(result.latex_text)
468 }
469
470 /// Export the entire document as DOCX to a file path.
471 ///
472 /// This is a **long operation**. Returns a typed [`Operation`] handle.
473 pub fn to_docx(&self, output_path: &str) -> Result<Operation<DocxExportResult>> {
474 self.to_docx_with_options(output_path, crate::DocxExportOptions::default())
475 }
476
477 /// As [`to_docx`](Self::to_docx), but with page geometry + base typography overrides — a
478 /// *manuscript* style (page size, margins, body font, line spacing, first-line indent,
479 /// alignment, and an optional page-number header). Per-block RTL is emitted automatically
480 /// from each block's own direction, independent of these options.
481 pub fn to_docx_with_options(
482 &self,
483 output_path: &str,
484 options: crate::DocxExportOptions,
485 ) -> Result<Operation<DocxExportResult>> {
486 let inner = self.inner.lock();
487 let dto = frontend::document_io::ExportDocxDto {
488 output_path: output_path.into(),
489 options,
490 };
491 let op_id = document_io_commands::export_docx(&inner.ctx, &dto)?;
492 Ok(Operation::new(
493 op_id,
494 &inner.ctx,
495 Box::new(|ctx, id| {
496 document_io_commands::get_export_docx_result(ctx, id)
497 .ok()
498 .flatten()
499 .map(|r| {
500 Ok(DocxExportResult {
501 file_path: r.file_path,
502 paragraph_count: to_usize(r.paragraph_count),
503 })
504 })
505 }),
506 ))
507 }
508
509 /// Export the entire document as an EPUB 3 file to a file path.
510 ///
511 /// This is a **long operation**. Returns a typed [`Operation`] handle.
512 pub fn to_epub(&self, output_path: &str) -> Result<Operation<EpubExportResult>> {
513 self.to_epub_with_options(output_path, crate::EpubExportOptions::default())
514 }
515
516 /// As [`to_epub`](Self::to_epub), but with book-level metadata (title, author, language,
517 /// reading direction). The document is split into chapters at the shallowest heading level
518 /// present (e.g. every top-level `# Chapter` heading) — see
519 /// [`EpubExportOptions`](crate::EpubExportOptions) for details.
520 pub fn to_epub_with_options(
521 &self,
522 output_path: &str,
523 options: crate::EpubExportOptions,
524 ) -> Result<Operation<EpubExportResult>> {
525 let inner = self.inner.lock();
526 let dto = frontend::document_io::ExportEpubDto {
527 output_path: output_path.into(),
528 options,
529 };
530 let op_id = document_io_commands::export_epub(&inner.ctx, &dto)?;
531 Ok(Operation::new(
532 op_id,
533 &inner.ctx,
534 Box::new(|ctx, id| {
535 document_io_commands::get_export_epub_result(ctx, id)
536 .ok()
537 .flatten()
538 .map(|r| {
539 Ok(EpubExportResult {
540 file_path: r.file_path,
541 chapter_count: to_usize(r.chapter_count),
542 })
543 })
544 }),
545 ))
546 }
547
548 /// Export the entire document as a PDF file, using the given options (page geometry,
549 /// typography, embedded font bytes, base language/direction).
550 ///
551 /// This is a **long operation**. Returns a typed [`Operation`] handle.
552 ///
553 /// Requires the `pdf` cargo feature on `text-document` (which forwards to `frontend`'s and
554 /// `document_io`'s own `pdf` features). If it was not enabled at compile time, this returns
555 /// `Err(DocumentError::Unsupported(..))` immediately rather than attempting the export — no
556 /// `#[cfg]` is needed at the call site either way.
557 pub fn to_pdf(
558 &self,
559 output_path: &str,
560 options: crate::PdfExportOptions,
561 ) -> Result<Operation<PdfExportResult>> {
562 self.to_pdf_with_options(output_path, options)
563 }
564
565 /// As [`to_pdf`](Self::to_pdf) — the two are identical; `to_pdf` is the plain entry point,
566 /// `to_pdf_with_options` exists (like [`to_docx_with_options`](Self::to_docx_with_options)
567 /// and [`to_epub_with_options`](Self::to_epub_with_options)) so the naming stays consistent
568 /// across the three file-based exporters, all of which take a mandatory options struct.
569 #[cfg(feature = "pdf")]
570 pub fn to_pdf_with_options(
571 &self,
572 output_path: &str,
573 options: crate::PdfExportOptions,
574 ) -> Result<Operation<PdfExportResult>> {
575 let inner = self.inner.lock();
576 let dto = frontend::document_io::ExportPdfDto {
577 output_path: output_path.into(),
578 options,
579 };
580 let op_id = document_io_commands::export_pdf(&inner.ctx, &dto)?;
581 Ok(Operation::new(
582 op_id,
583 &inner.ctx,
584 Box::new(|ctx, id| {
585 document_io_commands::get_export_pdf_result(ctx, id)
586 .ok()
587 .flatten()
588 .map(|r| {
589 Ok(PdfExportResult {
590 file_path: r.file_path,
591 page_count: to_usize(r.page_count),
592 })
593 })
594 }),
595 ))
596 }
597
598 /// As [`to_pdf`](Self::to_pdf), when the `pdf` cargo feature was not enabled at compile
599 /// time — returns [`DocumentError::Unsupported`] immediately, without starting an operation
600 /// or touching the backend at all.
601 #[cfg(not(feature = "pdf"))]
602 pub fn to_pdf_with_options(
603 &self,
604 _output_path: &str,
605 _options: crate::PdfExportOptions,
606 ) -> Result<Operation<PdfExportResult>> {
607 Err(DocumentError::Unsupported(
608 "PDF export requires the `pdf` cargo feature on the `text-document` crate".into(),
609 ))
610 }
611
612 /// Clear all document content and reset to an empty state.
613 pub fn clear(&self) -> Result<()> {
614 let queued = {
615 let mut inner = self.inner.lock();
616 let dto = frontend::document_io::ImportPlainTextDto {
617 plain_text: String::new(),
618 };
619 document_io_commands::import_plain_text(&inner.ctx, &dto)?;
620 undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
621 inner.invalidate_text_cache();
622 inner.rehighlight_all();
623 inner.queue_event(DocumentEvent::DocumentReset);
624 inner.check_block_count_changed();
625 inner.reset_cached_child_order();
626 inner.queue_event(DocumentEvent::UndoRedoChanged {
627 can_undo: false,
628 can_redo: false,
629 });
630 inner.take_queued_events()
631 };
632 crate::inner::dispatch_queued_events(queued);
633 Ok(())
634 }
635
636 // ── Cursor factory ───────────────────────────────────────
637
638 /// Create a cursor at position 0.
639 pub fn cursor(&self) -> TextCursor {
640 self.cursor_at(0)
641 }
642
643 /// Create a cursor at the given position. If `position` falls
644 /// inside an extended grapheme cluster (decomposed accents, ZWJ
645 /// emoji, skin-tone sequences, flag pairs), the cursor snaps
646 /// forward to the end of the containing cluster so subsequent
647 /// `NextCharacter`/`PreviousCharacter` round-trips remain identity.
648 pub fn cursor_at(&self, position: usize) -> TextCursor {
649 let data = {
650 let mut inner = self.inner.lock();
651 inner.register_cursor(position)
652 };
653 let cursor = TextCursor {
654 doc: self.inner.clone(),
655 data,
656 };
657 cursor.snap_position_to_grapheme_boundary();
658 cursor
659 }
660
661 // ── Document queries ─────────────────────────────────────
662
663 /// Get document statistics. O(1) — reads cached values.
664 pub fn stats(&self) -> DocumentStats {
665 let inner = self.inner.lock();
666 let dto = document_inspection_commands::get_document_stats(&inner.ctx)
667 .expect("get_document_stats should not fail");
668 DocumentStats::from(&dto)
669 }
670
671 /// Tell the document what each footnote label should print.
672 ///
673 /// Presentation only: never stored, never exported, never part of the text. A
674 /// reference occupies one character whatever its marker says.
675 ///
676 /// Set it when the numbers are a fact about something larger than this
677 /// document — a host compiling one chapter of a book knows the chapter's notes
678 /// continue a sequence this document cannot see. Leave it unset and the
679 /// document numbers its own references in reading order, which is right when
680 /// the document *is* the whole text.
681 /// Storing the map is only half of it: a marker is **shaped text**, so a
682 /// document already laid out keeps drawing the old one until something tells
683 /// it to reshape. Nothing else will — the map is presentation state and
684 /// changing it edits no block, so it emits no edit event of its own. Without
685 /// the notification below, a host that numbers a note the instant it is
686 /// created watches the raw label sit in the writer's prose until an unrelated
687 /// keystroke happens to force a relayout.
688 ///
689 /// `FormatChanged` over the whole document rather than a paint-only event:
690 /// the marker's width changes with its text (`9` and `10` are not the same
691 /// size), so the line has to be reshaped, not recoloured. Guarded on the map
692 /// actually differing, because a host pushes this on every refresh and a
693 /// full relayout per keystroke is not a thing to do by accident.
694 pub fn set_footnote_markers(&self, markers: std::collections::HashMap<String, String>) {
695 let queued = {
696 let mut inner = self.inner.lock();
697 {
698 let store = inner.ctx.db_context.get_store();
699 let mut current = store.footnote_markers.write();
700 if *current == markers {
701 return;
702 }
703 *current = markers;
704 }
705 inner.queue_event(DocumentEvent::FormatChanged {
706 position: 0,
707 length: 0,
708 kind: crate::flow::FormatChangeKind::Character,
709 });
710 inner.take_queued_events()
711 };
712 crate::inner::dispatch_queued_events(queued);
713 }
714
715 /// Every footnote reference in the document, as `(position, label)`, in
716 /// reading order.
717 ///
718 /// The seam a host uses to tie its own note storage to the prose: Skribisto
719 /// keeps note bodies in its store, so what it needs from the document is
720 /// only *where* the references are and *which* note each names.
721 ///
722 /// Positions are document-absolute character offsets — the same space a
723 /// cursor and a search hit use — so a caller can go straight from a caret to
724 /// the note under it without a second lookup.
725 pub fn footnote_references(&self) -> Vec<(usize, String)> {
726 let inner = self.inner.lock();
727 let store = inner.ctx.db_context.get_store();
728
729 let refs = store.block_footnote_refs.read();
730 if refs.is_empty() {
731 return Vec::new();
732 }
733
734 // Block order, then byte order within a block — the order they are read.
735 let mut blocks: Vec<(i64, u64)> = store
736 .blocks
737 .read()
738 .values()
739 .map(|b| (b.document_position, b.id))
740 .collect();
741 blocks.sort_unstable();
742
743 let mut out = Vec::new();
744 for (position, block_id) in blocks {
745 let Some(anchors) = refs.get(&block_id) else {
746 continue;
747 };
748 let Some(block) = store.blocks.read().get(&block_id).cloned() else {
749 continue;
750 };
751 let text =
752 frontend::common::database::rope_helpers::block_content_via_store(&block, store);
753 let mut ordered: Vec<_> = anchors.iter().collect();
754 ordered.sort_by_key(|a| a.byte_offset);
755 for anchor in ordered {
756 // Byte offset within the block → character offset within the
757 // document. The two differ the moment the block holds anything
758 // outside ASCII, which for prose is immediately.
759 let chars_before = text
760 .get(..anchor.byte_offset as usize)
761 .map(|s| s.chars().count())
762 .unwrap_or(0);
763 out.push((position as usize + chars_before, anchor.label.clone()));
764 }
765 }
766 out
767 }
768
769 /// The label of the footnote reference at `position`, if one sits there.
770 ///
771 /// What "the caret is on a footnote" means, for a host wiring a two-way
772 /// selection between its notes list and the prose.
773 pub fn footnote_reference_at(&self, position: usize) -> Option<String> {
774 self.footnote_references()
775 .into_iter()
776 .find(|(at, _)| *at == position)
777 .map(|(_, label)| label)
778 }
779
780 /// Get the total character count. O(1) — reads cached value.
781 pub fn character_count(&self) -> usize {
782 let inner = self.inner.lock();
783 let dto = document_inspection_commands::get_document_stats(&inner.ctx)
784 .expect("get_document_stats should not fail");
785 to_usize(dto.character_count)
786 }
787
788 /// Get the number of blocks (paragraphs). O(1) — reads cached value.
789 pub fn block_count(&self) -> usize {
790 let inner = self.inner.lock();
791 let dto = document_inspection_commands::get_document_stats(&inner.ctx)
792 .expect("get_document_stats should not fail");
793 to_usize(dto.block_count)
794 }
795
796 /// Returns true if the document has no text content.
797 pub fn is_empty(&self) -> bool {
798 self.character_count() == 0
799 }
800
801 /// Get text at a position for a given length.
802 pub fn text_at(&self, position: usize, length: usize) -> Result<String> {
803 let inner = self.inner.lock();
804 let dto = frontend::document_inspection::GetTextAtPositionDto {
805 position: to_i64(position),
806 length: to_i64(length),
807 };
808 let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
809 Ok(result.text)
810 }
811
812 /// Find the inline segment containing `position` and return its
813 /// stable element id (synthesized from `(block_id, byte_start)`
814 /// via [`common::format_runs::synth_element_id`]) together with the
815 /// segment's absolute start position and the character offset of
816 /// `position` within the segment. Used by accessibility layers to
817 /// convert a document-absolute character position into the
818 /// `(element_id, character_index_in_run)` coordinate space
819 /// AccessKit's `TextPosition` expects.
820 ///
821 /// Returns `None` when the position is outside the document.
822 /// Returns the element at position `position - 1` when `position`
823 /// falls exactly on an element boundary, matching the "cursor
824 /// belongs to the preceding element at a boundary" convention
825 /// used throughout text-document.
826 pub fn find_element_at_position(&self, position: usize) -> Option<(u64, usize, usize)> {
827 let block_info = self.block_at(position).ok()?;
828 let block_start = block_info.start;
829 let offset_in_block = position.checked_sub(block_start)?;
830 let block = crate::text_block::TextBlock {
831 doc: std::sync::Arc::clone(&self.inner),
832 block_id: block_info.block_id,
833 };
834 let frags = block.fragments();
835 // Walk fragments; match the fragment that contains
836 // `offset_in_block`. For a boundary position shared with the
837 // next fragment, prefer the preceding fragment (boundary
838 // belongs to the end of the previous element).
839 let mut last_text: Option<(u64, usize, usize, usize)> = None; // (id, abs_start, frag_offset, frag_length)
840 for frag in &frags {
841 match frag {
842 crate::flow::FragmentContent::Text {
843 offset,
844 length,
845 element_id,
846 ..
847 } => {
848 let frag_start = *offset;
849 let frag_end = frag_start + *length;
850 if offset_in_block >= frag_start && offset_in_block < frag_end {
851 let abs_start = block_start + frag_start;
852 let offset_within = offset_in_block - frag_start;
853 return Some((*element_id, abs_start, offset_within));
854 }
855 // Record as a candidate for the "end-of-element"
856 // boundary fallback (offset_in_block == frag_end).
857 if offset_in_block == frag_end {
858 last_text =
859 Some((*element_id, block_start + frag_start, frag_start, *length));
860 }
861 }
862 // Both objects occupy exactly one position and answer for it
863 // whole — there is no offset *inside* either to report.
864 crate::flow::FragmentContent::Image {
865 offset, element_id, ..
866 }
867 | crate::flow::FragmentContent::FootnoteReference {
868 offset, element_id, ..
869 } => {
870 if offset_in_block == *offset {
871 return Some((*element_id, block_start + offset, 0));
872 }
873 }
874 }
875 }
876 // Boundary fallback: position was at the end of the last text
877 // fragment we saw.
878 last_text.map(|(id, abs_start, _, length)| (id, abs_start, length))
879 }
880
881 /// Get info about the block at a position. O(log n).
882 pub fn block_at(&self, position: usize) -> Result<BlockInfo> {
883 let inner = self.inner.lock();
884 let dto = frontend::document_inspection::GetBlockAtPositionDto {
885 position: to_i64(position),
886 };
887 let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
888 Ok(BlockInfo::from(&result))
889 }
890
891 /// The sentence containing `position`, as absolute char offsets `(start, end)` — the
892 /// granularity between [`word`](TextCursor::select) and [`block_at`](Self::block_at).
893 ///
894 /// `content_locale` is a BCP-47-ish tag (`"en"`, `"en-US"`, `"pt_BR"`) naming the language
895 /// the text is written in. It selects the sentence tailoring for that language —
896 /// abbreviations that do not end a sentence, French spaced guillemets, the Greek question
897 /// mark. Pass it **fresh on every call**, like [`FindOptions::language`](crate::FindOptions):
898 /// only the caller knows what language the text is in, and it is not document state.
899 /// `None`, or a language with no tailoring, falls back to plain UAX #29.
900 ///
901 /// A sentence never crosses a block: a paragraph break always ends one. Returns `None` when
902 /// the block holds no sentence to point at (empty, or whitespace only). Trailing whitespace
903 /// is trimmed off the end, so the range covers the sentence and not the gap after it.
904 ///
905 /// The trailing edge is inclusive of the caret: a `position` at the very end of the block
906 /// resolves to the last sentence rather than to nothing.
907 pub fn sentence_at(
908 &self,
909 position: usize,
910 content_locale: Option<&str>,
911 ) -> Option<(usize, usize)> {
912 let inner = self.inner.lock();
913 let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
914 position: to_i64(position),
915 };
916 let block =
917 document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto).ok()?;
918 let block_start = to_usize(block.block_start);
919 let block_length = to_usize(block.block_length);
920 if block_length == 0 {
921 return None;
922 }
923 let text_dto = frontend::document_inspection::GetTextAtPositionDto {
924 position: to_i64(block_start),
925 length: to_i64(block_length),
926 };
927 let text = document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto)
928 .ok()?
929 .text;
930 drop(inner);
931
932 let offset = position.saturating_sub(block_start);
933 let (start, end) =
934 frontend::common::parser_tools::sentence_bounds(&text, offset, content_locale)?;
935 Some((block_start + start, block_start + end))
936 }
937
938 /// Get the block format at a position.
939 pub fn block_format_at(&self, position: usize) -> Result<BlockFormat> {
940 let inner = self.inner.lock();
941 let dto = frontend::document_inspection::GetBlockAtPositionDto {
942 position: to_i64(position),
943 };
944 let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
945 let block_id = block_info.block_id;
946 let block_id = block_id as u64;
947 let block_dto = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
948 .ok_or_else(|| DocumentError::NotFound("block not found".into()))?;
949 Ok(BlockFormat::from(&block_dto))
950 }
951
952 // ── Flow traversal (layout engine API) ─────────────────
953
954 /// Walk the main frame's visual flow in document order.
955 ///
956 /// Returns the top-level flow elements — blocks, tables, and
957 /// sub-frames — in the order defined by the main frame's
958 /// `child_order`. Table cell contents are NOT included here;
959 /// access them through [`TextTableCell::blocks()`](crate::TextTableCell::blocks).
960 ///
961 /// This is the primary entry point for layout initialization.
962 pub fn flow(&self) -> Vec<crate::flow::FlowElement> {
963 let inner = self.inner.lock();
964 let main_frame_id = get_main_frame_id(&inner);
965 crate::text_frame::build_flow_elements(&inner, &self.inner, main_frame_id)
966 }
967
968 /// Get a read-only handle to a block by its entity ID.
969 ///
970 /// Entity IDs are stable across insertions and deletions.
971 /// Returns `None` if no block with this ID exists.
972 pub fn block_by_id(&self, block_id: usize) -> Option<crate::text_block::TextBlock> {
973 let inner = self.inner.lock();
974 let exists = frontend::commands::block_commands::get_block(&inner.ctx, &(block_id as u64))
975 .ok()
976 .flatten()
977 .is_some();
978
979 if exists {
980 Some(crate::text_block::TextBlock {
981 doc: self.inner.clone(),
982 block_id,
983 })
984 } else {
985 None
986 }
987 }
988
989 /// Build a single `BlockSnapshot` for the block at the given position.
990 ///
991 /// This is O(k) where k = format runs + image anchors in that block,
992 /// compared to `snapshot_flow()` which is O(n) over the entire document.
993 /// Use for incremental layout updates after single-block edits.
994 pub fn snapshot_block_at_position(
995 &self,
996 position: usize,
997 ) -> Option<crate::flow::BlockSnapshot> {
998 self.snapshot_block_at_position_masked(position, &crate::highlight::HighlightMask::all())
999 }
1000
1001 /// Like [`snapshot_block_at_position`](Self::snapshot_block_at_position)
1002 /// but with **no highlights applied** — base fragments and empty
1003 /// `paint_highlights`, regardless of the active sessions. Used by the
1004 /// incremental relayout path of a view that has opted out of highlights.
1005 pub fn snapshot_block_at_position_without_highlights(
1006 &self,
1007 position: usize,
1008 ) -> Option<crate::flow::BlockSnapshot> {
1009 self.snapshot_block_at_position_masked(position, &crate::highlight::HighlightMask::none())
1010 }
1011
1012 /// Like [`snapshot_block_at_position`](Self::snapshot_block_at_position) but rendering
1013 /// only the sessions `mask` admits — the per-view incremental path (two panes over one
1014 /// document can carry different find sessions). `all()` = the plain method; `none()` = the
1015 /// without-highlights method.
1016 pub fn snapshot_block_at_position_masked(
1017 &self,
1018 position: usize,
1019 mask: &crate::highlight::HighlightMask,
1020 ) -> Option<crate::flow::BlockSnapshot> {
1021 let inner = self.inner.lock();
1022 // Effective kind resolved once here (the join over the mask's sessions), then threaded
1023 // down with the mask itself.
1024 let hl = crate::highlight::SnapshotHighlights {
1025 kind: inner.highlights.effective_kind(mask),
1026 mask,
1027 suppress_paint: false,
1028 };
1029 let main_frame_id = get_main_frame_id(&inner);
1030 let store = inner.ctx.db_context.get_store();
1031
1032 // Rope-authoritative fast path. When every block is mirrored to the
1033 // rope (now true with tables — see `rope_positions_match_flow`), the
1034 // rope IS the position space the snapshot reports in, so we must also
1035 // *locate* the block via the rope. Walking a hand-rolled `running_pos`
1036 // here instead would search in the old cells-inline-no-sentinel space
1037 // and then report the rope position — an off-by-the-sentinel mismatch
1038 // for any block after a table.
1039 if common::database::rope_helpers::rope_positions_match_flow(store)
1040 && let Some((block_id, _, _)) =
1041 common::database::rope_helpers::find_block_at_char_position(store, position as i64)
1042 {
1043 return crate::text_block::build_block_snapshot(&inner, block_id, hl);
1044 }
1045
1046 // Collect all block IDs in document order, traversing into nested frames
1047 let ordered_block_ids = collect_frame_block_ids(&inner, main_frame_id)?;
1048
1049 // Walk blocks computing positions on the fly
1050 let pos = position as i64;
1051 let mut running_pos: i64 = 0;
1052 for &block_id in &ordered_block_ids {
1053 let block_dto = block_commands::get_block(&inner.ctx, &block_id)
1054 .ok()
1055 .flatten()?;
1056 let entity: common::entities::Block = block_dto.clone().into();
1057 let block_end =
1058 running_pos + common::database::rope_helpers::block_char_length(&entity, store);
1059 if pos >= running_pos && pos <= block_end {
1060 return crate::text_block::build_block_snapshot_with_position(
1061 &inner,
1062 block_id,
1063 Some(running_pos as usize),
1064 hl,
1065 );
1066 }
1067 running_pos = block_end + 1;
1068 }
1069
1070 // Fallback to last block
1071 if let Some(&last_id) = ordered_block_ids.last() {
1072 return crate::text_block::build_block_snapshot(&inner, last_id, hl);
1073 }
1074 None
1075 }
1076
1077 /// Get a read-only handle to the block containing the given
1078 /// character position. Returns `None` if position is out of range.
1079 pub fn block_at_position(&self, position: usize) -> Option<crate::text_block::TextBlock> {
1080 let inner = self.inner.lock();
1081 let dto = frontend::document_inspection::GetBlockAtPositionDto {
1082 position: to_i64(position),
1083 };
1084 let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1085 Some(crate::text_block::TextBlock {
1086 doc: self.inner.clone(),
1087 block_id: result.block_id as usize,
1088 })
1089 }
1090
1091 /// Get a read-only handle to a block by its 0-indexed global
1092 /// block number.
1093 ///
1094 /// **O(n)**: requires scanning all blocks sorted by
1095 /// `document_position` to find the nth one. Prefer
1096 /// [`block_at_position()`](TextDocument::block_at_position) or
1097 /// [`block_by_id()`](TextDocument::block_by_id) in
1098 /// performance-sensitive paths.
1099 pub fn block_by_number(&self, block_number: usize) -> Option<crate::text_block::TextBlock> {
1100 let inner = self.inner.lock();
1101 let all_blocks = frontend::commands::block_commands::get_all_block(&inner.ctx).ok()?;
1102 let mut sorted: Vec<_> = all_blocks.into_iter().collect();
1103 let store = inner.ctx.db_context.get_store();
1104 crate::inner::refresh_block_positions(&mut sorted, store);
1105 sorted.sort_by_key(|b| b.document_position);
1106
1107 sorted
1108 .get(block_number)
1109 .map(|b| crate::text_block::TextBlock {
1110 doc: self.inner.clone(),
1111 block_id: b.id as usize,
1112 })
1113 }
1114
1115 /// All blocks in the document, sorted by `document_position`. **O(n)**.
1116 ///
1117 /// Returns blocks from all frames, including those inside table cells.
1118 /// This is the efficient way to iterate all blocks — avoids the O(n^2)
1119 /// cost of calling `block_by_number(i)` in a loop.
1120 pub fn blocks(&self) -> Vec<crate::text_block::TextBlock> {
1121 let inner = self.inner.lock();
1122 let all_blocks =
1123 frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1124 let mut sorted: Vec<_> = all_blocks.into_iter().collect();
1125 let store = inner.ctx.db_context.get_store();
1126 crate::inner::refresh_block_positions(&mut sorted, store);
1127 sorted.sort_by_key(|b| b.document_position);
1128 sorted
1129 .iter()
1130 .map(|b| crate::text_block::TextBlock {
1131 doc: self.inner.clone(),
1132 block_id: b.id as usize,
1133 })
1134 .collect()
1135 }
1136
1137 /// All blocks whose character range intersects `[position, position + length)`.
1138 ///
1139 /// **O(n)**: scans all blocks once. Returns them sorted by `document_position`.
1140 /// A block intersects if its range `[block.position, block.position + block.length)`
1141 /// overlaps the query range. An empty query range (`length == 0`) returns the
1142 /// block containing that position, if any.
1143 pub fn blocks_in_range(
1144 &self,
1145 position: usize,
1146 length: usize,
1147 ) -> Vec<crate::text_block::TextBlock> {
1148 let inner = self.inner.lock();
1149 let all_blocks =
1150 frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1151 let mut sorted: Vec<_> = all_blocks.into_iter().collect();
1152 let store = inner.ctx.db_context.get_store();
1153 crate::inner::refresh_block_positions(&mut sorted, store);
1154 sorted.sort_by_key(|b| b.document_position);
1155
1156 let range_start = position;
1157 let range_end = position + length;
1158 sorted
1159 .iter()
1160 .filter(|b| {
1161 let block_start = b.document_position.max(0) as usize;
1162 let entity: common::entities::Block = (*b).clone().into();
1163 let block_end = block_start
1164 + common::database::rope_helpers::block_char_length(&entity, store).max(0)
1165 as usize;
1166 // Overlap check: block intersects [range_start, range_end)
1167 if length == 0 {
1168 // Point query: block contains the position
1169 range_start >= block_start && range_start < block_end
1170 } else {
1171 block_start < range_end && block_end > range_start
1172 }
1173 })
1174 .map(|b| crate::text_block::TextBlock {
1175 doc: self.inner.clone(),
1176 block_id: b.id as usize,
1177 })
1178 .collect()
1179 }
1180
1181 /// Snapshot the entire main flow in a single lock acquisition.
1182 ///
1183 /// Returns a [`FlowSnapshot`](crate::FlowSnapshot) containing snapshots
1184 /// for every element in the flow.
1185 pub fn snapshot_flow(&self) -> crate::flow::FlowSnapshot {
1186 self.snapshot_flow_masked(&crate::highlight::HighlightMask::all())
1187 }
1188
1189 /// Snapshot the entire main flow with **no highlights applied** — base
1190 /// fragments and empty `paint_highlights` on every block, regardless of
1191 /// the active sessions.
1192 ///
1193 /// This is the per-view opt-out: a read-only viewer that should stay
1194 /// free of search / spell / syntax highlighting pulls *this* snapshot
1195 /// instead of [`snapshot_flow`](Self::snapshot_flow). Because suppression
1196 /// happens at build time, it works for metric-affecting sessions too
1197 /// (whose highlights are otherwise merged into `fragments` irreversibly).
1198 pub fn snapshot_flow_without_highlights(&self) -> crate::flow::FlowSnapshot {
1199 self.snapshot_flow_masked(&crate::highlight::HighlightMask::none())
1200 }
1201
1202 /// Snapshot the entire main flow rendering only the sessions `mask` admits.
1203 ///
1204 /// The generalization of the plain / without-highlights pair: `all()` shows every session,
1205 /// `none()` shows none, and `only([...])` shows a chosen set — which is how two panes over
1206 /// one shared document carry different find sessions. The effective
1207 /// `HighlighterKind` is resolved **once here**, at the snapshot root,
1208 /// and threaded down, so a view showing only paint-only sessions never pays the reshape
1209 /// path for a metric session it does not show.
1210 pub fn snapshot_flow_masked(
1211 &self,
1212 mask: &crate::highlight::HighlightMask,
1213 ) -> crate::flow::FlowSnapshot {
1214 let inner = self.inner.lock();
1215 let main_frame_id = get_main_frame_id(&inner);
1216 let hl = crate::highlight::SnapshotHighlights {
1217 kind: inner.highlights.effective_kind(mask),
1218 mask,
1219 suppress_paint: false,
1220 };
1221 let elements = crate::text_frame::build_flow_snapshot(&inner, main_frame_id, hl);
1222 crate::flow::FlowSnapshot { elements }
1223 }
1224
1225 /// Snapshot the main flow like [`snapshot_flow_masked`](Self::snapshot_flow_masked),
1226 /// but **without computing the paint-only overlay** (`paint_highlights` is
1227 /// empty on every block). Fragments are identical — metric sessions still
1228 /// split them — so a consumer that reads only the fragments and their
1229 /// geometry gets the exact same tree, minus the `extract_paint_spans` work.
1230 ///
1231 /// This is the accessibility path's snapshot: the AT tree reads fragments,
1232 /// never the paint overlay, so paying to compute a per-block paint span for
1233 /// each of a spell-checker's tens of thousands of ranges is pure waste (it
1234 /// dominated the a11y rebuild on a large mis-dictionaried document). Render
1235 /// and layout keep using [`snapshot_flow_masked`](Self::snapshot_flow_masked),
1236 /// which they must — they draw the overlay.
1237 pub fn snapshot_flow_masked_no_paint(
1238 &self,
1239 mask: &crate::highlight::HighlightMask,
1240 ) -> crate::flow::FlowSnapshot {
1241 let inner = self.inner.lock();
1242 let main_frame_id = get_main_frame_id(&inner);
1243 let hl = crate::highlight::SnapshotHighlights {
1244 kind: inner.highlights.effective_kind(mask),
1245 mask,
1246 suppress_paint: true,
1247 };
1248 let elements = crate::text_frame::build_flow_snapshot(&inner, main_frame_id, hl);
1249 crate::flow::FlowSnapshot { elements }
1250 }
1251
1252 // ── Search ───────────────────────────────────────────────
1253
1254 /// Find the next (or previous) occurrence. Returns `None` if not found.
1255 pub fn find(
1256 &self,
1257 query: &str,
1258 from: usize,
1259 options: &FindOptions,
1260 ) -> Result<Option<FindMatch>> {
1261 let inner = self.inner.lock();
1262 let dto = options.to_find_text_dto(query, from);
1263 let result = document_search_commands::find_text(&inner.ctx, &dto)?;
1264 Ok(convert::find_result_to_match(&result))
1265 }
1266
1267 /// Find all occurrences.
1268 pub fn find_all(&self, query: &str, options: &FindOptions) -> Result<Vec<FindMatch>> {
1269 let inner = self.inner.lock();
1270 let dto = options.to_find_all_dto(query);
1271 let result = document_search_commands::find_all(&inner.ctx, &dto)?;
1272 Ok(convert::find_all_to_matches(&result))
1273 }
1274
1275 /// Replace occurrences. Returns the number of replacements. Undoable.
1276 ///
1277 /// `options` carries both how to find the text and — via
1278 /// [`crate::ReplaceOptions::format_policy`] — what the replacement wears where it
1279 /// overwrites formatted prose. The default drops the formatting under the replaced
1280 /// range, which is fine for plain text and destructive for a rename that lands on a
1281 /// partly-bold name; pass a different policy when that matters.
1282 pub fn replace_text(
1283 &self,
1284 query: &str,
1285 replacement: &str,
1286 replace_all: bool,
1287 options: &crate::ReplaceOptions,
1288 ) -> Result<usize> {
1289 let (count, queued) = {
1290 let mut inner = self.inner.lock();
1291 let dto = options.to_replace_dto(query, replacement, replace_all);
1292 let result =
1293 document_search_commands::replace_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1294 let count = to_usize(result.replacements_count);
1295 inner.invalidate_text_cache();
1296 if count > 0 {
1297 inner.modified = true;
1298 inner.rehighlight_all();
1299 // Replacements are scattered across the document — we can't
1300 // provide a single position/chars delta. Signal "content changed
1301 // from position 0, affecting `count` sites" so the consumer
1302 // knows to re-read.
1303 inner.queue_event(DocumentEvent::ContentsChanged {
1304 position: 0,
1305 chars_removed: 0,
1306 chars_added: 0,
1307 blocks_affected: count,
1308 });
1309 inner.check_block_count_changed();
1310 inner.check_flow_changed();
1311 let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1312 let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1313 inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1314 }
1315 (count, inner.take_queued_events())
1316 };
1317 crate::inner::dispatch_queued_events(queued);
1318 Ok(count)
1319 }
1320
1321 /// Replace an explicit set of ranges, each with **its own** replacement text. Undoable
1322 /// as one action, however many ranges it touches.
1323 ///
1324 /// [`replace_text`](Self::replace_text) can only put the same string at every match.
1325 /// This is for the case where the caller decides *per occurrence* — a reviewed bulk
1326 /// rename where some occurrences are unticked, or one that preserves the case it found
1327 /// (`AURÉLIEN` → `AURÉLIAN`, not `aurélian`).
1328 ///
1329 /// ⚠ **Do not build the ranges with a separate `find_all` call.** The document can move
1330 /// between the two, and the ranges then address text that is no longer there — which
1331 /// does not fail, it rewrites *the wrong words*. Use
1332 /// [`find_and_replace`](Self::find_and_replace), which does both under one lock.
1333 ///
1334 /// Ranges that straddle a block boundary, or that overlap one another, are **skipped**;
1335 /// the returned count reflects only what was actually applied.
1336 pub fn replace_ranges(
1337 &self,
1338 ranges: &[ReplaceRange],
1339 options: &crate::ReplaceOptions,
1340 ) -> Result<usize> {
1341 let (count, queued) = {
1342 let mut inner = self.inner.lock();
1343 let count = Self::replace_ranges_locked(&mut inner, ranges, options)?;
1344 (count, inner.take_queued_events())
1345 };
1346 crate::inner::dispatch_queued_events(queued);
1347 Ok(count)
1348 }
1349
1350 /// Find every match of `query` and let `decide` choose what each becomes — **atomically**.
1351 ///
1352 /// `decide` is handed the matched text and the index of the match, and returns the
1353 /// replacement, or `None` to leave that occurrence alone. So a rename that preserves case
1354 /// and skips the occurrences a writer unticked is one call:
1355 ///
1356 /// ```no_run
1357 /// # use text_document::{TextDocument, FindOptions, ReplaceOptions};
1358 /// # let doc = TextDocument::new();
1359 /// # let excluded: Vec<usize> = vec![];
1360 /// doc.find_and_replace("Aurélien", &ReplaceOptions::new(FindOptions::default()), |matched, i| {
1361 /// if excluded.contains(&i) {
1362 /// return None; // the writer unticked this one
1363 /// }
1364 /// Some(if matched.chars().all(char::is_uppercase) { "AURÉLIAN".into() } else { "Aurélian".into() })
1365 /// })?;
1366 /// # Ok::<(), text_document::DocumentError>(())
1367 /// ```
1368 ///
1369 /// **The scan and the splice happen under one lock**, which is the whole point. Calling
1370 /// `find_all` and then `replace_ranges` would drop the lock in between, and the document
1371 /// can be edited there — after which every range addresses text that has moved. That does
1372 /// not raise an error; it silently rewrites the wrong words. The document mutex is not
1373 /// reentrant, so composing the two public methods cannot close the gap; only doing both
1374 /// inside one can.
1375 pub fn find_and_replace(
1376 &self,
1377 query: &str,
1378 options: &crate::ReplaceOptions,
1379 mut decide: impl FnMut(&str, usize) -> Option<String>,
1380 ) -> Result<usize> {
1381 let (count, queued) = {
1382 let mut inner = self.inner.lock();
1383
1384 // Scan. The matched TEXT comes back with the offsets, sliced by the use case from
1385 // the very text it searched — deliberately, so this never has to slice a
1386 // whole-document string of its own. The only one reachable here is
1387 // `to_plain_text`, which is the human-readable view and carries no `U+FFFC` anchor
1388 // for an embedded table; slicing it with these offsets would be wrong by two
1389 // characters per preceding table, and the rename would rewrite the wrong words.
1390 let found = {
1391 let dto = options.find.to_find_all_dto(query);
1392 document_search_commands::find_all(&inner.ctx, &dto)?
1393 };
1394
1395 // …decide, against the document as it is RIGHT NOW…
1396 let mut ranges: Vec<ReplaceRange> = Vec::new();
1397 for (i, ((&position, &length), matched)) in found
1398 .positions
1399 .iter()
1400 .zip(found.lengths.iter())
1401 .zip(found.matched_texts.iter())
1402 .enumerate()
1403 {
1404 if let Some(replacement) = decide(matched, i) {
1405 ranges.push(ReplaceRange {
1406 position: to_usize(position),
1407 length: to_usize(length),
1408 replacement,
1409 });
1410 }
1411 }
1412
1413 // …and splice — all without ever letting go of the lock.
1414 let count = if ranges.is_empty() {
1415 0
1416 } else {
1417 Self::replace_ranges_locked(&mut inner, &ranges, options)?
1418 };
1419 (count, inner.take_queued_events())
1420 };
1421 crate::inner::dispatch_queued_events(queued);
1422 Ok(count)
1423 }
1424
1425 /// The splice, with the lock already held. Shared by [`Self::replace_ranges`] and
1426 /// [`Self::find_and_replace`] so the second cannot drift from the first.
1427 fn replace_ranges_locked(
1428 inner: &mut crate::inner::TextDocumentInner,
1429 ranges: &[ReplaceRange],
1430 options: &crate::ReplaceOptions,
1431 ) -> Result<usize> {
1432 let dto = options.to_replace_ranges_dto(ranges);
1433 let result =
1434 document_search_commands::replace_ranges(&inner.ctx, Some(inner.stack_id), &dto)?;
1435 let count = to_usize(result.replacements_count);
1436
1437 inner.invalidate_text_cache();
1438 if count > 0 {
1439 inner.modified = true;
1440 inner.rehighlight_all();
1441 inner.queue_event(DocumentEvent::ContentsChanged {
1442 position: 0,
1443 chars_removed: 0,
1444 chars_added: 0,
1445 blocks_affected: count,
1446 });
1447 inner.check_block_count_changed();
1448 inner.check_flow_changed();
1449 let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1450 let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1451 inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1452 }
1453 Ok(count)
1454 }
1455
1456 // ── Resources ────────────────────────────────────────────
1457
1458 /// Add a resource (image, stylesheet) to the document.
1459 pub fn add_resource(
1460 &self,
1461 resource_type: ResourceType,
1462 name: &str,
1463 mime_type: &str,
1464 data: &[u8],
1465 ) -> Result<()> {
1466 let mut inner = self.inner.lock();
1467 let dto = frontend::resource::dtos::CreateResourceDto {
1468 created_at: Default::default(),
1469 updated_at: Default::default(),
1470 resource_type,
1471 name: name.into(),
1472 url: String::new(),
1473 mime_type: mime_type.into(),
1474 data_base64: BASE64.encode(data),
1475 };
1476 let created = resource_commands::create_resource(
1477 &inner.ctx,
1478 Some(inner.stack_id),
1479 &dto,
1480 inner.document_id,
1481 -1,
1482 )?;
1483 inner.resource_cache.insert(name.to_string(), created.id);
1484 Ok(())
1485 }
1486
1487 /// Get a resource by name. Returns `None` if not found.
1488 ///
1489 /// Uses an internal cache to avoid scanning all resources on repeated lookups.
1490 pub fn resource(&self, name: &str) -> Result<Option<Vec<u8>>> {
1491 let mut inner = self.inner.lock();
1492
1493 // Fast path: check the name → ID cache.
1494 if let Some(&id) = inner.resource_cache.get(name) {
1495 if let Some(r) = resource_commands::get_resource(&inner.ctx, &id)? {
1496 let bytes = BASE64
1497 .decode(&r.data_base64)
1498 .map_err(|e| DocumentError::Internal(e.into()))?;
1499 return Ok(Some(bytes));
1500 }
1501 // ID was stale — fall through to full scan.
1502 inner.resource_cache.remove(name);
1503 }
1504
1505 // Slow path: linear scan, then populate cache for the match.
1506 let all = resource_commands::get_all_resource(&inner.ctx)?;
1507 for r in &all {
1508 if r.name == name {
1509 inner.resource_cache.insert(name.to_string(), r.id);
1510 let bytes = BASE64
1511 .decode(&r.data_base64)
1512 .map_err(|e| DocumentError::Internal(e.into()))?;
1513 return Ok(Some(bytes));
1514 }
1515 }
1516 Ok(None)
1517 }
1518
1519 // ── Undo / Redo ──────────────────────────────────────────
1520
1521 /// Undo the last operation.
1522 pub fn undo(&self) -> Result<()> {
1523 let queued = {
1524 let mut inner = self.inner.lock();
1525 let before = capture_block_state(&inner);
1526 let result = undo_redo_commands::undo(&inner.ctx, Some(inner.stack_id));
1527 inner.invalidate_text_cache();
1528 result?;
1529 inner.rehighlight_all();
1530 emit_undo_redo_change_events(&mut inner, &before);
1531 inner.check_block_count_changed();
1532 inner.check_flow_changed();
1533 let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1534 let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1535 inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1536 inner.take_queued_events()
1537 };
1538 crate::inner::dispatch_queued_events(queued);
1539 Ok(())
1540 }
1541
1542 /// Redo the last undone operation.
1543 pub fn redo(&self) -> Result<()> {
1544 let queued = {
1545 let mut inner = self.inner.lock();
1546 let before = capture_block_state(&inner);
1547 let result = undo_redo_commands::redo(&inner.ctx, Some(inner.stack_id));
1548 inner.invalidate_text_cache();
1549 result?;
1550 inner.rehighlight_all();
1551 emit_undo_redo_change_events(&mut inner, &before);
1552 inner.check_block_count_changed();
1553 inner.check_flow_changed();
1554 let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1555 let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1556 inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1557 inner.take_queued_events()
1558 };
1559 crate::inner::dispatch_queued_events(queued);
1560 Ok(())
1561 }
1562
1563 /// Returns true if there are operations that can be undone.
1564 pub fn can_undo(&self) -> bool {
1565 let inner = self.inner.lock();
1566 undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id))
1567 }
1568
1569 /// Returns true if there are operations that can be redone.
1570 pub fn can_redo(&self) -> bool {
1571 let inner = self.inner.lock();
1572 undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id))
1573 }
1574
1575 /// Clear all undo/redo history.
1576 pub fn clear_undo_redo(&self) {
1577 let inner = self.inner.lock();
1578 undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
1579 }
1580
1581 // ── Modified state ───────────────────────────────────────
1582
1583 /// Returns true if the document has been modified since creation or last reset.
1584 pub fn is_modified(&self) -> bool {
1585 self.inner.lock().modified
1586 }
1587
1588 /// Set or clear the modified flag.
1589 pub fn set_modified(&self, modified: bool) {
1590 let queued = {
1591 let mut inner = self.inner.lock();
1592 if inner.modified != modified {
1593 inner.modified = modified;
1594 inner.queue_event(DocumentEvent::ModificationChanged(modified));
1595 }
1596 inner.take_queued_events()
1597 };
1598 crate::inner::dispatch_queued_events(queued);
1599 }
1600
1601 /// A monotonic counter, bumped once per [`DocumentEvent::ContentsChanged`]
1602 /// queued so far. Starts at `0`.
1603 ///
1604 /// Lets a caller answer "was this notification caused by exactly the
1605 /// most recent edit, with nothing else having happened since" precisely
1606 /// — snapshot the value when acting on a notification, and compare it
1607 /// against the current value later. This is deliberately *not* the same
1608 /// as [`is_modified`](Self::is_modified) (a flag, not a count) or the
1609 /// undo stack's depth (which does not grow when consecutive compatible
1610 /// edits merge into one entry — e.g. fast consecutive typing).
1611 pub fn content_revision(&self) -> u64 {
1612 self.inner.lock().content_revision
1613 }
1614
1615 // ── Document properties ──────────────────────────────────
1616
1617 /// Get the document title.
1618 pub fn title(&self) -> String {
1619 let inner = self.inner.lock();
1620 document_commands::get_document(&inner.ctx, &inner.document_id)
1621 .ok()
1622 .flatten()
1623 .map(|d| d.title)
1624 .unwrap_or_default()
1625 }
1626
1627 /// Set the document title.
1628 pub fn set_title(&self, title: &str) -> Result<()> {
1629 let inner = self.inner.lock();
1630 let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1631 .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1632 let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1633 update.title = title.into();
1634 document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1635 Ok(())
1636 }
1637
1638 /// Get the text direction.
1639 pub fn text_direction(&self) -> TextDirection {
1640 let inner = self.inner.lock();
1641 document_commands::get_document(&inner.ctx, &inner.document_id)
1642 .ok()
1643 .flatten()
1644 .map(|d| d.text_direction)
1645 .unwrap_or(TextDirection::LeftToRight)
1646 }
1647
1648 /// Set the text direction.
1649 pub fn set_text_direction(&self, direction: TextDirection) -> Result<()> {
1650 let inner = self.inner.lock();
1651 let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1652 .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1653 let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1654 update.text_direction = direction;
1655 document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1656 Ok(())
1657 }
1658
1659 /// Get the default wrap mode.
1660 pub fn default_wrap_mode(&self) -> WrapMode {
1661 let inner = self.inner.lock();
1662 document_commands::get_document(&inner.ctx, &inner.document_id)
1663 .ok()
1664 .flatten()
1665 .map(|d| d.default_wrap_mode)
1666 .unwrap_or(WrapMode::WordWrap)
1667 }
1668
1669 /// Set the default wrap mode.
1670 pub fn set_default_wrap_mode(&self, mode: WrapMode) -> Result<()> {
1671 let inner = self.inner.lock();
1672 let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1673 .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1674 let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1675 update.default_wrap_mode = mode;
1676 document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1677 Ok(())
1678 }
1679
1680 /// Get the document-wide default language (ISO 639-1 code, e.g. "en").
1681 /// This is the fallback hyphenation language for blocks that don't set
1682 /// their own `language`. Defaults to `"en"` when never set.
1683 pub fn default_language(&self) -> String {
1684 let inner = self.inner.lock();
1685 document_commands::get_document(&inner.ctx, &inner.document_id)
1686 .ok()
1687 .flatten()
1688 .and_then(|d| d.default_language)
1689 .unwrap_or_else(|| "en".to_string())
1690 }
1691
1692 /// Set the document-wide default language (ISO 639-1 code). Blocks
1693 /// without an explicit `language` inherit this for hyphenation.
1694 pub fn set_default_language(&self, language: &str) -> Result<()> {
1695 let inner = self.inner.lock();
1696 let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1697 .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1698 let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1699 update.default_language = Some(language.to_string());
1700 document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1701 Ok(())
1702 }
1703
1704 // ── Event subscription ───────────────────────────────────
1705
1706 /// Subscribe to document events via callback.
1707 ///
1708 /// Callbacks are invoked **outside** the document lock (after the editing
1709 /// operation completes and the lock is released). It is safe to call
1710 /// `TextDocument` or `TextCursor` methods from within the callback without
1711 /// risk of deadlock. However, keep callbacks lightweight — they run
1712 /// synchronously on the calling thread and block the caller until they
1713 /// return.
1714 ///
1715 /// Drop the returned [`Subscription`] to unsubscribe.
1716 ///
1717 /// # Breaking change (v0.0.6)
1718 ///
1719 /// The callback bound changed from `Send` to `Send + Sync` in v0.0.6
1720 /// to support `Arc`-based dispatch. Callbacks that capture non-`Sync`
1721 /// types (e.g., `Rc<T>`, `Cell<T>`) must be wrapped in a `Mutex`.
1722 pub fn on_change<F>(&self, callback: F) -> Subscription
1723 where
1724 F: Fn(DocumentEvent) + Send + Sync + 'static,
1725 {
1726 let mut inner = self.inner.lock();
1727 events::subscribe_inner(&mut inner, callback)
1728 }
1729
1730 /// Return events accumulated since the last `poll_events()` call.
1731 ///
1732 /// This delivery path is independent of callback dispatch via
1733 /// [`on_change`](Self::on_change) — using both simultaneously is safe
1734 /// and each path sees every event exactly once.
1735 pub fn poll_events(&self) -> Vec<DocumentEvent> {
1736 let mut inner = self.inner.lock();
1737 inner.drain_poll_events()
1738 }
1739
1740 // ── Syntax highlighting ──────────────────────────────────
1741
1742 /// Attach a single syntax highlighter to this document — the classic, one-highlighter
1743 /// entry point.
1744 ///
1745 /// Immediately re-highlights the entire document. **Replaces** the one highlighter this
1746 /// method manages, and *only* that one: a spell-checker or find layer registered
1747 /// independently via [`add_syntax_session`](Self::add_syntax_session) /
1748 /// [`add_range_session`](Self::add_range_session) is left untouched. Pass `None` to remove
1749 /// it.
1750 ///
1751 /// This is a convenience over the session registry — it owns exactly one "shim" session. A
1752 /// host that wants to manage several layers uses the session methods directly.
1753 pub fn set_syntax_highlighter(&self, highlighter: Option<Arc<dyn crate::SyntaxHighlighter>>) {
1754 let queued = {
1755 let mut inner = self.inner.lock();
1756 let prev_kind = inner.highlight_kind;
1757 let installed = highlighter.is_some();
1758 inner.highlights.set_shim(highlighter);
1759 if installed {
1760 inner.rehighlight_all(); // recomputes highlight_kind
1761 } else {
1762 inner.recompute_highlight_kind();
1763 }
1764 Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1765 inner.take_queued_events()
1766 };
1767 crate::inner::dispatch_queued_events(queued);
1768 }
1769
1770 /// Register a **syntax session** — a [`SyntaxHighlighter`](crate::SyntaxHighlighter)
1771 /// callback with its own per-block state cascade — and return its [`crate::SessionId`].
1772 ///
1773 /// Unlike [`set_syntax_highlighter`](Self::set_syntax_highlighter), this **adds** rather
1774 /// than replaces: a document can carry a syntax highlighter and a spell-checker at once,
1775 /// each a session, merged in `(priority, registration)` order (a later session's field
1776 /// wins). Sessions remain visible only in views whose
1777 /// [`HighlightMask`](crate::highlight::HighlightMask) admits them.
1778 pub fn add_syntax_session(
1779 &self,
1780 highlighter: Arc<dyn crate::SyntaxHighlighter>,
1781 ) -> crate::highlight::SessionId {
1782 self.add_syntax_session_with_priority(highlighter, 0)
1783 }
1784
1785 /// [`add_syntax_session`](Self::add_syntax_session) at an explicit merge priority — see
1786 /// [`add_range_session_with_priority`](Self::add_range_session_with_priority).
1787 pub fn add_syntax_session_with_priority(
1788 &self,
1789 highlighter: Arc<dyn crate::SyntaxHighlighter>,
1790 priority: i32,
1791 ) -> crate::highlight::SessionId {
1792 let (id, queued) = {
1793 let mut inner = self.inner.lock();
1794 let prev_kind = inner.highlight_kind;
1795 let id = inner.highlights.add_syntax(highlighter, priority);
1796 inner.rehighlight_all();
1797 Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1798 (id, inner.take_queued_events())
1799 };
1800 crate::inner::dispatch_queued_events(queued);
1801 id
1802 }
1803
1804 /// Register an empty **range session** — absolute-offset ranges set with
1805 /// [`set_session_ranges`](Self::set_session_ranges), the shape used for search and (later)
1806 /// an externally-driven spell-checker. Returns its [`crate::SessionId`].
1807 ///
1808 /// A view's own find session is a range session it alone admits; that is how two panes
1809 /// over one document highlight different queries.
1810 pub fn add_range_session(&self) -> crate::highlight::SessionId {
1811 self.add_range_session_with_priority(0)
1812 }
1813
1814 /// [`add_range_session`](Self::add_range_session) at an explicit **merge priority**.
1815 ///
1816 /// Where two sessions format the same character, the higher priority wins field by field;
1817 /// equal priorities fall back to registration order, which is what every session gets by
1818 /// default (`0`).
1819 ///
1820 /// Reach for this when a layer must reliably lose — an ambient background band that every
1821 /// find match and spell squiggle should paint over. Registration order cannot express that:
1822 /// a per-view layer is registered when its view appears, so whether it lands before or
1823 /// after the find session depends on the order the user happened to open things in.
1824 pub fn add_range_session_with_priority(&self, priority: i32) -> crate::highlight::SessionId {
1825 let mut inner = self.inner.lock();
1826 inner.highlights.add_range(priority)
1827 // No repaint: an empty range session shows nothing until its ranges are set.
1828 }
1829
1830 /// Replace the ranges of a range session (absolute char offsets, the space
1831 /// [`FindMatch`] reports in). Returns `false` if `id` is not a range
1832 /// session.
1833 ///
1834 /// Fires a highlight-changed event so live views showing this session re-snapshot — the
1835 /// only signal there is, since the ranges do not mutate the document.
1836 pub fn set_session_ranges(
1837 &self,
1838 id: crate::highlight::SessionId,
1839 ranges: Vec<crate::highlight::RangeHighlight>,
1840 ) -> bool {
1841 let (ok, queued) = {
1842 let mut inner = self.inner.lock();
1843 let prev_kind = inner.highlight_kind;
1844 // The block layout the ranges are bucketed against — cheap (ids + positions, no
1845 // block text) and computed before the mutable borrow of `highlights`. This is what
1846 // lets `merged_spans_for_block` look up only a block's own ranges instead of
1847 // scanning the whole vector per block.
1848 let block_positions = crate::highlight::ordered_block_positions(&inner);
1849 let changed = inner.highlights.set_ranges(id, ranges, &block_positions);
1850 if let Some((position, length)) = changed {
1851 inner.recompute_highlight_kind();
1852 // The real extent, not `0, 0`: a view can then recolor just the block it covers
1853 // rather than re-snapshotting the whole document on every caret move.
1854 Self::queue_highlight_changed(&mut inner, position, length, prev_kind);
1855 }
1856 (changed.is_some(), inner.take_queued_events())
1857 };
1858 crate::inner::dispatch_queued_events(queued);
1859 ok
1860 }
1861
1862 /// Retire a session (of either kind). Returns whether it existed.
1863 pub fn remove_session(&self, id: crate::highlight::SessionId) -> bool {
1864 let (existed, queued) = {
1865 let mut inner = self.inner.lock();
1866 let prev_kind = inner.highlight_kind;
1867 let existed = inner.highlights.remove(id);
1868 if existed {
1869 inner.recompute_highlight_kind();
1870 Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1871 }
1872 (existed, inner.take_queued_events())
1873 };
1874 crate::inner::dispatch_queued_events(queued);
1875 existed
1876 }
1877
1878 /// Re-highlight the entire document.
1879 ///
1880 /// Call this when the highlighter's rules change (e.g., new keywords
1881 /// were added, spellcheck dictionary updated).
1882 pub fn rehighlight(&self) {
1883 let queued = {
1884 let mut inner = self.inner.lock();
1885 let prev_kind = inner.highlight_kind;
1886 inner.rehighlight_all();
1887 Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1888 inner.take_queued_events()
1889 };
1890 crate::inner::dispatch_queued_events(queued);
1891 }
1892
1893 /// Re-highlight a single block and cascade to subsequent blocks if
1894 /// the block state changes.
1895 pub fn rehighlight_block(&self, block_id: usize) {
1896 let queued = {
1897 let mut inner = self.inner.lock();
1898 let prev_kind = inner.highlight_kind;
1899 inner.rehighlight_from_block(block_id);
1900 Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1901 inner.take_queued_events()
1902 };
1903 crate::inner::dispatch_queued_events(queued);
1904 }
1905
1906 /// Queue the relayout/repaint notification for a highlight-only change.
1907 ///
1908 /// Highlighting overlays the layout without touching stored formatting,
1909 /// so it emits no edit event on its own — subscribers (live editors)
1910 /// must be told to re-snapshot. The event kind depends on whether the
1911 /// shaping input (`fragments`) changed:
1912 ///
1913 /// - A change that leaves `fragments` BASE on both sides (paint-only ↔
1914 /// paint-only / none) emits [`DocumentEvent::HighlightPaintChanged`],
1915 /// which the editor handles by recoloring the cached layout without
1916 /// reshaping.
1917 /// - Any transition involving a metric-affecting highlighter changes
1918 /// `fragments` (highlights are merged in / removed), so it emits
1919 /// [`DocumentEvent::FormatChanged`] (full relayout, caret/scroll
1920 /// preserved).
1921 ///
1922 /// `position` / `length` name the extent that changed, so a live view can
1923 /// recolor just the blocks it covers instead of re-deriving the whole
1924 /// snapshot. **A `length` of `0` means "unknown — assume the whole
1925 /// document"**, which is what the genuinely document-wide operations pass
1926 /// (installing or retiring a highlighter, a full rehighlight). Only
1927 /// [`set_session_ranges`](Self::set_session_ranges) reports a real extent,
1928 /// its before/after range sets giving an exact answer.
1929 fn queue_highlight_changed(
1930 inner: &mut TextDocumentInner,
1931 position: usize,
1932 length: usize,
1933 prev_kind: crate::highlight::HighlighterKind,
1934 ) {
1935 use crate::highlight::HighlighterKind::{Metric, None as KNone, PaintOnly};
1936 let new_kind = inner.highlight_kind;
1937 let event = match (prev_kind, new_kind) {
1938 // No highlighter before or after — nothing changed.
1939 (KNone, KNone) => return,
1940 // Fragments are BASE on both sides: recolor-only.
1941 (PaintOnly, PaintOnly) | (KNone, PaintOnly) | (PaintOnly, KNone) => {
1942 DocumentEvent::HighlightPaintChanged { position, length }
1943 }
1944 // A metric highlighter is involved on one side: fragments change.
1945 (KNone, Metric)
1946 | (Metric, Metric)
1947 | (Metric, PaintOnly)
1948 | (Metric, KNone)
1949 | (PaintOnly, Metric) => DocumentEvent::FormatChanged {
1950 position,
1951 length,
1952 kind: crate::flow::FormatChangeKind::Character,
1953 },
1954 };
1955 inner.queue_event(event);
1956 }
1957}
1958
1959impl Default for TextDocument {
1960 fn default() -> Self {
1961 Self::new()
1962 }
1963}
1964
1965// ── Undo/redo change detection helpers ─────────────────────────
1966
1967/// Lightweight block state for before/after comparison during undo/redo.
1968struct UndoBlockState {
1969 id: u64,
1970 position: i64,
1971 text_length: i64,
1972 plain_text: String,
1973 format: BlockFormat,
1974}
1975
1976/// Capture the state of all blocks, sorted by document_position.
1977fn capture_block_state(inner: &TextDocumentInner) -> Vec<UndoBlockState> {
1978 let mut all_blocks =
1979 frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1980 let store = inner.ctx.db_context.get_store();
1981 crate::inner::refresh_block_positions(&mut all_blocks, store);
1982 let mut states: Vec<UndoBlockState> = all_blocks
1983 .into_iter()
1984 .map(|b| {
1985 let format = BlockFormat::from(&b);
1986 let entity: common::entities::Block = b.clone().into();
1987 let plain_text =
1988 common::database::rope_helpers::block_content_via_store(&entity, store);
1989 let text_length = common::database::rope_helpers::block_char_length(&entity, store);
1990 UndoBlockState {
1991 id: b.id,
1992 position: b.document_position,
1993 text_length,
1994 plain_text,
1995 format,
1996 }
1997 })
1998 .collect();
1999 states.sort_by_key(|s| s.position);
2000 states
2001}
2002
2003/// Build the full document text from sorted block states (joined with newlines).
2004fn build_doc_text(states: &[UndoBlockState]) -> String {
2005 states
2006 .iter()
2007 .map(|s| s.plain_text.as_str())
2008 .collect::<Vec<_>>()
2009 .join("\n")
2010}
2011
2012/// Compute the precise edit between two strings by comparing common prefix and suffix.
2013/// Returns `(edit_offset, chars_removed, chars_added)`.
2014fn compute_text_edit(before: &str, after: &str) -> (usize, usize, usize) {
2015 let before_chars: Vec<char> = before.chars().collect();
2016 let after_chars: Vec<char> = after.chars().collect();
2017
2018 // Common prefix
2019 let prefix_len = before_chars
2020 .iter()
2021 .zip(after_chars.iter())
2022 .take_while(|(a, b)| a == b)
2023 .count();
2024
2025 // Common suffix (not overlapping with prefix)
2026 let before_remaining = before_chars.len() - prefix_len;
2027 let after_remaining = after_chars.len() - prefix_len;
2028 let suffix_len = before_chars
2029 .iter()
2030 .rev()
2031 .zip(after_chars.iter().rev())
2032 .take(before_remaining.min(after_remaining))
2033 .take_while(|(a, b)| a == b)
2034 .count();
2035
2036 let removed = before_remaining - suffix_len;
2037 let added = after_remaining - suffix_len;
2038
2039 (prefix_len, removed, added)
2040}
2041
2042/// Compare block state before and after undo/redo and emit
2043/// ContentsChanged / FormatChanged events for affected regions.
2044fn emit_undo_redo_change_events(inner: &mut TextDocumentInner, before: &[UndoBlockState]) {
2045 let after = capture_block_state(inner);
2046
2047 // Build a map of block id → state for the "before" set.
2048 let before_map: std::collections::HashMap<u64, &UndoBlockState> =
2049 before.iter().map(|s| (s.id, s)).collect();
2050 let after_map: std::collections::HashMap<u64, &UndoBlockState> =
2051 after.iter().map(|s| (s.id, s)).collect();
2052
2053 // Track the affected content region (earliest position, total old/new length).
2054 let mut content_changed = false;
2055 let mut earliest_pos: Option<usize> = None;
2056 let mut old_end: usize = 0;
2057 let mut new_end: usize = 0;
2058 let mut blocks_affected: usize = 0;
2059
2060 let mut format_only_changes: Vec<(usize, usize)> = Vec::new(); // (position, length)
2061
2062 // Check blocks present in both before and after.
2063 for after_state in &after {
2064 if let Some(before_state) = before_map.get(&after_state.id) {
2065 let text_changed = before_state.plain_text != after_state.plain_text
2066 || before_state.text_length != after_state.text_length;
2067 let format_changed = before_state.format != after_state.format;
2068
2069 if text_changed {
2070 content_changed = true;
2071 blocks_affected += 1;
2072 let pos = after_state.position.max(0) as usize;
2073 earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
2074 old_end = old_end.max(
2075 before_state.position.max(0) as usize
2076 + before_state.text_length.max(0) as usize,
2077 );
2078 new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
2079 } else if format_changed {
2080 let pos = after_state.position.max(0) as usize;
2081 let len = after_state.text_length.max(0) as usize;
2082 format_only_changes.push((pos, len));
2083 }
2084 } else {
2085 // Block exists in after but not in before — new block from undo/redo.
2086 content_changed = true;
2087 blocks_affected += 1;
2088 let pos = after_state.position.max(0) as usize;
2089 earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
2090 new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
2091 }
2092 }
2093
2094 // Check blocks that were removed (present in before but not after).
2095 for before_state in before {
2096 if !after_map.contains_key(&before_state.id) {
2097 content_changed = true;
2098 blocks_affected += 1;
2099 let pos = before_state.position.max(0) as usize;
2100 earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
2101 old_end = old_end.max(pos + before_state.text_length.max(0) as usize);
2102 }
2103 }
2104
2105 if content_changed {
2106 let position = earliest_pos.unwrap_or(0);
2107 let chars_removed = old_end.saturating_sub(position);
2108 let chars_added = new_end.saturating_sub(position);
2109
2110 // Use a precise text-level diff for cursor adjustment so cursors land
2111 // at the actual edit point rather than the end of the affected block.
2112 let before_text = build_doc_text(before);
2113 let after_text = build_doc_text(&after);
2114 let (edit_offset, precise_removed, precise_added) =
2115 compute_text_edit(&before_text, &after_text);
2116 if precise_removed > 0 || precise_added > 0 {
2117 inner.adjust_cursors(edit_offset, precise_removed, precise_added);
2118 }
2119
2120 inner.queue_event(DocumentEvent::ContentsChanged {
2121 position,
2122 chars_removed,
2123 chars_added,
2124 blocks_affected,
2125 });
2126 }
2127
2128 // Emit FormatChanged for blocks where only formatting changed (not content).
2129 for (position, length) in format_only_changes {
2130 inner.queue_event(DocumentEvent::FormatChanged {
2131 position,
2132 length,
2133 kind: FormatChangeKind::Block,
2134 });
2135 }
2136}
2137
2138// ── Flow helpers ──────────────────────────────────────────────
2139
2140/// Get the main frame ID for the document.
2141/// Collect all block IDs in document order from a frame, recursing into nested
2142/// sub-frames (negative entries in child_order).
2143fn collect_frame_block_ids(
2144 inner: &TextDocumentInner,
2145 frame_id: frontend::common::types::EntityId,
2146) -> Option<Vec<u64>> {
2147 let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
2148 .ok()
2149 .flatten()?;
2150
2151 if !frame_dto.child_order.is_empty() {
2152 let mut block_ids = Vec::new();
2153 for &entry in &frame_dto.child_order {
2154 if entry > 0 {
2155 block_ids.push(entry as u64);
2156 } else if entry < 0 {
2157 let sub_frame_id = (-entry) as u64;
2158 let sub_frame = frame_commands::get_frame(&inner.ctx, &sub_frame_id)
2159 .ok()
2160 .flatten();
2161 if let Some(ref sf) = sub_frame {
2162 if let Some(table_id) = sf.table {
2163 // Table anchor frame: collect blocks from cell frames
2164 // in row-major order, matching collect_block_ids_recursive.
2165 if let Some(table_dto) = table_commands::get_table(&inner.ctx, &table_id)
2166 .ok()
2167 .flatten()
2168 {
2169 let mut cell_dtos: Vec<_> = table_dto
2170 .cells
2171 .iter()
2172 .filter_map(|&cid| {
2173 table_cell_commands::get_table_cell(&inner.ctx, &cid)
2174 .ok()
2175 .flatten()
2176 })
2177 .collect();
2178 cell_dtos
2179 .sort_by(|a, b| a.row.cmp(&b.row).then(a.column.cmp(&b.column)));
2180 for cell_dto in &cell_dtos {
2181 if let Some(cf_id) = cell_dto.cell_frame
2182 && let Some(cf_ids) = collect_frame_block_ids(inner, cf_id)
2183 {
2184 block_ids.extend(cf_ids);
2185 }
2186 }
2187 }
2188 } else if let Some(sub_ids) = collect_frame_block_ids(inner, sub_frame_id) {
2189 block_ids.extend(sub_ids);
2190 }
2191 }
2192 }
2193 }
2194 Some(block_ids)
2195 } else {
2196 Some(frame_dto.blocks.to_vec())
2197 }
2198}
2199
2200pub(crate) fn get_main_frame_id(inner: &TextDocumentInner) -> frontend::common::types::EntityId {
2201 // The document's first frame is the main frame.
2202 let frames = frontend::commands::document_commands::get_document_relationship(
2203 &inner.ctx,
2204 &inner.document_id,
2205 &frontend::document::dtos::DocumentRelationshipField::Frames,
2206 )
2207 .unwrap_or_default();
2208
2209 frames.first().copied().unwrap_or(0)
2210}
2211
2212// ── Long-operation event data helpers ─────────────────────────
2213
2214/// Parse progress JSON: `{"id":"...", "percentage": 50.0, "message": "..."}`
2215fn parse_progress_data(data: &Option<String>) -> (String, f64, String) {
2216 let Some(json) = data else {
2217 return (String::new(), 0.0, String::new());
2218 };
2219 let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
2220 let id = v["id"].as_str().unwrap_or_default().to_string();
2221 let pct = v["percentage"].as_f64().unwrap_or(0.0);
2222 let msg = v["message"].as_str().unwrap_or_default().to_string();
2223 (id, pct, msg)
2224}
2225
2226/// Parse completed/cancelled JSON: `{"id":"..."}`
2227fn parse_id_data(data: &Option<String>) -> String {
2228 let Some(json) = data else {
2229 return String::new();
2230 };
2231 let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
2232 v["id"].as_str().unwrap_or_default().to_string()
2233}
2234
2235/// Parse failed JSON: `{"id":"...", "error":"..."}`
2236fn parse_failed_data(data: &Option<String>) -> (String, String) {
2237 let Some(json) = data else {
2238 return (String::new(), "unknown error".into());
2239 };
2240 let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
2241 let id = v["id"].as_str().unwrap_or_default().to_string();
2242 let error = v["error"].as_str().unwrap_or("unknown error").to_string();
2243 (id, error)
2244}