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 byte ended an OSC sequence — and therefore which one ends its reply.
17///
18/// The engine relays this rather than choosing: a query event carries the
19/// terminator the request arrived with, and the consumer hands it back to the
20/// matching `report_*`. Under [ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md) the parse-time fact is a *mechanism* only
21/// the engine can observe, while *which* terminator to send is policy — and a
22/// consumer cannot exercise a policy on a fact it was never given. That was
23/// measured, not assumed: `bell_terminated` was discarded at the parser boundary
24/// before any event was queued.
25///
26/// **The spec settles the direction, not just the reference tally.**
27/// `ctlseqs.txt:2020` — *"XTerm accepts either BEL or ST for terminating OSC
28/// sequences, and when returning information, uses the same terminator used in a
29/// query. While the latter is preferred, the former is supported for legacy
30/// applications."* Under [ADR-0004](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0004-spec-faithful-when-alacritty-omits.md) that outranks every implementation, this one
31/// included. **On the colour path** all three implementations that echo carry the
32/// terminator *outward with the request* rather than remembering it: alacritty
33/// binds it into the reply formatter it sends its consumer
34/// (`alacritty_terminal/src/term/mod.rs:1678`), ghostty makes it a field on the
35/// parsed command (`src/terminal/osc.zig:87`, written at
36/// `src/termio/stream_handler.zig:1497`), and xterm threads it as a parameter
37/// (`misc.c:3567`, emitted at `:3593`). xterm.js is the one that always sends ST
38/// (`CoreBrowserTerminal.ts:239`) — which is what this engine used to do.
39///
40/// **The colour qualifier is load-bearing, because `OSC 52` has a counterexample.**
41/// xterm *does* store the terminator for the clipboard: `int base64_final;` on the
42/// screen (`ptyx.h:2637`), written when the request is parsed (`misc.c:3389`) and
43/// read a file away when the paste finally arrives (`button.c:2218`), because its
44/// X selection retrieval is asynchronous. That is shape (b) — the one this engine
45/// weighed and did not take — on the exact sequence `report_clipboard` answers.
46///
47/// It is recorded here because it **strengthens** the choice rather than undoing
48/// it. xterm stores precisely because the reply is detached from the request, and
49/// its store is a *single scalar*, so two overlapping paste requests would collide
50/// exactly as one stored terminator does here. Detachment is the condition
51/// [`crate::Engine::drain_events`] creates for every family at once, not just one —
52/// so carrying answers it where storing only postpones it.
53///
54/// **Carrying beats remembering for a reason that is this channel's.**
55/// [`crate::Engine::drain_events`] hands over a batch, so a consumer can hold two
56/// colour queries at once and answer them in either order; one remembered scalar
57/// could not say which exchange it belonged to. An occurrence's payload is
58/// detached from its instant by the queue — [ADR-0029](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0029-a-published-coordinate-carries-its-instant-or-is-re-asked.md) D4 records the same shape
59/// for coordinates — so there is no re-ask and the fact must ride the event.
60///
61/// **Exhaustive on purpose ([#843](https://github.com/kihyun1998/justerm/issues/843)'s rule).** The space is closed at exactly two,
62/// with a date for each (`ctlseqs.txt:2024-2028`), so there is no member a later
63/// slice may name and nothing for `#[non_exhaustive]` to preserve.
64///
65/// ⚠ **The closure rests on the input space, not on the spec alone.** ECMA-48
66/// gives `ST` a third encoding — the 8-bit C1 `0x9C` — and it is absent here because
67/// [`crate::Engine::feed`] does not treat a lone `0x80..=0x9F` byte as a control at
68/// all, not because the spec stops at two. A reader who finds `0x9C` in `ctlseqs.txt`
69/// and concludes this enum is missing a member has the reasoning backwards: were that
70/// contract ever revisited, the member would follow, and it is the contract that is
71/// load-bearing.
72/// ghostty reaches the same shape independently — `src/terminal/osc.zig:252` is a
73/// two-member `{ st, bel }` with no trailing `_`, which is Zig's marker for an
74/// open enum and is used at 23 other sites in that tree. Convergence on both the
75/// partition and the closure is the non-arbitrariness signal.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
77pub enum Terminator {
78    /// `ESC \` (ST), the terminator ECMA-48 documents and xterm prefers.
79    ///
80    /// The default, and what an OSC ended by **any other byte that ends one**
81    /// resolves to. Those streams are real rather than theoretical: `vte` ends a
82    /// string on three byte classes — `BEL`, the cancel pair `CAN`/`SUB` (`0x18` /
83    /// `0x1a`), and a bare `ESC` opening the next sequence — and only the first is
84    /// reported as bell-terminated. So a cancelled query is still relayed, and
85    /// answered ST.
86    ///
87    /// **Read "any other byte that ends one" strictly: the 8-bit C1 `ST` (`0x9C`) is
88    /// not a fourth class.** It does not end the string, so there is no event
89    /// to carry a terminator and nothing resolves to this variant — the OSC stays
90    /// open instead. See [`crate::Engine::feed`] for why that is a contract.
91    ///
92    /// That is the right answer, not a fallback: xterm hardcodes ST on exactly this
93    /// shape (`charproc.c:8964`, *"should be ST"*) and ghostty's `Terminator.init`
94    /// returns `.st` for a missing byte (`src/terminal/osc.zig:263`).
95    #[default]
96    St,
97    /// `BEL` (`0x07`), supported for legacy applications.
98    ///
99    /// Real applications still emit it: `nvim` 0.8.0 asks `ESC ] 11 ; ? BEL` in
100    /// this crate's own `cursor_color_nvim.raw` fixture. And the shell idiom
101    /// `printf '\e]11;?\a'; read -d $'\a'` reads *until* BEL, so an ST answer to
102    /// a BEL question blocks that `read` until it times out.
103    Bel,
104}
105
106impl Terminator {
107    /// The bytes that end a reply carrying this terminator.
108    pub(crate) fn bytes(self) -> &'static [u8] {
109        match self {
110            Terminator::St => b"\x1b\\",
111            Terminator::Bel => b"\x07",
112        }
113    }
114}
115
116/// Which selection an `OSC 52` clipboard request names.
117///
118/// A *value*, never the protocol byte, so a consumer never parses the sequence —
119/// the same reason [`TermEvent::SetPaletteColor`] carries a `u8` index rather
120/// than the field it was written in.
121///
122/// **Three members, and `p` is kept apart from `s` deliberately.** The
123/// sequence's target field admits `c`, `p`, `q`, `s` and the eight cut buffers
124/// (`ctlseqs.txt:2156`); justerm models the three a consumer can act on and
125/// ignores the rest rather than folding them onto a neighbour, since mapping `q`
126/// onto a selection would be the engine inventing an equivalence the application
127/// did not ask for. ghostty folds every unrecognised kind onto the clipboard
128/// (`src/termio/stream_handler.zig:1013`); alacritty ignores them
129/// (`alacritty_terminal/src/term/mod.rs:1714`), and so does this. Read
130/// ghostty's from the `switch` and not from the comment four lines above it,
131/// which says *"we ignore the 'kind' field and always use the standard
132/// clipboard"* and is contradicted by the code under it — only the `else` arm
133/// goes to `.standard`.
134///
135/// **The first draft collapsed `p` and `s` into one member, and that was wrong
136/// on the wire.** alacritty collapses them
137/// (`alacritty_terminal/src/term/mod.rs:1713`) but replies with the byte the
138/// application sent (`:1744`), so the collapse never reaches a client. This
139/// engine hands the consumer a value and gets it back at `report_clipboard`, so
140/// a collapse here would answer `ESC ] 52 ; s ; ?` naming `p` — a selector the
141/// application never wrote, in the one field a client could pair a reply on. The
142/// spec lists the two separately (`ctlseqs.txt:2157`), xterm binds them to
143/// different atoms (`misc.c:3327`) and echoes the recognised list back
144/// (`misc.c:3384`), and ghostty keeps three locations apart in both directions
145/// (`src/Surface.zig:5954`, `src/terminal/c/terminal.zig:2942`). Splitting the
146/// member is what lets the value round-trip without the engine remembering
147/// anything.
148///
149/// **`#[non_exhaustive]` ([#843](https://github.com/kihyun1998/justerm/issues/843)).** The set is open by the paragraph above: `q` and
150/// the eight cut buffers are in the sequence and unmodelled here, so a later slice
151/// may name one. A consumer meeting a member it does not know can decline the
152/// request, which is already how it refuses any of them.
153///
154/// **ghostty reaches the same shape independently**, which [#843](https://github.com/kihyun1998/justerm/issues/843) had recorded as
155/// impossible — its issue says Zig "has no such construct", and Zig does: a
156/// trailing `_` marks a non-exhaustive enum, used at 23 sites in that tree. The
157/// one that matters here is `src/terminal/clipboard.zig:2`, whose `Location` is
158/// `{ standard, selection, primary, _ }` — the same three members this type
159/// carries, and open for the same reason. Convergence on both the partition and
160/// the openness is the non-arbitrariness signal; it was invisible while the
161/// corpus was recorded as having no vote.
162#[non_exhaustive]
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
164pub enum ClipboardTarget {
165    /// The system clipboard — the `c` field, **and the empty field**.
166    ///
167    /// The empty field is the common form in the wild rather than an edge case:
168    /// it is what `tmux` 3.2a was measured emitting for both an ordinary
169    /// copy-mode copy and `set-buffer -w`. Reading it as "unrecognised"
170    /// would drop the only emission this project has observed.
171    Clipboard,
172    /// The primary selection — the `p` field. On a platform with no primary
173    /// selection a consumer may treat it as [`Clipboard`](Self::Clipboard); the
174    /// engine does not make that choice for it.
175    Primary,
176    /// The `s` field — *"the configurable primary/clipboard selection"*
177    /// (`ctlseqs.txt:2161`), which is to say: whichever of the two the user has
178    /// configured.
179    ///
180    /// **Relayed rather than resolved, and that is the boundary working.** The
181    /// thing that decides what `s` means is a setting: xterm resolves `SELECT`
182    /// through `DefaultSelection`, which is the `selectToClipboard` resource
183    /// (`button.c:2081`), and under [ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md) a setting is the consumer's. So the
184    /// application's choice is carried through unchanged and the consumer
185    /// resolves it against the configuration it owns.
186    ///
187    /// **A consumer with no such setting should treat this as
188    /// [`Primary`](Self::Primary), because that is what xterm-as-shipped does**
189    /// — `selectToClipboard` defaults to false. Worth stating rather than left
190    /// to taste, since the alternative reading sends the copy somewhere the
191    /// reference would not.
192    ///
193    /// And the setting is not purely out of reach: **DECSET 1041 sets the same
194    /// resource from the stream** (`ctlseqs.txt:1008`), so an engine that
195    /// tracked that mode could resolve `s` itself. justerm does not model 1041,
196    /// which is a *declined* capability rather than an impossible one — the
197    /// honest form of the claim, and the mode is unimplemented here like the
198    /// rest of the tail.
199    Selection,
200}
201
202/// A consumer-facing event emitted while parsing the VT stream.
203///
204/// **`#[non_exhaustive]`, so a consumer must carry a `_` arm and a new variant
205/// never breaks one.** Decided 2026-09-02, by the maintainer, while a slice was
206/// adding two variants — and what decided it was neither that slice nor any
207/// consumer we can see.
208///
209/// **What decided it is [`CLAUDE.md`](https://github.com/kihyun1998/justerm/blob/master/CLAUDE.md)'s own identity statement**: *"`justerm-core`
210/// is not penterm-only — it is a reusable, independent crate."* That sentence
211/// says there are consumers we cannot edit, which is precisely what this
212/// attribute defends; a crate whose identity were "internal, used by penterm"
213/// would want the opposite, because there a broken build is the compiler doing
214/// us a favour. So this follows from a call already made rather than from a
215/// preference, and it reverses only if that identity does.
216///
217/// Three measurements, so the next reader does not have to retake them:
218///
219/// - **crates.io reverse dependencies: zero** (the single row the API returns is
220///   this crate itself), across 248 downloads split over 11 versions — i.e. no
221///   external consumer exists *today*. That is why the identity statement had to
222///   decide it: there was nothing to observe.
223/// - **Cost in this workspace: zero.** `cargo test --workspace` (87 suites) and
224///   `clippy -D warnings` both stay green. A same-crate `match` may still be
225///   exhaustive, `justerm-wasm-decode` and `justerm-renderer` never name
226///   `TermEvent`, and `justerm-web`'s `events.ts` mirrors this union by hand
227///   rather than deriving it. **That mirror was narrower than this enum when the
228///   measurement was taken and no longer is in the same way:** it now
229///   carries the `OSC 52` pair as well, and what stayed at title/bell/cwd is its
230///   `EventHandlers` — the *notification* surface, not the channel. The cost
231///   measured here is unaffected, since a hand-written mirror never had a
232///   compiler relationship to this enum to break.
233/// - **The window closes at `1.0.0`.** Adding this is free while the crate is
234///   `0.x` and is *itself* a breaking change afterwards, while an enum without
235///   it turns every future variant into a major bump. Conformance here is
236///   cumulative by design — the VT tail is perpetual — and the two slices before
237///   this one added three variants and two, so that rate is measured rather than
238///   assumed.
239///
240/// **The argument that lost, recorded because it is a real cost.** An exhaustive
241/// match is a *feature* for a consumer: the compiler tells them a new event
242/// exists and makes them decide about it. penterm's `route_event` is the worked
243/// example — its `ColumnMode` and `ColorSchemeQuery` arms carry a comment
244/// explaining why each is dropped, written by someone the compiler had just
245/// informed. That signal is given up here, and it now has to come from release
246/// notes. It loses because this is a *notification* channel where ignoring an
247/// unknown event is documented as safe, so the guarantee belongs in prose rather
248/// than in the type — but a consumer who wanted the old behaviour is not
249/// imagining the loss.
250///
251/// (penterm was the evidence that an outside exhaustive matcher can exist — its
252/// five-variant `match` predates nine minor versions of this enum — and not the
253/// reason. It is being reimplemented, which is exactly why it could not be.)
254#[non_exhaustive]
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum TermEvent {
257    /// The window title is now this string.
258    ///
259    /// Read the tense carefully: this is **not** only "the
260    /// application set a title". Two paths emit it — `OSC 0`/`OSC 2`, and an
261    /// XTWINOPS title *pop* (`CSI 23 t`) restoring what an earlier `CSI 22 t`
262    /// saved. A consumer that treats it as "the title is now this" is correct
263    /// for both; one that treats it as "the application just chose this" is
264    /// wrong for the second, which is why there is no separate pop event.
265    ///
266    /// A pop can legitimately restore the **empty** string — every application
267    /// measured pushes at startup, before setting a title of its own — and that
268    /// means "go back to whatever you would show by default", not "show a blank
269    /// title".
270    ///
271    /// A title containing 15 or more `;` arrives cut short, because the parser this
272    /// engine builds on passes at most 16 OSC fields; the shorter title is not marked.
273    Title(String),
274    /// The terminal bell rang (BEL, `0x07`).
275    Bell,
276    /// The working directory was reported (OSC 7), e.g. `file://host/path`.
277    ///
278    /// Passed as declared, except that a value containing 15 or more unencoded `;`
279    /// arrives cut short (the same 16-field parser bound as [`TermEvent::Title`]).
280    /// An emitter that percent-encodes `;` never reaches it.
281    Cwd(String),
282    /// The app requested 80/132-column mode (DECCOLM `?3`). justerm is
283    /// dimension-free, so this is a *request* — the consumer may honor it by
284    /// calling `resize(cols, rows)`, or ignore it. `cols` is 80 or 132.
285    ColumnMode { cols: usize },
286    /// The app queried the light/dark color scheme (DSR `CSI ? 996 n`). justerm
287    /// is theme-agnostic, so the consumer (which knows the scheme) answers by
288    /// calling `Engine::report_color_scheme`.
289    ColorSchemeQuery,
290    /// The app set ANSI palette entry `index` to `spec` (OSC 4). One event per
291    /// `index ; spec` pair the engine accepts — a pair whose index does not parse
292    /// as a `u8`, or whose spec is `?` (a query) or **empty**, produces
293    /// none, and the pairs around it are unaffected either way. The cell still
294    /// references `Indexed(index)` — only the consumer's `palette[index]` changes,
295    /// so the engine stays theme-agnostic.
296    ///
297    /// **`spec` is never empty**, so a consumer's colour parser is never handed a
298    /// blank string. It is otherwise verbatim and unvalidated: the engine holds no
299    /// palette and parses no colour, so `spec` may still be whitespace or
300    /// nonsense, and interpreting it is the consumer's ([ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md)).
301    SetPaletteColor { index: u8, spec: String },
302    /// The app set the default foreground colour (OSC 10). Raw spec, forwarded
303    /// for the consumer to apply — theme-agnostic, like [`SetBackground`](Self::SetBackground).
304    SetForeground(String),
305    /// The app set the default background colour (OSC 11). The engine is
306    /// theme-agnostic, so it forwards the raw spec string (`rgb:…`/`#…`) for the
307    /// consumer to parse and apply to its palette — it never holds hex.
308    SetBackground(String),
309    /// The app reset palette entries to the theme default (OSC 104). `None` =
310    /// the whole table (no argument); `Some(index)` = one entry, one event per
311    /// index given. The consumer restores its palette.
312    ResetPaletteColor(Option<u8>),
313    /// The app queried ANSI palette entry `index` (OSC 4 with `?` for that pair);
314    /// the consumer answers with `report_palette_color`.
315    QueryPaletteColor {
316        index: u8,
317        /// The terminator `report_palette_color` must answer with.
318        terminator: Terminator,
319    },
320    /// The app set the cursor colour (OSC 12). The third slot of the same
321    /// dynamic-colour sequence `SetForeground` and `SetBackground` ride, and
322    /// theme-agnostic for the same reason: the raw spec is forwarded and the
323    /// consumer — which owns the palette *and* the cursor's contrast guard —
324    /// applies it.
325    SetCursorColor(String),
326    /// The app queried the cursor colour (OSC 12 with `?`); the consumer answers
327    /// with `report_cursor_color`.
328    QueryCursorColor {
329        /// The terminator `report_cursor_color` must answer with.
330        terminator: Terminator,
331    },
332    /// The app reset the cursor colour to the theme default (OSC 112). The
333    /// third member of the 110/111/112 reset family, and the one real
334    /// applications emit most: `nvim` sends it on startup, on every alt-screen
335    /// transition and on exit.
336    ResetCursorColor,
337    /// The app reset the default foreground to the theme default (OSC 110).
338    ResetForeground,
339    /// The app reset the default background to the theme default (OSC 111).
340    ResetBackground,
341    /// The app queried the default foreground colour (OSC 10 with `?`); the
342    /// consumer answers with `report_foreground`.
343    QueryForeground {
344        /// The terminator `report_foreground` must answer with.
345        terminator: Terminator,
346    },
347    /// The app queried the default background colour (OSC 11 with `?`). The
348    /// theme-agnostic engine relays it; the consumer answers with
349    /// `report_background`, mirroring `ColorSchemeQuery`.
350    QueryBackground {
351        /// The terminator `report_background` must answer with.
352        terminator: Terminator,
353    },
354    /// The app asked for `text` to be put on `target` (`OSC 52` with a payload).
355    /// The engine has already base64-decoded it, and holds no clipboard
356    /// of its own.
357    ///
358    /// **This is a request, not a fact.** Whether the copy happens is the
359    /// consumer's: it owns the platform clipboard, any permission model and any
360    /// prompt, and a consumer that drops this event has refused the copy. The
361    /// engine carries no allow/deny knob, which is where it parts company with
362    /// alacritty — alacritty gates the same sequence behind a four-state config
363    /// (`alacritty_terminal/src/term/mod.rs:1706`, `:1727`). Under [ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md) that
364    /// gate lives one layer out.
365    ///
366    /// **The reason once given for that was measurably wrong, and is corrected
367    /// here rather than quietly dropped (re-read 2026-09-10).** This said
368    /// *"because alacritty **is** the consumer"*. It is not the distinction:
369    /// alacritty's gate sits inside `alacritty_terminal`, the **engine** crate,
370    /// with the policy *injected across the crate boundary* — `Osc52` is a field
371    /// on that crate's `Config` (`:353`), written by the application at
372    /// `alacritty/src/config/ui_config.rs:125`. That is [ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md)'s own shape, so
373    /// it was never a reason this crate *could not* hold an injected gate. The
374    /// conclusion stands on the ADR alone, and on the fact that an engine which
375    /// touches no clipboard buys nothing by putting a gate in front of a relay.
376    ///
377    /// **An empty `text` means "clear it".** `ESC ] 52 ; c ; ESC \` carries a
378    /// payload that decodes to nothing, and both the spec and xterm end that
379    /// exchange with an empty selection — the spec because `Pd` *"becomes the
380    /// new selection"* whatever it is (`ctlseqs.txt:2166`), xterm because it
381    /// clears the buffer before appending anything (`misc.c:3410`). Note this is
382    /// **not** the spec's *"neither a base64 string nor `?`"* clause at
383    /// `ctlseqs.txt:2174`, which the engine diverges from: an empty payload is a
384    /// perfectly well-formed encoding of no bytes, so it never reaches that
385    /// sentence. Citing `:2174` here, as an earlier draft did, would have the
386    /// same line standing as authority followed and as authority departed from.
387    /// ghostty pins the same input under a test named *"clear clipboard"*
388    /// (`src/terminal/osc/parsers/clipboard_operation.zig:93`). It needs no rule
389    /// of its own here, which is the argument for having none: an empty payload
390    /// *is* a well-formed encoding of no bytes, so it reaches the consumer
391    /// through the ordinary path.
392    ClipboardStore {
393        target: ClipboardTarget,
394        text: String,
395    },
396    /// The app asked what is on `target` (`OSC 52` with a `?` payload).
397    /// The consumer answers by calling `report_clipboard`, which encodes the
398    /// reply — or declines, which is how a clipboard *read* is refused
399    /// independently of a write.
400    ///
401    /// The engine cannot answer this itself and deliberately holds nothing that
402    /// would let it: a query is answered from the consumer's clipboard or not at
403    /// all, so there is no engine state here for a hostile application to read
404    /// back. Same `Query…` + `report_…` shape as `OSC 4`/`10`/`11`/`12`.
405    QueryClipboard {
406        target: ClipboardTarget,
407        /// The terminator `report_clipboard` must answer with.
408        terminator: Terminator,
409    },
410    /// A decoration marker's line left the buffer — evicted past the scrollback
411    /// cap, or scrolled out of an in-screen region. The handle is now
412    /// dead; the consumer drops the decoration bound to it. This is the
413    /// frame-mode equivalent of xterm's `IMarker.onDispose` — disposal is a
414    /// point-in-time fact (a marker absent from a frame may merely be scrolled
415    /// off-screen), so it rides the event queue, not the frame overlay.
416    MarkerDisposed(MarkerId),
417    /// A marker was created — by `add_marker`, or by the *stream* through an
418    /// OSC 133 command mark, which the consumer never called for.
419    ///
420    /// The mirror of [`TermEvent::MarkerDisposed`], and it exists for the same reason
421    /// [ADR-0020](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0020-what-qualifies-for-the-frame-snapshot.md) R1 gives: an appearance is an occurrence, not state, so it rides this
422    /// queue rather than a frame field. Without it a consumer that pulled a marker
423    /// index (`Engine::marker_index`) has no way to learn of a marker born after its
424    /// pull — the population would only ever shrink.
425    ///
426    /// `line` is absolute at the moment of creation, and `evicted_total` / `epoch` are the
427    /// instant it is absolute at — the same triple [`crate::MarkerIndex`] carries, because
428    /// this event is that pull's incremental mirror. The consumer appends the entry with
429    /// the basis it arrived on and rebases it exactly like a pulled one.
430    ///
431    /// **The two are one fact and neither is usable alone.** A single `feed` can
432    /// create a marker and then evict, so by the end of the batch the buffer's origin has
433    /// moved out from under the line this event already carries. `Frame::evicted_total` is
434    /// the basis at the *end* of that batch, so reading `line` against it misplaces the
435    /// marker by however much the batch evicted after the birth — measured at three lines,
436    /// with the event line, both frame bases, the epoch and `Frame::marker_count` all
437    /// identical to the batch that evicted *first* and needs no adjustment at all.
438    ///
439    /// **And a basis dates only a uniform move.** Eviction shifts every marker by
440    /// the same amount, which is what one scalar can say; a reflow or a region rotate
441    /// moves them *individually*, which is what `epoch` is for. A birth still queued when
442    /// the epoch moves describes a buffer that no longer exists, and carrying only the
443    /// basis leaves that indistinguishable from a birth in the current generation —
444    /// measured, a mark at absolute 3 reflowed to 5 with the basis unmoved at 0.
445    ///
446    /// Deliberately not an epoch bump: a bump costs a whole re-pull, and creation is
447    /// `O(1)` information.
448    MarkerCreated {
449        id: MarkerId,
450        line: u32,
451        kind: MarkerKind,
452        /// Lines evicted since RIS at the moment of creation — the basis `line` is
453        /// absolute at. Carried rather than inferred so that placement does not depend on
454        /// whether the consumer drains this queue before or after it reads the frame,
455        /// which nothing in the API specifies.
456        ///
457        /// It is the same quantity `Frame::evicted_total` reports, so a consumer whose
458        /// transport crosses a language boundary owes it the same treatment: the wasm
459        /// frame getter hands its `u64` over as an `f64` deliberately (exact to 2^53),
460        /// because a `BigInt` on one side of a subtraction and a `number` on the other is
461        /// a `TypeError`, not a rounding question.
462        evicted_total: u64,
463        /// The marker generation this line belongs to — [`crate::MarkerIndex::epoch`] at
464        /// the moment of creation. Two lines dated with different epochs are
465        /// answers about different buffers and nothing rebases one onto the other, so a
466        /// consumer adopts this entry only into the generation it names and lets the
467        /// re-pull that the bump already forces supply it otherwise.
468        ///
469        /// Compare it for **equality**, never for order: the counter is
470        /// `wrapping_add`, so `<` is meaningless across a wrap while `==` is exact.
471        epoch: u32,
472    },
473}