justerm_core/event.rs
1//! Consumer event surface (#12): point-in-time notifications the engine
2//! accumulates while parsing, for the consumer to drain.
3//!
4//! Pull, not push — the engine queues events during `feed` and the consumer
5//! takes them with `drain_events`, mirroring the rest of the pull cadence
6//! (`damage` / `frame` / `reset_damage`). No callback is injected across the
7//! boundary, so the engine stays decoupled from the consumer's event loop
8//! (unlike alacritty's `EventListener`, whose push model would couple them).
9//!
10//! OSC 8 hyperlinks are deliberately absent — a hyperlink is per-cell state
11//! (which cells are links), not a point-in-time event, so it is modelled like
12//! graphemes in its own slice (#26), not here.
13
14use crate::serialize::{MarkerId, MarkerKind};
15
16/// Which selection an `OSC 52` clipboard request names (#828).
17///
18/// A *value*, never the protocol byte, so a consumer never parses the sequence —
19/// the same reason [`TermEvent::SetPaletteColor`] carries a `u8` index rather
20/// than the field it was written in.
21///
22/// **Three members, and `p` is kept apart from `s` deliberately.** The
23/// sequence's target field admits `c`, `p`, `q`, `s` and the eight cut buffers
24/// (`ctlseqs.txt:2156`); justerm models the three a consumer can act on and
25/// ignores the rest rather than folding them onto a neighbour, since mapping `q`
26/// onto a selection would be the engine inventing an equivalence the application
27/// did not ask for. ghostty folds every unrecognised kind onto the clipboard
28/// (`src/termio/stream_handler.zig:1013`); alacritty ignores them
29/// (`alacritty_terminal/src/term/mod.rs:1714`), and so does this. Read
30/// ghostty's from the `switch` and not from the comment four lines above it,
31/// which says *"we ignore the 'kind' field and always use the standard
32/// clipboard"* and is contradicted by the code under it — only the `else` arm
33/// goes to `.standard`.
34///
35/// **The first draft collapsed `p` and `s` into one member, and that was wrong
36/// on the wire.** alacritty collapses them
37/// (`alacritty_terminal/src/term/mod.rs:1713`) but replies with the byte the
38/// application sent (`:1744`), so the collapse never reaches a client. This
39/// engine hands the consumer a value and gets it back at `report_clipboard`, so
40/// a collapse here would answer `ESC ] 52 ; s ; ?` naming `p` — a selector the
41/// application never wrote, in the one field a client could pair a reply on. The
42/// spec lists the two separately (`ctlseqs.txt:2157`), xterm binds them to
43/// different atoms (`misc.c:3327`) and echoes the recognised list back
44/// (`misc.c:3384`), and ghostty keeps three locations apart in both directions
45/// (`src/Surface.zig:5954`, `src/terminal/c/terminal.zig:2942`). Splitting the
46/// member is what lets the value round-trip without the engine remembering
47/// anything.
48///
49/// **`#[non_exhaustive]` (#843).** The set is open by the paragraph above: `q` and
50/// the eight cut buffers are in the sequence and unmodelled here, so a later slice
51/// may name one. A consumer meeting a member it does not know can decline the
52/// request, which is already how it refuses any of them.
53///
54/// **ghostty reaches the same shape independently**, which #843 had recorded as
55/// impossible — its issue says Zig "has no such construct", and Zig does: a
56/// trailing `_` marks a non-exhaustive enum, used at 23 sites in that tree. The
57/// one that matters here is `src/terminal/clipboard.zig:2`, whose `Location` is
58/// `{ standard, selection, primary, _ }` — the same three members this type
59/// carries, and open for the same reason. Convergence on both the partition and
60/// the openness is the non-arbitrariness signal; it was invisible while the
61/// corpus was recorded as having no vote.
62#[non_exhaustive]
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum ClipboardTarget {
65 /// The system clipboard — the `c` field, **and the empty field**.
66 ///
67 /// The empty field is the common form in the wild rather than an edge case:
68 /// it is what `tmux` 3.2a was measured emitting for both an ordinary
69 /// copy-mode copy and `set-buffer -w` (#828). Reading it as "unrecognised"
70 /// would drop the only emission this project has observed.
71 Clipboard,
72 /// The primary selection — the `p` field. On a platform with no primary
73 /// selection a consumer may treat it as [`Clipboard`](Self::Clipboard); the
74 /// engine does not make that choice for it.
75 Primary,
76 /// The `s` field — *"the configurable primary/clipboard selection"*
77 /// (`ctlseqs.txt:2161`), which is to say: whichever of the two the user has
78 /// configured.
79 ///
80 /// **Relayed rather than resolved, and that is the boundary working.** The
81 /// thing that decides what `s` means is a setting: xterm resolves `SELECT`
82 /// through `DefaultSelection`, which is the `selectToClipboard` resource
83 /// (`button.c:2081`), and under ADR-0017 a setting is the consumer's. So the
84 /// application's choice is carried through unchanged and the consumer
85 /// resolves it against the configuration it owns.
86 ///
87 /// **A consumer with no such setting should treat this as
88 /// [`Primary`](Self::Primary), because that is what xterm-as-shipped does**
89 /// — `selectToClipboard` defaults to false. Worth stating rather than left
90 /// to taste, since the alternative reading sends the copy somewhere the
91 /// reference would not.
92 ///
93 /// And the setting is not purely out of reach: **DECSET 1041 sets the same
94 /// resource from the stream** (`ctlseqs.txt:1008`), so an engine that
95 /// tracked that mode could resolve `s` itself. justerm does not model 1041,
96 /// which is a *declined* capability rather than an impossible one — the
97 /// honest form of the claim, and the mode is unimplemented here like the
98 /// rest of the tail (#47).
99 Selection,
100}
101
102/// A consumer-facing event emitted while parsing the VT stream.
103///
104/// **`#[non_exhaustive]`, so a consumer must carry a `_` arm and a new variant
105/// never breaks one.** Decided 2026-09-02, by the maintainer, while #828 was
106/// adding two — and what decided it was neither this slice nor any consumer we
107/// can see.
108///
109/// **What decided it is `CLAUDE.md`'s own identity statement**: *"`justerm-core`
110/// is not penterm-only — it is a reusable, independent crate."* That sentence
111/// says there are consumers we cannot edit, which is precisely what this
112/// attribute defends; a crate whose identity were "internal, used by penterm"
113/// would want the opposite, because there a broken build is the compiler doing
114/// us a favour. So this follows from a call already made rather than from a
115/// preference, and it reverses only if that identity does.
116///
117/// Three measurements, so the next reader does not have to retake them:
118///
119/// - **crates.io reverse dependencies: zero** (the single row the API returns is
120/// this crate itself), across 248 downloads split over 11 versions — i.e. no
121/// external consumer exists *today*. That is why the identity statement had to
122/// decide it: there was nothing to observe.
123/// - **Cost in this workspace: zero.** `cargo test --workspace` (87 suites) and
124/// `clippy -D warnings` both stay green. A same-crate `match` may still be
125/// exhaustive, `justerm-wasm-decode` and `justerm-renderer` never name
126/// `TermEvent`, and `justerm-web`'s `events.ts` is a deliberately narrower
127/// union (title / bell / cwd).
128/// - **The window closes at `1.0.0`.** Adding this is free while the crate is
129/// `0.x` and is *itself* a breaking change afterwards, while an enum without
130/// it turns every future variant into a major bump. Conformance here is
131/// cumulative by design (#47 is a perpetual tail) and the two slices before
132/// this one added three variants and two, so that rate is measured rather than
133/// assumed.
134///
135/// **The argument that lost, recorded because it is a real cost.** An exhaustive
136/// match is a *feature* for a consumer: the compiler tells them a new event
137/// exists and makes them decide about it. penterm's `route_event` is the worked
138/// example — its `ColumnMode` and `ColorSchemeQuery` arms carry a comment
139/// explaining why each is dropped, written by someone the compiler had just
140/// informed. That signal is given up here, and it now has to come from release
141/// notes. It loses because this is a *notification* channel where ignoring an
142/// unknown event is documented as safe, so the guarantee belongs in prose rather
143/// than in the type — but a consumer who wanted the old behaviour is not
144/// imagining the loss.
145///
146/// (penterm was the evidence that an outside exhaustive matcher can exist — its
147/// five-variant `match` predates nine minor versions of this enum — and not the
148/// reason. It is being reimplemented, which is exactly why it could not be.)
149#[non_exhaustive]
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum TermEvent {
152 /// The window title is now this string.
153 ///
154 /// Read the tense carefully: since #823 this is **not** only "the
155 /// application set a title". Two paths emit it — `OSC 0`/`OSC 2`, and an
156 /// XTWINOPS title *pop* (`CSI 23 t`) restoring what an earlier `CSI 22 t`
157 /// saved. A consumer that treats it as "the title is now this" is correct
158 /// for both; one that treats it as "the application just chose this" is
159 /// wrong for the second, which is why there is no separate pop event.
160 ///
161 /// A pop can legitimately restore the **empty** string — every application
162 /// measured pushes at startup, before setting a title of its own — and that
163 /// means "go back to whatever you would show by default", not "show a blank
164 /// title".
165 Title(String),
166 /// The terminal bell rang (BEL, `0x07`).
167 Bell,
168 /// The working directory was reported (OSC 7), e.g. `file://host/path`.
169 Cwd(String),
170 /// The app requested 80/132-column mode (DECCOLM `?3`). justerm is
171 /// dimension-free, so this is a *request* — the consumer may honor it by
172 /// calling `resize(cols, rows)`, or ignore it. `cols` is 80 or 132 (#82).
173 ColumnMode { cols: usize },
174 /// The app queried the light/dark color scheme (DSR `CSI ? 996 n`). justerm
175 /// is theme-agnostic, so the consumer (which knows the scheme) answers by
176 /// calling `Engine::report_color_scheme` (#85).
177 ColorSchemeQuery,
178 /// The app set ANSI palette entry `index` to `spec` (OSC 4). One event per
179 /// `index ; spec` pair in the sequence. The cell still references
180 /// `Indexed(index)` — only the consumer's `palette[index]` changes, so the
181 /// engine stays theme-agnostic (#122).
182 SetPaletteColor { index: u8, spec: String },
183 /// The app set the default foreground colour (OSC 10). Raw spec, forwarded
184 /// for the consumer to apply — theme-agnostic, like [`SetBackground`](Self::SetBackground) (#122).
185 SetForeground(String),
186 /// The app set the default background colour (OSC 11). The engine is
187 /// theme-agnostic, so it forwards the raw spec string (`rgb:…`/`#…`) for the
188 /// consumer to parse and apply to its palette — it never holds hex (#122).
189 SetBackground(String),
190 /// The app reset palette entries to the theme default (OSC 104). `None` =
191 /// the whole table (no argument); `Some(index)` = one entry, one event per
192 /// index given. The consumer restores its palette (#122).
193 ResetPaletteColor(Option<u8>),
194 /// The app queried ANSI palette entry `index` (OSC 4 with `?` for that pair);
195 /// the consumer answers with `report_palette_color` (#122).
196 QueryPaletteColor { index: u8 },
197 /// The app set the cursor colour (OSC 12, #832). The third slot of the same
198 /// dynamic-colour sequence `SetForeground` and `SetBackground` ride, and
199 /// theme-agnostic for the same reason: the raw spec is forwarded and the
200 /// consumer — which owns the palette *and* the cursor's contrast guard —
201 /// applies it.
202 SetCursorColor(String),
203 /// The app queried the cursor colour (OSC 12 with `?`); the consumer answers
204 /// with `report_cursor_color` (#832).
205 QueryCursorColor,
206 /// The app reset the cursor colour to the theme default (OSC 112, #832). The
207 /// third member of the 110/111/112 reset family, and the one real
208 /// applications emit most: `nvim` sends it on startup, on every alt-screen
209 /// transition and on exit.
210 ResetCursorColor,
211 /// The app reset the default foreground to the theme default (OSC 110, #122).
212 ResetForeground,
213 /// The app reset the default background to the theme default (OSC 111, #122).
214 ResetBackground,
215 /// The app queried the default foreground colour (OSC 10 with `?`); the
216 /// consumer answers with `report_foreground` (#122).
217 QueryForeground,
218 /// The app queried the default background colour (OSC 11 with `?`). The
219 /// theme-agnostic engine relays it; the consumer answers with
220 /// `report_background` (#122), mirroring `ColorSchemeQuery`.
221 QueryBackground,
222 /// The app asked for `text` to be put on `target` (`OSC 52` with a payload,
223 /// #828). The engine has already base64-decoded it, and holds no clipboard
224 /// of its own.
225 ///
226 /// **This is a request, not a fact.** Whether the copy happens is the
227 /// consumer's: it owns the platform clipboard, any permission model and any
228 /// prompt, and a consumer that drops this event has refused the copy. The
229 /// engine carries no allow/deny knob, which is where it parts company with
230 /// alacritty — alacritty gates the same sequence behind a four-state config
231 /// (`alacritty_terminal/src/term/mod.rs:1706`) because alacritty *is* the
232 /// consumer. Under ADR-0017 that gate lives one layer out.
233 ///
234 /// **An empty `text` means "clear it".** `ESC ] 52 ; c ; ESC \` carries a
235 /// payload that decodes to nothing, and both the spec and xterm end that
236 /// exchange with an empty selection — the spec because `Pd` *"becomes the
237 /// new selection"* whatever it is (`ctlseqs.txt:2166`), xterm because it
238 /// clears the buffer before appending anything (`misc.c:3410`). Note this is
239 /// **not** the spec's *"neither a base64 string nor `?`"* clause at
240 /// `ctlseqs.txt:2174`, which the engine diverges from: an empty payload is a
241 /// perfectly well-formed encoding of no bytes, so it never reaches that
242 /// sentence. Citing `:2174` here, as an earlier draft did, would have the
243 /// same line standing as authority followed and as authority departed from.
244 /// ghostty pins the same input under a test named *"clear clipboard"*
245 /// (`src/terminal/osc/parsers/clipboard_operation.zig:93`). It needs no rule
246 /// of its own here, which is the argument for having none: an empty payload
247 /// *is* a well-formed encoding of no bytes, so it reaches the consumer
248 /// through the ordinary path.
249 ClipboardStore {
250 target: ClipboardTarget,
251 text: String,
252 },
253 /// The app asked what is on `target` (`OSC 52` with a `?` payload, #828).
254 /// The consumer answers by calling `report_clipboard`, which encodes the
255 /// reply — or declines, which is how a clipboard *read* is refused
256 /// independently of a write.
257 ///
258 /// The engine cannot answer this itself and deliberately holds nothing that
259 /// would let it: a query is answered from the consumer's clipboard or not at
260 /// all, so there is no engine state here for a hostile application to read
261 /// back. Same `Query…` + `report_…` shape as `OSC 4`/`10`/`11`/`12`.
262 QueryClipboard { target: ClipboardTarget },
263 /// A decoration marker's line left the buffer — evicted past the scrollback
264 /// cap, or scrolled out of an in-screen region (#118). The handle is now
265 /// dead; the consumer drops the decoration bound to it. This is the
266 /// frame-mode equivalent of xterm's `IMarker.onDispose` — disposal is a
267 /// point-in-time fact (a marker absent from a frame may merely be scrolled
268 /// off-screen), so it rides the event queue, not the frame overlay.
269 MarkerDisposed(MarkerId),
270 /// A marker was created (#490) — by `add_marker`, or by the *stream* through an
271 /// OSC 133 command mark, which the consumer never called for.
272 ///
273 /// The mirror of [`TermEvent::MarkerDisposed`], and it exists for the same reason
274 /// ADR-0020 R1 gives: an appearance is an occurrence, not state, so it rides this
275 /// queue rather than a frame field. Without it a consumer that pulled a marker
276 /// index (`Engine::marker_index`) has no way to learn of a marker born after its
277 /// pull — the population would only ever shrink.
278 ///
279 /// `line` is absolute at the moment of creation, and `evicted_total` / `epoch` are the
280 /// instant it is absolute at — the same triple [`crate::MarkerIndex`] carries, because
281 /// this event is that pull's incremental mirror. The consumer appends the entry with
282 /// the basis it arrived on and rebases it exactly like a pulled one.
283 ///
284 /// **The two are one fact and neither is usable alone (#737).** A single `feed` can
285 /// create a marker and then evict, so by the end of the batch the buffer's origin has
286 /// moved out from under the line this event already carries. `Frame::evicted_total` is
287 /// the basis at the *end* of that batch, so reading `line` against it misplaces the
288 /// marker by however much the batch evicted after the birth — measured at three lines,
289 /// with the event line, both frame bases, the epoch and `Frame::marker_count` all
290 /// identical to the batch that evicted *first* and needs no adjustment at all.
291 ///
292 /// **And a basis dates only a uniform move (#741).** Eviction shifts every marker by
293 /// the same amount, which is what one scalar can say; a reflow or a region rotate
294 /// moves them *individually*, which is what `epoch` is for. A birth still queued when
295 /// the epoch moves describes a buffer that no longer exists, and carrying only the
296 /// basis leaves that indistinguishable from a birth in the current generation —
297 /// measured, a mark at absolute 3 reflowed to 5 with the basis unmoved at 0.
298 ///
299 /// Deliberately not an epoch bump: a bump costs a whole re-pull, and creation is
300 /// `O(1)` information.
301 MarkerCreated {
302 id: MarkerId,
303 line: u32,
304 kind: MarkerKind,
305 /// Lines evicted since RIS at the moment of creation — the basis `line` is
306 /// absolute at. Carried rather than inferred so that placement does not depend on
307 /// whether the consumer drains this queue before or after it reads the frame,
308 /// which nothing in the API specifies.
309 ///
310 /// It is the same quantity `Frame::evicted_total` reports, so a consumer whose
311 /// transport crosses a language boundary owes it the same treatment: the wasm
312 /// frame getter hands its `u64` over as an `f64` deliberately (exact to 2^53),
313 /// because a `BigInt` on one side of a subtraction and a `number` on the other is
314 /// a `TypeError`, not a rounding question.
315 evicted_total: u64,
316 /// The marker generation this line belongs to — [`crate::MarkerIndex::epoch`] at
317 /// the moment of creation (#741). Two lines dated with different epochs are
318 /// answers about different buffers and nothing rebases one onto the other, so a
319 /// consumer adopts this entry only into the generation it names and lets the
320 /// re-pull that the bump already forces supply it otherwise.
321 ///
322 /// Compare it for **equality**, never for order: the counter is
323 /// `wrapping_add`, so `<` is meaningless across a wrap while `==` is exact.
324 epoch: u32,
325 },
326}