add consume_events to PointerEventListener and MouseEventListener; move event listeners to capture phase

- Frontend
  - pointerEventListener.ts: install click/pointer* handlers with addEventListener({ capture: true }); create/remove bound handlers lazily; honor consume_events by marking events handled in parent handler.
  - mouseEventListener.ts: same capture-phase install and lazy handler refs; consume_events respected.
- Tests
  - Add test_mouse_event_listener.py for consume_events and capture-phase propagation.
  - Update test_pointer_event_listener.py to reflect new capture behavior.

Behavior change:
- PointerEventListener propagation semantics changed: with capture-phase parent listeners, events now reach the parent first; if the parent’s handler consumes the event, the child won’t see it (previously a child consuming could prevent the parent from receiving it). This ensures the handler decides whether to consume.
This commit is contained in:
iap
2025-10-16 11:49:32 -04:00
parent d7645b96a5
commit 6bf50bcca3
6 changed files with 363 additions and 106 deletions

View File

@@ -4,6 +4,7 @@ import { DragHandler } from "../eventHandling";
import { ComponentId } from "../dataModels";
import { findComponentUnderMouse } from "../utils";
import { ComponentStatesUpdateContext } from "../componentManagement";
import { markEventAsHandled } from "../eventHandling";
function eventMouseButtonToString(event: MouseEvent): object {
return {
@@ -30,10 +31,18 @@ export type MouseEventListenerState = ComponentState & {
reportDragStart: boolean;
reportDragMove: boolean;
reportDragEnd: boolean;
consume_events: boolean;
};
export class MouseEventListenerComponent extends ComponentBase<MouseEventListenerState> {
private _dragHandler: DragHandler | null = null;
// Handler refs created on install to keep structure minimal
private _onClickBound: ((e: MouseEvent) => void) | null = null;
private _onMouseDownBound: ((e: MouseEvent) => void) | null = null;
private _onMouseUpBound: ((e: MouseEvent) => void) | null = null;
private _onMouseMoveBound: ((e: MouseEvent) => void) | null = null;
private _onMouseEnterBound: ((e: MouseEvent) => void) | null = null;
private _onMouseLeaveBound: ((e: MouseEvent) => void) | null = null;
createElement(context: ComponentStatesUpdateContext): HTMLElement {
let element = document.createElement("div");
@@ -49,73 +58,167 @@ export class MouseEventListenerComponent extends ComponentBase<MouseEventListene
this.replaceOnlyChild(context, deltaState.content);
if (deltaState.reportPress) {
this.element.onclick = (e) => {
this.sendMessageToBackend({
type: "press",
...eventMouseButtonToString(e),
...eventMousePositionToString(e),
});
};
} else {
this.element.onclick = null;
if (deltaState.reportPress !== undefined) {
if (this.state.reportPress) {
if (this._onClickBound === null) {
this._onClickBound = (e: MouseEvent) => {
this._sendMessageToBackend(e, {
type: "press",
...eventMouseButtonToString(e),
...eventMousePositionToString(e),
});
};
this.element.addEventListener("click", this._onClickBound, {
capture: true,
});
}
} else {
if (this._onClickBound !== null) {
this.element.removeEventListener(
"click",
this._onClickBound,
{ capture: true } as AddEventListenerOptions
);
this._onClickBound = null;
}
}
}
if (deltaState.reportMouseDown) {
this.element.onmousedown = (e) => {
this.sendMessageToBackend({
type: "mouseDown",
...eventMouseButtonToString(e),
...eventMousePositionToString(e),
});
};
} else {
this.element.onmousedown = null;
if (deltaState.reportMouseDown !== undefined) {
if (this.state.reportMouseDown) {
if (this._onMouseDownBound === null) {
this._onMouseDownBound = (e: MouseEvent) => {
this._sendMessageToBackend(e, {
type: "mouseDown",
...eventMouseButtonToString(e),
...eventMousePositionToString(e),
});
};
this.element.addEventListener(
"mousedown",
this._onMouseDownBound,
{ capture: true }
);
}
} else {
if (this._onMouseDownBound !== null) {
this.element.removeEventListener(
"mousedown",
this._onMouseDownBound,
{ capture: true } as AddEventListenerOptions
);
this._onMouseDownBound = null;
}
}
}
if (deltaState.reportMouseUp) {
this.element.onmouseup = (e) => {
this.sendMessageToBackend({
type: "mouseUp",
...eventMouseButtonToString(e),
...eventMousePositionToString(e),
});
};
} else {
this.element.onmouseup = null;
if (deltaState.reportMouseUp !== undefined) {
if (this.state.reportMouseUp) {
if (this._onMouseUpBound === null) {
this._onMouseUpBound = (e: MouseEvent) => {
this._sendMessageToBackend(e, {
type: "mouseUp",
...eventMouseButtonToString(e),
...eventMousePositionToString(e),
});
};
this.element.addEventListener(
"mouseup",
this._onMouseUpBound,
{ capture: true }
);
}
} else {
if (this._onMouseUpBound !== null) {
this.element.removeEventListener(
"mouseup",
this._onMouseUpBound,
{ capture: true } as AddEventListenerOptions
);
this._onMouseUpBound = null;
}
}
}
if (deltaState.reportMouseMove) {
this.element.onmousemove = (e) => {
this.sendMessageToBackend({
type: "mouseMove",
...eventMousePositionToString(e),
});
};
} else {
this.element.onmousemove = null;
if (deltaState.reportMouseMove !== undefined) {
if (this.state.reportMouseMove) {
if (this._onMouseMoveBound === null) {
this._onMouseMoveBound = (e: MouseEvent) => {
this._sendMessageToBackend(e, {
type: "mouseMove",
...eventMousePositionToString(e),
});
};
this.element.addEventListener(
"mousemove",
this._onMouseMoveBound,
{ capture: true }
);
}
} else {
if (this._onMouseMoveBound !== null) {
this.element.removeEventListener(
"mousemove",
this._onMouseMoveBound,
{ capture: true } as AddEventListenerOptions
);
this._onMouseMoveBound = null;
}
}
}
if (deltaState.reportMouseEnter) {
this.element.onmouseenter = (e) => {
this.sendMessageToBackend({
type: "mouseEnter",
...eventMousePositionToString(e),
});
};
} else {
this.element.onmouseenter = null;
if (deltaState.reportMouseEnter !== undefined) {
if (this.state.reportMouseEnter) {
if (this._onMouseEnterBound === null) {
this._onMouseEnterBound = (e: MouseEvent) => {
this._sendMessageToBackend(e, {
type: "mouseEnter",
...eventMousePositionToString(e),
});
};
this.element.addEventListener(
"mouseenter",
this._onMouseEnterBound,
{ capture: true }
);
}
} else {
if (this._onMouseEnterBound !== null) {
this.element.removeEventListener(
"mouseenter",
this._onMouseEnterBound,
{ capture: true } as AddEventListenerOptions
);
this._onMouseEnterBound = null;
}
}
}
if (deltaState.reportMouseLeave) {
this.element.onmouseleave = (e) => {
this.sendMessageToBackend({
type: "mouseLeave",
...eventMousePositionToString(e),
});
};
} else {
this.element.onmouseleave = null;
if (deltaState.reportMouseLeave !== undefined) {
if (this.state.reportMouseLeave) {
if (this._onMouseLeaveBound === null) {
this._onMouseLeaveBound = (e: MouseEvent) => {
this._sendMessageToBackend(e, {
type: "mouseLeave",
...eventMousePositionToString(e),
});
};
this.element.addEventListener(
"mouseleave",
this._onMouseLeaveBound,
{ capture: true }
);
}
} else {
if (this._onMouseLeaveBound !== null) {
this.element.removeEventListener(
"mouseleave",
this._onMouseLeaveBound,
{ capture: true } as AddEventListenerOptions
);
this._onMouseLeaveBound = null;
}
}
}
if (
@@ -159,7 +262,7 @@ export class MouseEventListenerComponent extends ComponentBase<MouseEventListene
}
private _sendDragEvent(eventType: string, event: MouseEvent): void {
this.sendMessageToBackend({
this._sendMessageToBackend(event, {
type: eventType,
...eventMouseButtonToString(event),
x: event.clientX / pixelsPerRem,
@@ -167,4 +270,11 @@ export class MouseEventListenerComponent extends ComponentBase<MouseEventListene
component: findComponentUnderMouse(event),
});
}
private _sendMessageToBackend(event: MouseEvent, message: object): void {
// Mark the event as handled if needed
if (this.state.consume_events) markEventAsHandled(event);
this.sendMessageToBackend(message);
}
}

View File

@@ -19,6 +19,7 @@ export type PointerEventListenerState = ComponentState & {
reportDragStart: boolean;
reportDragMove: boolean;
reportDragEnd: boolean;
consume_events: boolean;
};
const DOUBLE_CLICK_TIMEOUT = 300;
@@ -28,6 +29,13 @@ export class PointerEventListenerComponent extends ComponentBase<PointerEventLis
private _doubleClickTimeoutByButton: {
[button: number]: number | undefined;
} = {};
// Handler references created on-demand where installed
private _onClickBound: (e: MouseEvent) => void | null = null;
private _onPointerDownBound: ((e: PointerEvent) => void) | null = null;
private _onPointerUpBound: ((e: PointerEvent) => void) | null = null;
private _onPointerMoveBound: ((e: PointerEvent) => void) | null = null;
private _onPointerEnterBound: ((e: PointerEvent) => void) | null = null;
private _onPointerLeaveBound: ((e: PointerEvent) => void) | null = null;
createElement(context: ComponentStatesUpdateContext): HTMLElement {
let element = document.createElement("div");
@@ -52,58 +60,148 @@ export class PointerEventListenerComponent extends ComponentBase<PointerEventLis
deltaState.reportDoublePress ?? this.state.reportDoublePress;
if (reportPress || reportDoublePress) {
this.element.onclick = this._onClick.bind(this);
if (this._onClickBound === null) {
this._onClickBound = this._onClick.bind(this);
this.element.addEventListener("click", this._onClickBound, {
capture: true,
});
}
} else {
this.element.onclick = null;
if (this._onClickBound !== null) {
this.element.removeEventListener(
"click",
this._onClickBound,
{ capture: true } as AddEventListenerOptions
);
this._onClickBound = null;
}
}
}
if (deltaState.reportPointerDown !== undefined) {
if (deltaState.reportPointerDown.length > 0) {
this.element.onpointerdown = (e) => {
if (eventMatchesButton(e, deltaState.reportPointerDown!)) {
this._sendEventToBackend("pointerDown", e, false);
}
};
if ((this.state.reportPointerDown?.length ?? 0) > 0) {
if (this._onPointerDownBound === null) {
this._onPointerDownBound = (e: PointerEvent) => {
if (
eventMatchesButton(e, this.state.reportPointerDown)
) {
this._sendEventToBackend("pointerDown", e, false);
}
};
this.element.addEventListener(
"pointerdown",
this._onPointerDownBound,
{ capture: true }
);
}
} else {
this.element.onpointerdown = null;
if (this._onPointerDownBound !== null) {
this.element.removeEventListener(
"pointerdown",
this._onPointerDownBound,
{ capture: true } as AddEventListenerOptions
);
this._onPointerDownBound = null;
}
}
}
if (deltaState.reportPointerUp !== undefined) {
if (deltaState.reportPointerUp.length > 0) {
this.element.onpointerup = (e) => {
if (eventMatchesButton(e, deltaState.reportPointerUp!)) {
this._sendEventToBackend("pointerUp", e, false);
}
};
if ((this.state.reportPointerUp?.length ?? 0) > 0) {
if (this._onPointerUpBound === null) {
this._onPointerUpBound = (e: PointerEvent) => {
if (eventMatchesButton(e, this.state.reportPointerUp)) {
this._sendEventToBackend("pointerUp", e, false);
}
};
this.element.addEventListener(
"pointerup",
this._onPointerUpBound,
{ capture: true }
);
}
} else {
this.element.onpointerup = null;
if (this._onPointerUpBound !== null) {
this.element.removeEventListener(
"pointerup",
this._onPointerUpBound,
{ capture: true } as AddEventListenerOptions
);
this._onPointerUpBound = null;
}
}
}
if (deltaState.reportPointerMove) {
this.element.onpointermove = (e) => {
this._sendEventToBackend("pointerMove", e, true);
};
} else {
this.element.onpointermove = null;
if (deltaState.reportPointerMove !== undefined) {
if (this.state.reportPointerMove) {
if (this._onPointerMoveBound === null) {
this._onPointerMoveBound = (e: PointerEvent) => {
this._sendEventToBackend("pointerMove", e, true);
};
this.element.addEventListener(
"pointermove",
this._onPointerMoveBound,
{ capture: true }
);
}
} else {
if (this._onPointerMoveBound !== null) {
this.element.removeEventListener(
"pointermove",
this._onPointerMoveBound,
{ capture: true } as AddEventListenerOptions
);
this._onPointerMoveBound = null;
}
}
}
if (deltaState.reportPointerEnter) {
this.element.onpointerenter = (e) => {
this._sendEventToBackend("pointerEnter", e, false);
};
} else {
this.element.onpointerenter = null;
if (deltaState.reportPointerEnter !== undefined) {
if (this.state.reportPointerEnter) {
if (this._onPointerEnterBound === null) {
this._onPointerEnterBound = (e: PointerEvent) => {
this._sendEventToBackend("pointerEnter", e, false);
};
this.element.addEventListener(
"pointerenter",
this._onPointerEnterBound,
{ capture: true }
);
}
} else {
if (this._onPointerEnterBound !== null) {
this.element.removeEventListener(
"pointerenter",
this._onPointerEnterBound,
{ capture: true } as AddEventListenerOptions
);
this._onPointerEnterBound = null;
}
}
}
if (deltaState.reportPointerLeave) {
this.element.onpointerleave = (e) => {
this._sendEventToBackend("pointerLeave", e, false);
};
} else {
this.element.onpointerleave = null;
if (deltaState.reportPointerLeave !== undefined) {
if (this.state.reportPointerLeave) {
if (this._onPointerLeaveBound === null) {
this._onPointerLeaveBound = (e: PointerEvent) => {
this._sendEventToBackend("pointerLeave", e, false);
};
this.element.addEventListener(
"pointerleave",
this._onPointerLeaveBound,
{ capture: true }
);
}
} else {
if (this._onPointerLeaveBound !== null) {
this.element.removeEventListener(
"pointerleave",
this._onPointerLeaveBound,
{ capture: true } as AddEventListenerOptions
);
this._onPointerLeaveBound = null;
}
}
}
if (
@@ -290,8 +388,8 @@ export class PointerEventListenerComponent extends ComponentBase<PointerEventLis
return;
}
// Mark the event as handled
markEventAsHandled(event);
// Mark the event as handled if needed
if (this.state.consume_events) markEventAsHandled(event);
// Send the event
this.sendMessageToBackend({

View File

@@ -248,6 +248,8 @@ class MouseEventListener(FundamentalComponent):
mouse button.
`on_drag_end`: Triggered when the user stops dragging the mouse.
`consume_events`: Consume events after they are processed.
"""
content: rio.Component
@@ -261,6 +263,7 @@ class MouseEventListener(FundamentalComponent):
on_drag_start: rio.EventHandler[DragStartEvent] = None
on_drag_move: rio.EventHandler[DragMoveEvent] = None
on_drag_end: rio.EventHandler[DragEndEvent] = None
consume_events: bool = False
def __post_init__(self) -> None:
deprecations.warn(

View File

@@ -171,6 +171,8 @@ class PointerEventListener(FundamentalComponent):
leaves the component.
`on_drag_end`: Triggered when the user stops dragging the pointer.
`consume_events`: Consume events after they are processed.
"""
content: rio.Component
@@ -191,6 +193,7 @@ class PointerEventListener(FundamentalComponent):
on_drag_start: rio.EventHandler[PointerEvent] = None
on_drag_move: rio.EventHandler[PointerMoveEvent] = None
on_drag_end: rio.EventHandler[PointerEvent] = None
consume_events: bool = True
def _custom_serialize_(self) -> JsonDoc:
return {

View File

@@ -0,0 +1,34 @@
import pytest
import rio
from rio.testing import BrowserClient
@pytest.mark.parametrize("consume_events", [True, False])
async def test_specific_button_events(consume_events: bool) -> None:
down_events: list[rio.MouseDownEvent] = []
up_events: list[rio.MouseUpEvent] = []
def on_mouse_down(e: rio.MouseDownEvent):
down_events.append(e)
def on_mouse_up(e: rio.MouseUpEvent):
up_events.append(e)
def build():
return rio.MouseEventListener(
rio.MouseEventListener(
rio.Spacer(),
on_mouse_down=on_mouse_down,
on_mouse_up=on_mouse_up,
),
on_mouse_down=on_mouse_down,
on_mouse_up=on_mouse_up,
consume_events=consume_events,
)
async with BrowserClient(build) as client:
await client.click(0.5, 0.5, sleep=0.5)
assert len(down_events) == 1 + (not consume_events)
assert len(up_events) == 1 + (not consume_events)

View File

@@ -43,7 +43,7 @@ async def test_on_double_press_event(
def build():
return rio.PointerEventListener(
rio.Spacer(), on_double_press=on_double_press
rio.TextInput(), on_double_press=on_double_press
)
async with BrowserClient(build) as client:
@@ -70,36 +70,45 @@ async def test_on_double_press_event(
ids=lambda buttons: "/".join(buttons),
)
@pytest.mark.parametrize("pressed_button", ["left", "middle", "right"])
@pytest.mark.parametrize("consume_events", [True, False])
async def test_specific_button_events(
event_buttons: t.Sequence[t.Literal["left", "middle", "right"]],
pressed_button: t.Literal["left", "middle", "right"],
consume_events: bool,
) -> None:
down_event: rio.PointerEvent | None = None
up_event: rio.PointerEvent | None = None
down_events: list[rio.PointerEvent] = []
up_events: list[rio.PointerEvent] = []
def on_pointer_down(e: rio.PointerEvent):
nonlocal down_event
down_event = e
down_events.append(e)
def on_pointer_up(e: rio.PointerEvent):
nonlocal up_event
up_event = e
up_events.append(e)
def build():
return rio.PointerEventListener(
rio.Spacer(),
rio.PointerEventListener(
rio.Spacer(),
on_pointer_down={
button: on_pointer_down for button in event_buttons
},
on_pointer_up={
button: on_pointer_up for button in event_buttons
},
),
on_pointer_down={
button: on_pointer_down for button in event_buttons
},
on_pointer_up={button: on_pointer_up for button in event_buttons},
consume_events=consume_events,
)
async with BrowserClient(build) as client:
await client.click(0.5, 0.5, button=pressed_button, sleep=0.5)
if pressed_button in event_buttons:
assert down_event is not None
assert up_event is not None
assert len(down_events) == 1 + (not consume_events)
assert len(up_events) == 1 + (not consume_events)
else:
assert down_event is None
assert up_event is None
assert len(down_events) == 0
assert len(up_events) == 0