From 6bf50bcca391789d4e9498fd7b3b36d4f663ea03 Mon Sep 17 00:00:00 2001 From: iap Date: Thu, 16 Oct 2025 11:49:32 -0400 Subject: [PATCH] add consume_events to `PointerEventListener` and `MouseEventListener`; move event listeners to capture phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../code/components/mouseEventListener.ts | 226 +++++++++++++----- .../code/components/pointerEventListener.ts | 170 ++++++++++--- rio/components/mouse_event_listener.py | 3 + rio/components/pointer_event_listener.py | 3 + .../test_mouse_event_listener.py | 34 +++ .../test_pointer_event_listener.py | 33 ++- 6 files changed, 363 insertions(+), 106 deletions(-) create mode 100644 tests/test_frontend/test_mouse_event_listener.py diff --git a/frontend/code/components/mouseEventListener.ts b/frontend/code/components/mouseEventListener.ts index a54daa1d..df81e4ff 100644 --- a/frontend/code/components/mouseEventListener.ts +++ b/frontend/code/components/mouseEventListener.ts @@ -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 { 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 { - 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 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 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 None: deprecations.warn( diff --git a/rio/components/pointer_event_listener.py b/rio/components/pointer_event_listener.py index 5ba1b9ea..ed635b72 100644 --- a/rio/components/pointer_event_listener.py +++ b/rio/components/pointer_event_listener.py @@ -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 { diff --git a/tests/test_frontend/test_mouse_event_listener.py b/tests/test_frontend/test_mouse_event_listener.py new file mode 100644 index 00000000..19bd6a0d --- /dev/null +++ b/tests/test_frontend/test_mouse_event_listener.py @@ -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) diff --git a/tests/test_frontend/test_pointer_event_listener.py b/tests/test_frontend/test_pointer_event_listener.py index 8d777453..2dd9b6a6 100644 --- a/tests/test_frontend/test_pointer_event_listener.py +++ b/tests/test_frontend/test_pointer_event_listener.py @@ -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