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}