Skip to main content

text_document/
events.rs

1//! Document event types and subscription handle.
2
3use std::sync::Arc;
4use std::sync::atomic::AtomicBool;
5
6use crate::inner::{CallbackEntry, TextDocumentInner};
7
8/// Events emitted by a [`TextDocument`](crate::TextDocument).
9///
10/// Subscribe via [`TextDocument::on_change`](crate::TextDocument::on_change) (callback-based)
11/// or poll via [`TextDocument::poll_events`](crate::TextDocument::poll_events) (frame-loop).
12///
13/// These events carry enough information for a UI to do incremental updates —
14/// repaint only the affected region, not the entire document.
15/// Which channel some text arrived through.
16///
17/// A **fact about how the characters reached the document**, and nothing more.
18/// It says who typed, pasted or dictated nothing at all: an application can only
19/// report the channel it was called through, and the inference from a channel to
20/// an author is not one any of this can make.
21///
22/// ## Why the default is `Unspecified` and not `Programmatic`
23///
24/// Every insertion method has a plain form and a `_with_origin` sibling. The
25/// plain form reports [`Unspecified`](Self::Unspecified), which means *the
26/// caller did not say* — deliberately **not** [`Programmatic`](Self::Programmatic),
27/// which would assert that the application inserted the text itself. Those are
28/// different claims, and a consumer counting them apart is entitled to know
29/// which one it has.
30///
31/// The same distinction as an absent field versus a zero: an unspecified origin
32/// is missing information, and a wrong one is a wrong fact.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
34pub enum InsertionOrigin {
35    /// The caller did not say. The default for every plain insertion method.
36    #[default]
37    Unspecified,
38    /// Entered a key at a time.
39    Typed,
40    /// Committed by an input method — the multi-keystroke path that produces
41    /// one character. Kept apart from [`Typed`](Self::Typed) because a consumer
42    /// counting keystrokes and one counting characters disagree here, and both
43    /// are right.
44    Composed,
45    /// Pasted from a clipboard.
46    Pasted,
47    /// Dropped in from elsewhere.
48    Dropped,
49    /// Brought in by a document import.
50    Imported,
51    /// Re-applied by undo or redo.
52    ///
53    /// Hard-coded at those two paths, which never re-enter the insertion API —
54    /// they snapshot and diff instead — so a replayed edit can never be counted
55    /// twice under its original origin.
56    Replayed,
57    /// Inserted by the application: a template, an expansion, a substitution.
58    Programmatic,
59    /// Arrived through an accessibility channel: dictation, a braille display.
60    ///
61    /// **Never folded into [`Typed`](Self::Typed).** Some people write this way,
62    /// and a record that erased the distinction would be reporting them as
63    /// something they are not.
64    Assistive,
65}
66
67impl InsertionOrigin {
68    /// A stable lower-case token, for anything that has to write one down.
69    ///
70    /// Spelled out rather than derived from the variant name, so renaming a
71    /// variant cannot silently change what a consumer persisted.
72    pub fn token(self) -> &'static str {
73        match self {
74            InsertionOrigin::Unspecified => "unspecified",
75            InsertionOrigin::Typed => "typed",
76            InsertionOrigin::Composed => "composed",
77            InsertionOrigin::Pasted => "pasted",
78            InsertionOrigin::Dropped => "dropped",
79            InsertionOrigin::Imported => "imported",
80            InsertionOrigin::Replayed => "replayed",
81            InsertionOrigin::Programmatic => "programmatic",
82            InsertionOrigin::Assistive => "assistive",
83        }
84    }
85
86    /// Every variant, for a consumer building a table over them.
87    pub const ALL: [InsertionOrigin; 9] = [
88        InsertionOrigin::Unspecified,
89        InsertionOrigin::Typed,
90        InsertionOrigin::Composed,
91        InsertionOrigin::Pasted,
92        InsertionOrigin::Dropped,
93        InsertionOrigin::Imported,
94        InsertionOrigin::Replayed,
95        InsertionOrigin::Programmatic,
96        InsertionOrigin::Assistive,
97    ];
98}
99
100#[derive(Debug, Clone, PartialEq)]
101pub enum DocumentEvent {
102    /// Text content changed at a specific region.
103    ///
104    /// Emitted by every edit that changes what the document contains: the
105    /// text-level ones (`insert_text`, `delete_char`, `delete_previous_char`,
106    /// `remove_selected_text`, `insert_formatted_text`, `insert_block`,
107    /// `insert_html`, `insert_markdown`, `insert_fragment`, `insert_image`), the
108    /// streaming appends, `undo` and `redo`, and every **structural table edit**
109    /// (`insert_table_row`, `insert_table_column`, `remove_table_row`,
110    /// `remove_table_column`, `merge_table_cells`, `split_table_cell`,
111    /// `remove_table`, and the cursor-relative wrappers over them).
112    ///
113    /// ⚠ The list above was wrong in both directions for a long time, and the
114    /// half that mattered was the table edits: they emitted nothing at all, so a
115    /// consumer holding offsets kept them across a row insert and a consumer
116    /// caching on [`TextDocument::content_revision`] never reheated. Nothing
117    /// errored and nothing looked wrong.
118    ///
119    /// ## Two things a consumer should know about the figures
120    ///
121    /// `chars_added` and `chars_removed` are a **net delta for the affected
122    /// region**, not "characters this edit introduced": replacing a selection
123    /// reports both, and a caller wanting to know how much text an edit brought
124    /// in cannot get it from here.
125    ///
126    /// For `undo`, `redo` and the table edits the delta is computed as a diff
127    /// over blocks joined by newlines, which is not the same string
128    /// [`TextDocument::to_plain_text`] renders when the document contains a
129    /// table. Consumers that shift offsets by these figures are consistent with
130    /// each other; a consumer reconciling them against `to_plain_text` is not.
131    ///
132    /// [`TextDocument::content_revision`]: crate::TextDocument::content_revision
133    /// [`TextDocument::to_plain_text`]: crate::TextDocument::to_plain_text
134    ContentsChanged {
135        position: usize,
136        chars_removed: usize,
137        chars_added: usize,
138        blocks_affected: usize,
139    },
140
141    /// Text arrived, and this is where it came from.
142    ///
143    /// Emitted **alongside** [`ContentsChanged`](Self::ContentsChanged), never
144    /// instead of it, and only when an insertion actually added characters.
145    ///
146    /// ## Why this is not a field on `ContentsChanged`
147    ///
148    /// Because `ContentsChanged` carries the wrong number for the question.
149    /// Its `chars_added` is a **net delta for the affected region**: replacing a
150    /// twelve-character selection with a four-character paste reports both a
151    /// removal and an addition, and neither figure is "how much text this paste
152    /// brought in". Attaching an origin to a net delta would produce an
153    /// attribution that looks precise and is not.
154    ///
155    /// `chars_inserted` here is the other number: **what this insertion
156    /// introduced**, which is the one a consumer attributing text to a channel
157    /// actually wants.
158    ///
159    /// Keeping it a separate event is also what makes it additive — every
160    /// existing consumer of `ContentsChanged` is untouched, and one that does
161    /// not care about origins never has to mention this.
162    TextInserted {
163        position: usize,
164        /// How many characters this insertion introduced. Never a net delta.
165        chars_inserted: usize,
166        origin: InsertionOrigin,
167    },
168
169    /// Formatting changed without text content change.
170    FormatChanged {
171        position: usize,
172        length: usize,
173        /// Distinguishes block-level changes (relayout needed) from
174        /// character-level changes (reshaping only).
175        kind: crate::flow::FormatChangeKind,
176    },
177
178    /// Only paint-level highlight attributes changed (colors, underline
179    /// decorations) on a paint-only highlighter. The shaping input
180    /// (`fragments`) is unchanged, so the layout engine can recolor the
181    /// cached layout without reshaping or reflowing.
182    ///
183    /// `position` / `length` are document-absolute character offsets bounding
184    /// the extent that changed, so a view may recolor just the blocks they
185    /// cover rather than re-snapshotting the whole document.
186    ///
187    /// **A `length` of `0` means "unknown — assume the whole document"**, and
188    /// is what the genuinely document-wide operations send: installing or
189    /// retiring a highlighter, and a full rehighlight. `set_session_ranges`
190    /// knows its own before/after ranges and reports their union exactly.
191    /// A receiver that does not care may keep treating every one of these as
192    /// whole-document; that is the safe reading of both cases.
193    HighlightPaintChanged { position: usize, length: usize },
194
195    /// Block count changed. Carries the new count.
196    BlockCountChanged(usize),
197
198    /// Flow elements were inserted at the given index in the main
199    /// frame's `child_order`.
200    ///
201    /// This is a performance optimization — the layout engine can
202    /// update incrementally instead of re-querying
203    /// [`TextDocument::flow()`](crate::TextDocument::flow).
204    FlowElementsInserted { flow_index: usize, count: usize },
205
206    /// Flow elements were removed starting at the given index in the
207    /// main frame's `child_order`.
208    FlowElementsRemoved { flow_index: usize, count: usize },
209
210    /// The document was completely replaced (import, clear).
211    DocumentReset,
212
213    /// Undo/redo was performed or availability changed.
214    UndoRedoChanged { can_undo: bool, can_redo: bool },
215
216    /// The modified flag changed.
217    ModificationChanged(bool),
218
219    /// A long operation progressed.
220    LongOperationProgress {
221        operation_id: String,
222        percent: f64,
223        message: String,
224    },
225
226    /// A long operation completed or failed.
227    LongOperationFinished {
228        operation_id: String,
229        success: bool,
230        error: Option<String>,
231    },
232}
233
234/// Handle to a document event subscription.
235///
236/// Events are delivered as long as this handle is alive.
237/// Drop it to unsubscribe. No explicit unsubscribe method needed.
238pub struct Subscription {
239    alive: Arc<AtomicBool>,
240}
241
242impl Drop for Subscription {
243    fn drop(&mut self) {
244        self.alive
245            .store(false, std::sync::atomic::Ordering::Relaxed);
246    }
247}
248
249/// Register a callback with the document inner, returning a Subscription handle.
250pub(crate) fn subscribe_inner<F>(inner: &mut TextDocumentInner, callback: F) -> Subscription
251where
252    F: Fn(DocumentEvent) + Send + Sync + 'static,
253{
254    let alive = Arc::new(AtomicBool::new(true));
255    inner.callbacks.push(CallbackEntry {
256        alive: Arc::downgrade(&alive),
257        callback: Arc::new(callback),
258    });
259    Subscription { alive }
260}