summaryrefslogtreecommitdiff
path: root/src/tui_event.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_event.zig')
-rw-r--r--src/tui_event.zig577
1 files changed, 577 insertions, 0 deletions
diff --git a/src/tui_event.zig b/src/tui_event.zig
new file mode 100644
index 0000000..70d2fca
--- /dev/null
+++ b/src/tui_event.zig
@@ -0,0 +1,577 @@
+//! The extension UI event system (plan §7): ONE string-keyed mechanism for all
+//! extension UI.
+//!
+//! ## The model (§7)
+//!
+//! There is exactly one way for a component to get on screen: pick an event
+//! string, register a handler that sets a component for it, then emit the event
+//! at the component's creation boundary. Built-in events (`session_start`,
+//! `user_message`, `thinking`, `assistant_text`, `tool`, `compaction`) are just
+//! event strings panto emits itself; extension events are mechanically
+//! identical. There is no separate `addComponent` API — additions are always
+//! tied to an event firing.
+//!
+//! A handler receives an `*Event` carrying:
+//! - the event NAME,
+//! - the CURRENT chosen `Component` (the built-in default at first, or
+//! whatever a prior handler set) via `getComponent()` / `setComponent()`,
+//! - structured per-event DATA (e.g. `tool_name`, `args`) via `payload`.
+//!
+//! ### Precedence (§7.3)
+//!
+//! Handlers run in REGISTRATION ORDER. Precedence is last-`setComponent`-wins
+//! ("last-wins-blind"): the final component set is used. There is no merge. The
+//! documented, expected pattern is to WRAP — read the current component, deco-
+//! rate/replace it, set it back:
+//!
+//! bus.on("tool", myHandler); // myHandler: get -> wrap -> set
+//!
+//! A handler that clobbers without reading the current component is at fault,
+//! not the framework.
+//!
+//! ### Streaming lifecycle & mid-stream swaps (§7.4, revised)
+//!
+//! The original §7.4 said an event fires ONCE at creation, before first paint.
+//! That was a simplification. The streaming block types now emit a UNIFORM
+//! LIFECYCLE of events, and `setComponent` works at ANY of them — not just the
+//! creation boundary:
+//!
+//! - thinking: `thinking` -> `thinking_delta`* -> `thinking_complete`
+//! - assistant text: `assistant_text` -> `assistant_text_delta`* ->
+//! `assistant_text_complete`
+//! - tool: `tool` (name UNKNOWN, component shows `tool (?)`) ->
+//! `tool_details` (name resolved) -> `tool_delta`* (args
+//! JSON streaming) -> `tool_call_complete` (full args) ->
+//! `tool_result` (the atomic result block lands)
+//! - user/session/compaction: fire once (no streaming).
+//!
+//! (`*` = fires per streaming chunk.) `tool_call_complete` is the end of the
+//! tool CALL, NOT the end of all `tool_*` events: the result arrives afterward
+//! as `tool_result` (tool results are atomic, delivered out-of-band).
+//!
+//! A handler may `setComponent` at any of these. When it sets a component that
+//! differs from the slot's current one, the call site SWAPS it in mid-stream
+//! (see the app's `fireForEntry`): the new component takes over the rendered
+//! region (full repaint from line 0, orphaned lines from a taller predecessor
+//! cleared) while panto KEEPS DRIVING the structured deltas into the slot's
+//! typed default box. The documented wrap pattern (`getComponent` -> wrap ->
+//! `setComponent`) makes this transparent: the wrapper forwards drive calls to
+//! the inner default box and renders through it.
+//!
+//! Why per-chunk delta events at all (they fire alongside an existing render):
+//! the chosen component already re-renders on every delta, so a per-delta
+//! handler hook is marginal cost on top of work panto already does — and Lua
+//! (the extension language) was chosen for exactly that efficiency. The delta
+//! events fire at the SAME boundary the component re-renders; they add no new
+//! render cadence.
+//!
+//! ### No "active component" (§6)
+//!
+//! Each streamable event yields its OWN component instance, keyed by
+//! call-id/block-index at the call site. The bus itself holds no per-event
+//! component state across emits: every `emit` is seeded with that boundary's
+//! own default and returns that boundary's own chosen component. Parallel tool
+//! calls each get their own.
+//!
+//! ## Bridge friendliness (§7.6)
+//!
+//! Dispatch is a vtable of function pointers over `*anyopaque`, matching the
+//! `Component` vtable in `tui_component.zig`. A Lua-backed (or future C-ABI)
+//! handler implements the same `Handler` callback shape; a Lua-defined
+//! component implements the same `Component` vtable across the bridge. Nothing
+//! here knows or cares whether a handler/component is native or bridged. The
+//! Lua side is implemented in a LATER sub-phase; this module is Zig-only and
+//! must not depend on the Lua machinery.
+
+const std = @import("std");
+const component = @import("tui_component.zig");
+
+const Component = component.Component;
+
+// ===========================================================================
+// Handler
+// ===========================================================================
+
+/// A registered event handler. Vtable-style: a `callback` function pointer over
+/// an opaque `ctx`, so a native closure, a Lua-backed handler, or a future
+/// C-ABI handler all plug into the same shape (§7.6).
+///
+/// The callback receives the live `*Event`; it inspects `payload`, reads the
+/// current component with `event.getComponent()`, and optionally replaces it
+/// with `event.setComponent()`. Its return is void — the chosen component is
+/// communicated through the event, not the return value (so the wrap pattern is
+/// natural and precedence is last-wins).
+pub const Handler = struct {
+ ctx: *anyopaque,
+ callback: *const fn (ctx: *anyopaque, event: *Event) void,
+
+ pub fn call(self: Handler, event: *Event) void {
+ self.callback(self.ctx, event);
+ }
+};
+
+// ===========================================================================
+// Payload — structured per-event data (§7.2)
+// ===========================================================================
+
+/// Structured data carried by an event, surfaced to handlers as typed fields
+/// (the §7.2 `event.tool_name`, `event.args`, … shape). A tagged union keeps
+/// the per-event fields explicit and bridge-friendly (the Lua bridge maps each
+/// variant's fields onto the `event` object's properties).
+///
+/// New built-in event types add a variant here; extension-defined events use
+/// `.custom` with an opaque pointer the emitter and handler agree on. Borrowed
+/// slices are valid only for the duration of the `emit` call (handlers must
+/// copy anything they retain), mirroring the streaming-event borrow contract
+/// elsewhere in panto.
+pub const Payload = union(enum) {
+ /// `session_start`: the welcome/banner boundary.
+ session_start: SessionStart,
+ /// `user_message`: a submitted user message.
+ user_message: UserMessage,
+ /// `thinking` / `thinking_delta` / `thinking_complete`: a streaming
+ /// thinking block's lifecycle. The shared `Thinking` payload carries the
+ /// block index plus the streaming `delta` (empty at start/complete) and
+ /// the accumulated `text` (empty until a delta/complete carries it).
+ thinking: Thinking,
+ /// `assistant_text` / `assistant_text_delta` / `assistant_text_complete`:
+ /// a streaming assistant text block's lifecycle. Same shape as `Thinking`.
+ assistant_text: AssistantText,
+ /// `tool` / `tool_details` / `tool_delta` / `tool_call_complete` /
+ /// `tool_result`: the tool-use lifecycle. The shared `Tool` payload
+ /// carries the block index, the resolved name (empty until `tool_details`,
+ /// e.g. at the `tool` start boundary where the component shows `tool (?)`),
+ /// the streaming args `delta`, the accumulated args `input`, the result
+ /// `output`, and the tool-call `id` (set once resolved).
+ tool: Tool,
+ /// `compaction`: a compaction-summary boundary.
+ compaction: Compaction,
+ /// An extension-defined event. The emitter and handler agree on the
+ /// meaning of `data`; panto does not interpret it.
+ custom: Custom,
+
+ pub const SessionStart = struct {
+ version: []const u8 = "",
+ cwd: []const u8 = "",
+ model: []const u8 = "",
+ };
+ pub const UserMessage = struct {
+ text: []const u8 = "",
+ };
+ /// Lifecycle payload shared by `thinking`, `thinking_delta`, and
+ /// `thinking_complete`. Which fields are populated depends on the event:
+ /// - `thinking` (start): only `index`.
+ /// - `thinking_delta`: `index`, `delta` (this chunk), `text` (the
+ /// accumulated buffer so far, including this chunk).
+ /// - `thinking_complete`: `index`, `text` (the final buffer); `delta`
+ /// empty.
+ pub const Thinking = struct {
+ /// libpanto block index for this thinking block.
+ index: usize = 0,
+ /// The streaming chunk for a `*_delta` event; empty otherwise.
+ delta: []const u8 = "",
+ /// The accumulated text so far (delta) or the final text (complete);
+ /// empty at the start boundary.
+ text: []const u8 = "",
+ };
+ /// Lifecycle payload shared by `assistant_text`, `assistant_text_delta`,
+ /// and `assistant_text_complete`. Same field semantics as `Thinking`.
+ pub const AssistantText = struct {
+ /// libpanto block index for this text block.
+ index: usize = 0,
+ /// The streaming chunk for a `*_delta` event; empty otherwise.
+ delta: []const u8 = "",
+ /// The accumulated text so far (delta) or the final text (complete);
+ /// empty at the start boundary.
+ text: []const u8 = "",
+ };
+ /// Lifecycle payload shared by all `tool*` events. Which fields are
+ /// populated depends on the event:
+ /// - `tool` (start): `index`; `tool_name` empty (`tool (?)`).
+ /// - `tool_details`: `index`, `tool_name`, `id`.
+ /// - `tool_delta`: `index`, `tool_name` (if known), `delta` (this args
+ /// chunk), `input` (accumulated args so far).
+ /// - `tool_call_complete`: `index`, `tool_name`, `id`, `input` (final
+ /// args).
+ /// - `tool_result`: `index` (best-effort), `tool_name`, `id`, `output`
+ /// (the result text).
+ pub const Tool = struct {
+ /// libpanto block index for this tool-use block.
+ index: usize = 0,
+ /// Tool name if known at the boundary, else empty (the `tool` start
+ /// event fires before the streamed name resolves; the component shows
+ /// `tool (?)` until `tool_details`).
+ tool_name: []const u8 = "",
+ /// Tool-call id, once resolved (from `tool_details`/`tool_call_complete`
+ /// /`tool_result`); empty at the start boundary.
+ id: []const u8 = "",
+ /// The streaming args chunk for `tool_delta`; empty otherwise.
+ delta: []const u8 = "",
+ /// Accumulated args JSON (delta/complete), or empty.
+ input: []const u8 = "",
+ /// Tool result text for `tool_result`; empty otherwise.
+ output: []const u8 = "",
+ };
+ pub const Compaction = struct {
+ summary: []const u8 = "",
+ };
+ pub const Custom = struct {
+ data: ?*anyopaque = null,
+ };
+};
+
+// ===========================================================================
+// Event
+// ===========================================================================
+
+/// The live object a handler receives. Holds the event name, the current
+/// chosen component, and the structured payload.
+///
+/// Lifecycle: the emitter constructs an `Event` seeded with the built-in
+/// default component (or null when there is no default), runs every handler in
+/// registration order, and then reads `current` as the final chosen component.
+/// `getComponent` returns whatever is current — the default before any handler
+/// runs, then whatever the most recent `setComponent` installed (§7.2). It is
+/// not a frozen "default".
+pub const Event = struct {
+ name: []const u8,
+ /// The currently chosen component for this event: the seeded default first,
+ /// then whatever a handler last set. Null is legal (an event with no
+ /// default and no handler that sets one).
+ current: ?Component,
+ payload: Payload,
+
+ /// Construct an event seeded with `default` as the initial component.
+ pub fn init(name: []const u8, default: ?Component, payload: Payload) Event {
+ return .{ .name = name, .current = default, .payload = payload };
+ }
+
+ /// The component currently chosen for this event (§7.2). Returns the
+ /// running current value — the default until a handler changes it, then the
+ /// last-set component.
+ pub fn getComponent(self: *const Event) ?Component {
+ return self.current;
+ }
+
+ /// Set/replace the chosen component (§7.2). Last writer wins (§7.3).
+ pub fn setComponent(self: *Event, c: Component) void {
+ self.current = c;
+ }
+};
+
+// ===========================================================================
+// EventBus
+// ===========================================================================
+
+/// The registry of event-name -> ordered handler list, plus the emit walk.
+///
+/// `on` appends a handler under an event name (creating the bucket on first
+/// use), preserving registration order. `emit` seeds an `Event` with the
+/// caller's default component, runs every handler for that name in order, and
+/// returns the final chosen component.
+///
+/// Ownership: the bus owns its name-keyed buckets and the handler arrays; it
+/// does NOT own handler `ctx` pointers or any component (those are owned by
+/// their registrant / the transcript). `deinit` frees only the bus's own
+/// bookkeeping.
+pub const EventBus = struct {
+ alloc: std.mem.Allocator,
+ /// event name -> ordered list of handlers (registration order).
+ handlers: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(Handler)) = .empty,
+ /// Owned copies of the event-name keys (the map borrows these).
+ keys: std.ArrayListUnmanaged([]u8) = .empty,
+
+ pub fn init(alloc: std.mem.Allocator) EventBus {
+ return .{ .alloc = alloc };
+ }
+
+ pub fn deinit(self: *EventBus) void {
+ var it = self.handlers.valueIterator();
+ while (it.next()) |list| list.deinit(self.alloc);
+ self.handlers.deinit(self.alloc);
+ for (self.keys.items) |k| self.alloc.free(k);
+ self.keys.deinit(self.alloc);
+ }
+
+ /// Register `handler` for `name`. Handlers fire in registration order on
+ /// `emit`. The same name may have many handlers; the same handler may be
+ /// registered more than once (it then fires that many times). `name` is
+ /// copied into bus-owned storage on first use, so the caller need not keep
+ /// it alive.
+ pub fn on(self: *EventBus, name: []const u8, handler: Handler) !void {
+ const gop = try self.handlers.getOrPut(self.alloc, name);
+ if (!gop.found_existing) {
+ // First handler for this name: own a stable copy of the key so the
+ // map's key slice outlives the caller's `name` argument.
+ const key_copy = try self.alloc.dupe(u8, name);
+ errdefer self.alloc.free(key_copy);
+ try self.keys.append(self.alloc, key_copy);
+ gop.key_ptr.* = key_copy;
+ gop.value_ptr.* = .empty;
+ }
+ try gop.value_ptr.append(self.alloc, handler);
+ }
+
+ /// Fire the event named `event.name`, running every registered handler in
+ /// registration order. The passed `event` is seeded by the caller with its
+ /// boundary-local default component (`Event.init`); each handler may read
+ /// `getComponent()` and replace it with `setComponent()`. Returns the final
+ /// chosen component (the seeded default if no handler changed it, or null
+ /// if there was no default and none was set).
+ ///
+ /// No "active component" (§6): the bus stores no component across emits.
+ /// Each emit operates only on the `event` the caller owns, so two
+ /// concurrent `tool` boundaries each pass their own `event` (with their own
+ /// default) and get back their own chosen component.
+ pub fn emit(self: *EventBus, event: *Event) ?Component {
+ if (self.handlers.getPtr(event.name)) |list| {
+ for (list.items) |h| h.call(event);
+ }
+ return event.current;
+ }
+
+ /// Convenience: seed an `Event` with `default` + `payload`, emit it, and
+ /// return the chosen component. The transient event lives only for the
+ /// call. Equivalent to constructing an `Event` and calling `emit`.
+ pub fn fire(
+ self: *EventBus,
+ name: []const u8,
+ default: ?Component,
+ payload: Payload,
+ ) ?Component {
+ var ev = Event.init(name, default, payload);
+ return self.emit(&ev);
+ }
+
+ /// Number of handlers registered for `name` (0 if none). Diagnostic/test
+ /// helper.
+ pub fn handlerCount(self: *const EventBus, name: []const u8) usize {
+ if (self.handlers.getPtr(name)) |list| return list.items.len;
+ return 0;
+ }
+};
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+
+/// A trivial test component: renders one fixed line. Identity is its `tag` so
+/// tests can assert which component came out of an emit.
+const FakeComponent = struct {
+ tag: u8,
+ line_storage: [1][]const u8 = undefined,
+
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ _ = width;
+ _ = alloc;
+ const self: *FakeComponent = @ptrCast(@alignCast(ptr));
+ self.line_storage[0] = "x";
+ return self.line_storage[0..];
+ }
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ _ = ptr;
+ return 0;
+ }
+ fn invalidateImpl(ptr: *anyopaque) void {
+ _ = ptr;
+ }
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ };
+ fn comp(self: *FakeComponent) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+};
+
+test "emit with zero handlers returns the seeded default unchanged" {
+ var bus = EventBus.init(testing.allocator);
+ defer bus.deinit();
+
+ var def = FakeComponent{ .tag = 1 };
+ const out = bus.fire("tool", def.comp(), .{ .tool = .{ .index = 0 } });
+ try testing.expect(out != null);
+ try testing.expectEqual(@as(*anyopaque, def.comp().ptr), out.?.ptr);
+
+ // And a null default passes through as null.
+ const none = bus.fire("nope", null, .{ .custom = .{} });
+ try testing.expect(none == null);
+}
+
+test "getComponent returns the running current (default, then prior handler's)" {
+ var bus = EventBus.init(testing.allocator);
+ defer bus.deinit();
+
+ var def = FakeComponent{ .tag = 1 };
+ var replacement = FakeComponent{ .tag = 2 };
+
+ const Ctx = struct {
+ replacement: *FakeComponent,
+ default_ptr: *anyopaque,
+ saw_default_first: bool = false,
+ fn cb(ctx: *anyopaque, ev: *Event) void {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ // Before this handler sets anything, getComponent is the default.
+ if (ev.getComponent()) |cur| {
+ if (cur.ptr == self.default_ptr) self.saw_default_first = true;
+ }
+ ev.setComponent(self.replacement.comp());
+ }
+ };
+ var ctx = Ctx{ .replacement = &replacement, .default_ptr = def.comp().ptr };
+ try bus.on("tool", .{ .ctx = &ctx, .callback = Ctx.cb });
+
+ const out = bus.fire("tool", def.comp(), .{ .tool = .{ .index = 0 } });
+ try testing.expect(ctx.saw_default_first);
+ try testing.expectEqual(@as(*anyopaque, replacement.comp().ptr), out.?.ptr);
+}
+
+test "handlers run in registration order, last setComponent wins" {
+ var bus = EventBus.init(testing.allocator);
+ defer bus.deinit();
+
+ var def = FakeComponent{ .tag = 0 };
+ var a = FakeComponent{ .tag = 1 };
+ var b = FakeComponent{ .tag = 2 };
+
+ // Record the order handlers observed, and have each set its own component.
+ var order: std.ArrayListUnmanaged(u8) = .empty;
+ defer order.deinit(testing.allocator);
+
+ const Ctx = struct {
+ which: *FakeComponent,
+ order: *std.ArrayListUnmanaged(u8),
+ alloc: std.mem.Allocator,
+ fn cb(ctx: *anyopaque, ev: *Event) void {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ self.order.append(self.alloc, self.which.tag) catch {};
+ ev.setComponent(self.which.comp());
+ }
+ };
+ var ca = Ctx{ .which = &a, .order = &order, .alloc = testing.allocator };
+ var cb = Ctx{ .which = &b, .order = &order, .alloc = testing.allocator };
+ try bus.on("tool", .{ .ctx = &ca, .callback = Ctx.cb });
+ try bus.on("tool", .{ .ctx = &cb, .callback = Ctx.cb });
+
+ const out = bus.fire("tool", def.comp(), .{ .tool = .{} });
+ // Registration order: a then b.
+ try testing.expectEqualSlices(u8, &.{ 1, 2 }, order.items);
+ // Last writer (b) wins.
+ try testing.expectEqual(@as(*anyopaque, b.comp().ptr), out.?.ptr);
+}
+
+test "wrapping pattern: handler reads default, wraps, sets" {
+ var bus = EventBus.init(testing.allocator);
+ defer bus.deinit();
+
+ var def = FakeComponent{ .tag = 7 };
+
+ // A wrapper component that decorates an inner component (records the inner
+ // ptr so we can assert the handler read the default).
+ const Wrapper = struct {
+ inner: Component,
+ line_storage: [1][]const u8 = undefined,
+ fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
+ const self: *@This() = @ptrCast(@alignCast(ptr));
+ return self.inner.render(width, alloc);
+ }
+ fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
+ const self: *@This() = @ptrCast(@alignCast(ptr));
+ return self.inner.firstLineChanged();
+ }
+ fn invalidateImpl(ptr: *anyopaque) void {
+ const self: *@This() = @ptrCast(@alignCast(ptr));
+ self.inner.invalidate();
+ }
+ const vtable = Component.VTable{
+ .render = renderImpl,
+ .firstLineChanged = firstLineChangedImpl,
+ .invalidate = invalidateImpl,
+ };
+ fn comp(self: *@This()) Component {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+ };
+
+ var wrapper: Wrapper = undefined;
+ const Ctx = struct {
+ wrapper: *Wrapper,
+ fn cb(ctx: *anyopaque, ev: *Event) void {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ const inner = ev.getComponent().?; // the default
+ self.wrapper.* = .{ .inner = inner };
+ ev.setComponent(self.wrapper.comp());
+ }
+ };
+ var ctx = Ctx{ .wrapper = &wrapper };
+ try bus.on("tool", .{ .ctx = &ctx, .callback = Ctx.cb });
+
+ const out = bus.fire("tool", def.comp(), .{ .tool = .{} });
+ // The chosen component is the wrapper, and it wraps the default.
+ try testing.expectEqual(@as(*anyopaque, wrapper.comp().ptr), out.?.ptr);
+ try testing.expectEqual(@as(*anyopaque, def.comp().ptr), wrapper.inner.ptr);
+}
+
+test "two concurrent tool events get independent components (no active component)" {
+ var bus = EventBus.init(testing.allocator);
+ defer bus.deinit();
+
+ // A handler that, for each tool event, mints a distinct component keyed by
+ // the event's block index — proving the bus holds no shared/active state.
+ var comps = [_]FakeComponent{
+ .{ .tag = 10 },
+ .{ .tag = 11 },
+ };
+ const Ctx = struct {
+ comps: []FakeComponent,
+ fn cb(ctx: *anyopaque, ev: *Event) void {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ const idx = ev.payload.tool.index;
+ ev.setComponent(self.comps[idx].comp());
+ }
+ };
+ var ctx = Ctx{ .comps = &comps };
+ try bus.on("tool", .{ .ctx = &ctx, .callback = Ctx.cb });
+
+ // Two separate boundaries, each with its own default + index.
+ var def0 = FakeComponent{ .tag = 0 };
+ var def1 = FakeComponent{ .tag = 1 };
+ const out0 = bus.fire("tool", def0.comp(), .{ .tool = .{ .index = 0 } });
+ const out1 = bus.fire("tool", def1.comp(), .{ .tool = .{ .index = 1 } });
+
+ try testing.expectEqual(@as(*anyopaque, comps[0].comp().ptr), out0.?.ptr);
+ try testing.expectEqual(@as(*anyopaque, comps[1].comp().ptr), out1.?.ptr);
+ // Distinct instances.
+ try testing.expect(out0.?.ptr != out1.?.ptr);
+}
+
+test "on copies the event-name key (caller need not keep it alive)" {
+ var bus = EventBus.init(testing.allocator);
+ defer bus.deinit();
+
+ var name_buf: [8]u8 = undefined;
+ @memcpy(name_buf[0..4], "tool");
+ const transient = name_buf[0..4];
+
+ var def = FakeComponent{ .tag = 1 };
+ var replacement = FakeComponent{ .tag = 2 };
+ const Ctx = struct {
+ replacement: *FakeComponent,
+ fn cb(ctx: *anyopaque, ev: *Event) void {
+ const self: *@This() = @ptrCast(@alignCast(ctx));
+ ev.setComponent(self.replacement.comp());
+ }
+ };
+ var ctx = Ctx{ .replacement = &replacement };
+ try bus.on(transient, .{ .ctx = &ctx, .callback = Ctx.cb });
+
+ // Scribble over the caller's buffer; the bus must have its own copy.
+ @memcpy(name_buf[0..4], "ZZZZ");
+
+ const out = bus.fire("tool", def.comp(), .{ .tool = .{} });
+ try testing.expectEqual(@as(*anyopaque, replacement.comp().ptr), out.?.ptr);
+ try testing.expectEqual(@as(usize, 1), bus.handlerCount("tool"));
+}