Skip to main content

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}