mirror of
https://github.com/anomalyco/opentui.git
synced 2026-04-25 13:06:00 +03:00
[GH-ISSUE #650] DOM-like capture/bubble event dispatch #175
Labels
No labels
bug
core
documentation
feature
good first issue
help wanted
pull-request
question
react
solid
tmux
windows
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/opentui#175
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @cevr on GitHub (Feb 8, 2026).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/650
Background
#638 introduced
<focus-scope>for keyboard isolation and scoped hooks. The implementation onfeat/focus-scope(PR #646) works, but revealed that the underlying event architecture is fighting us:renderableHandlersSet. Adding scopes required dual-path dispatch (emitScopeFirstvsemitGlobalFirst) based onfindClosestKeyboardScope().processMouseEvent) — closest to DOM, but no capture phase.Every new feature (trapFocus isolation, global shortcuts like Ctrl+C surviving scopes, framework hooks discovering their scope) requires another special case in
InternalKeyHandler. The complexity compounds: parallel listener stores (onInternal/offInternal),focusedRenderableProviderinverting renderer state into the handler,eventPreventedOrStopped/eventPropagationStoppedflags, and framework hooks needingfindClosestKeyboardScope.Starting from the focus-scope work, we stepped back and asked: what's the simplest model that handles all of this without special cases?
Design: DOM-like capture/bubble
The answer is the same one the DOM uses. All propagating events follow a two-phase dispatch through the renderable tree:
{ capture: true }fire top-downEvent target resolution
keypress,keyrelease,pastemousedown,mouseup, etc.resizePropagation
stopPropagation()halts traversal after current node's listeners finishstopImmediatePropagation()halts remaining listeners on current node toopreventDefault()suppresses default behavior (orthogonal to propagation)Listener API on Renderable
Event base type
KeyEvent,PasteEventextendTUIEvent.How this simplifies everything
trapFocus → bubble-phase stopPropagation
Events bubble from focused renderable, hit the scope boundary, stop. No dispatch path switching. No
findClosestKeyboardScope.Global handlers → run before tree dispatch
renderer.keyInput.on("keypress", handler)registers on the EventEmitter.InternalKeyHandler.emit()iterates these inline before dispatching through the tree — conceptually "above" root in capture order.This gives global handlers a deterministic, stable position: always first. If they call
stopPropagation(), the tree never sees the event. If they don't, normal capture/bubble proceeds.Framework hooks → addEventListener on renderable
No scope discovery needed. Tree topology handles isolation naturally.
InternalKeyHandler → thin emit bridge
No temporary addEventListener/removeEventListener dance per keystroke. No wrapper allocations.
What gets deleted
InternalKeyHandler.renderableHandlers/onInternal/offInternalemitScopeFirst/emitGlobalFirst/emitGlobalListeners/emitInternalListenerseventPreventedOrStopped/eventPropagationStoppedRenderable.processKeyEvent/processKeyReleaseEvent/processPasteEvent_internalKeyInputonRenderContextfindClosestKeyboardScope(for dispatch decisions)Edge cases
target.isDestroyedchecked after each listener invocationdefaultPreventedReference implementation
Branch
cvr/event-architecturehas a working implementation for keyboard/paste dispatch. 47 tests across 3 test files covering capture/bubble ordering, stopPropagation, stopImmediatePropagation, preventDefault independence, scope isolation, paste dispatch, once listeners, error handling, and nested component scenarios. All existing tests pass.New files
packages/core/src/lib/event.ts—TUIEvent,EventPhasepackages/core/src/lib/event-dispatch.ts—dispatchEvent,buildPropagationPathModified
Renderable.ts—addEventListener/removeEventListener, handler types,defaultPreventedchecksKeyHandler.ts—KeyEvent/PasteEventextendTUIEvent, simplifiedInternalKeyHandler.emit()renderer.ts— wire root and focus providertypes.ts— remove_internalKeyInputMouse dispatch through
dispatchEventis a natural next step but not yet implemented.Thoughts? Open to feedback on the API surface, naming, or anything that smells off.
Mouse event migration
Branch
cvr/event-architecture-mouseextends the capture/bubble model to mouse events.Changes
MouseEvent extends TUIEvent— newlib/mouse-event.ts, re-exported fromrenderer.tsfor backward compat. InheritsstopImmediatePropagation,eventPhase,currentTargetfrom base.processMouseEvent→dispatchEvent— replaces manual parent-chain walk with full capture/bubble dispatch.addEventListener—onMouseDown,onMouseScroll, etc. now register/remove via the same listener system as keyboard events.onMouseEventvirtual method removed — subclasses (ScrollBox,EditBufferRenderable,TextBufferRenderable, examples) converted toaddEventListenercalls in constructors.What stays unchanged
"down","up","scroll", etc.)MouseEventonMouseDown,onMouse, etc.)Tests
39 tests pass (32 existing + 7 new covering TUIEvent inheritance, capture-before-bubble ordering, stopPropagation in capture, stopImmediatePropagation, scroll through capture/bubble, and property handler + addEventListener coexistence). All 3141 core, 26 React, and 176 Solid tests pass.