From cf40d6620cd6f76b651b0a53741a362d9aa1c5bf Mon Sep 17 00:00:00 2001 From: dogukani <38019578+dogukani@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:53:25 +0300 Subject: [PATCH 1/3] feat: dispatch scroll events for virtual lists --- packages/core/events.cpp | 2 ++ packages/core/include/events.h | 3 ++- packages/core/index.d.ts | 2 ++ packages/elements/components/VirtualList.d.ts | 3 ++- packages/elements/components/VirtualList.tsx | 7 +++++-- packages/elements/ui/virtual_list.cpp | 19 ++++++++++++++++++- packages/engine/ui/tree_events.cpp | 9 ++++++--- packages/engine/ui/tree_events.h | 1 + 8 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/core/events.cpp b/packages/core/events.cpp index ea9f110..13b05a3 100644 --- a/packages/core/events.cpp +++ b/packages/core/events.cpp @@ -50,6 +50,8 @@ const char *PointerEvent::typeName() const return "keydown"; case PointerEventType::Rotary: return "rotary"; + case PointerEventType::Scroll: + return "scroll"; } return ""; } diff --git a/packages/core/include/events.h b/packages/core/include/events.h index 82795a4..1ab96e1 100644 --- a/packages/core/include/events.h +++ b/packages/core/include/events.h @@ -14,7 +14,8 @@ enum class PointerEventType { Click, Input, KeyDown, - Rotary + Rotary, + Scroll }; enum class EventPhase { diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 264c0be..730e8a0 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -445,6 +445,7 @@ export interface GeaEventMap { input: InputEvent keydown: KeyEvent rotary: RotaryEvent + scroll: Event } export type TouchEventHandler = (event: TouchEvent) => void @@ -1523,6 +1524,7 @@ export interface NativeButtonProps extends NativeEventAttributes { } export interface NativeVirtualListProps extends NativeEventAttributes { + onScroll?: (event: Event) => void id?: string class?: ClassValue style?: Style diff --git a/packages/elements/components/VirtualList.d.ts b/packages/elements/components/VirtualList.d.ts index d482e58..814c041 100644 --- a/packages/elements/components/VirtualList.d.ts +++ b/packages/elements/components/VirtualList.d.ts @@ -1,4 +1,4 @@ -import type { ClassValue, TouchEventHandler } from '@geastack/core' +import type { ClassValue, Event, TouchEventHandler } from '@geastack/core' export type VirtualListItemTemplate = (index: number, top: number) => any @@ -15,6 +15,7 @@ export interface VirtualListProps { onTouchStart?: TouchEventHandler onTouchMove?: TouchEventHandler onTouchEnd?: TouchEventHandler + onScroll?: (event: Event) => void } export default function VirtualList(props: VirtualListProps): any diff --git a/packages/elements/components/VirtualList.tsx b/packages/elements/components/VirtualList.tsx index a0b9ad5..1ca393d 100644 --- a/packages/elements/components/VirtualList.tsx +++ b/packages/elements/components/VirtualList.tsx @@ -1,4 +1,4 @@ -import type { ClassValue, TouchEventHandler } from '@geastack/core' +import type { ClassValue, Event, TouchEventHandler } from '@geastack/core' // A windowing list backed by the native element. The element is // an ordinary overflow:scroll container that scrolls over a virtual content @@ -25,6 +25,7 @@ export interface VirtualListProps { onTouchStart?: TouchEventHandler onTouchMove?: TouchEventHandler onTouchEnd?: TouchEventHandler + onScroll?: (event: Event) => void } const SLOT_COUNT = 16 @@ -94,7 +95,8 @@ export default function VirtualList({ itemTemplate, onTouchStart, onTouchMove, - onTouchEnd + onTouchEnd, + onScroll }: VirtualListProps) { const rowOverscan = overscan === undefined ? 2 : overscan return ( @@ -105,6 +107,7 @@ export default function VirtualList({ onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} + onScroll={onScroll} > diff --git a/packages/elements/ui/virtual_list.cpp b/packages/elements/ui/virtual_list.cpp index 5df8585..5c62315 100644 --- a/packages/elements/ui/virtual_list.cpp +++ b/packages/elements/ui/virtual_list.cpp @@ -21,6 +21,18 @@ namespace gea::embedded::ui { namespace { +void dispatchScrollEvent(int node) +{ + Tree &tree = Tree::instance(); + if (!tree.hasListenersForType("scroll")) return; + gea::framework::events::PointerEvent event{}; + event.type = gea::framework::events::PointerEventType::Scroll; + event.targetId = node; + event.bubbles = false; + event.cancelable = false; + tree.dispatchEvent(event); +} + int parseIntAttribute(const char *value, int fallback) { if (!value || !value[0]) return fallback; @@ -225,6 +237,7 @@ bool VirtualListRenderer::setScrollTop(int node, int scrollTop) n.render.dirty = 1; n.render.layout_dirty = 1; Tree::instance().markScrollDirty(node); + dispatchScrollEvent(node); return true; } @@ -268,7 +281,11 @@ void Tree::setScrollTop(int node, int scrollTop) { if (node < 0 || node >= nodeCount()) return; Node &n = treeState().nodes[node]; - if (!isViewLikeNodeType(n.type) || (n.type != NodeType::VirtualList && !scrollsOverflowY(n.style))) return; + if (n.type == NodeType::VirtualList) { + VirtualListRenderer::setScrollTop(node, scrollTop); + return; + } + if (!isViewLikeNodeType(n.type) || !scrollsOverflowY(n.style)) return; const int maxY = ViewRenderer::scrollMaxY(n); if (scrollTop < 0) scrollTop = 0; if (scrollTop > maxY) scrollTop = maxY; diff --git a/packages/engine/ui/tree_events.cpp b/packages/engine/ui/tree_events.cpp index b36fa3d..6fb2641 100644 --- a/packages/engine/ui/tree_events.cpp +++ b/packages/engine/ui/tree_events.cpp @@ -57,7 +57,7 @@ void applyAudioAttribute(Tree &tree, int node, const char *name, const char *val // Index order mirrors NodeEventListeners' slots and is kept in sync by // setEventListener (on the empty->set transition) and NodeEventListeners::clear() // (which every removal path calls). -constexpr int kEventTypeCount = 6; +constexpr int kEventTypeCount = 7; int g_listenerTypeCounts[kEventTypeCount] = {}; EventListenerId g_nextEventListenerId = 1; @@ -75,6 +75,7 @@ int eventTypeIndex(const char *type) if (sameName(type, "touchend") || sameName(type, "pointerup")) return 3; if (sameName(type, "input")) return 4; if (sameName(type, "keydown")) return 5; + if (sameName(type, "scroll")) return 6; return -1; } @@ -276,6 +277,7 @@ void NodeEventListeners::clear() release(touchend, 3); release(input, 4); release(keydown, 5); + release(scroll, 6); } NodeEventListenerList *NodeEventListeners::listenersFor(const char *type) @@ -287,6 +289,7 @@ NodeEventListenerList *NodeEventListeners::listenersFor(const char *type) if (sameName(type, "touchend") || sameName(type, "pointerup")) return &touchend; if (sameName(type, "input")) return &input; if (sameName(type, "keydown")) return &keydown; + if (sameName(type, "scroll")) return &scroll; return nullptr; } @@ -298,7 +301,7 @@ const NodeEventListenerList *NodeEventListeners::listenersFor(const char *type) bool NodeEventListeners::hasAny() const { return !click.empty() || !touchstart.empty() || !touchmove.empty() || - !touchend.empty() || !input.empty() || !keydown.empty(); + !touchend.empty() || !input.empty() || !keydown.empty() || !scroll.empty(); } EventListenerId Tree::setEventListener(int node, const char *type, gea::framework::events::EventListener listener) @@ -408,7 +411,7 @@ bool Tree::dispatchEvent(gea::framework::events::PointerEvent &event) cursor = nextId; (*callback)(event); dispatched = true; - if (event.propagationStopped || !event.bubbles) break; + if (event.propagationStopped) break; } if (event.propagationStopped || !event.bubbles) break; } diff --git a/packages/engine/ui/tree_events.h b/packages/engine/ui/tree_events.h index 48eae8e..0a89fdd 100644 --- a/packages/engine/ui/tree_events.h +++ b/packages/engine/ui/tree_events.h @@ -55,6 +55,7 @@ struct NodeEventListeners { NodeEventListenerList touchend; NodeEventListenerList input; NodeEventListenerList keydown; + NodeEventListenerList scroll; void clear(); NodeEventListenerList *listenersFor(const char *type); From 56f747b1a20d5101aa3919e4f42fa10ec59083c1 Mon Sep 17 00:00:00 2001 From: dogukani <38019578+dogukani@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:31:26 +0300 Subject: [PATCH 2/3] fix: preserve target listeners for non-bubbling events --- .../test/test_gea_engine_rare_data_main.cpp | 24 +++++++++++++++++++ packages/engine/ui/tree_events.cpp | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/core/test/test_gea_engine_rare_data_main.cpp b/packages/core/test/test_gea_engine_rare_data_main.cpp index 1476685..450ba40 100644 --- a/packages/core/test/test_gea_engine_rare_data_main.cpp +++ b/packages/core/test/test_gea_engine_rare_data_main.cpp @@ -115,6 +115,30 @@ int probe() kTestName, stopChildHits, stopParentHits); return 33; } + int nextChildHits = 0; + tree.setEventListener(stopChildId, "click", [&](gea::framework::events::PointerEvent &) { nextChildHits++; }); + { + gea::framework::events::PointerEvent ev{}; + ev.type = gea::framework::events::PointerEventType::Click; + ev.targetId = stopChildId; + tree.dispatchEvent(ev); + } + if (stopChildHits != 2 || nextChildHits != 1 || stopParentHits != 0) { + std::fprintf(stderr, "[%s] stopPropagation skipped a target listener or reached parent\n", kTestName); + return 39; + } + int parentScrollHits = 0; + tree.setEventListener(perParentId, "scroll", [&](gea::framework::events::PointerEvent &) { parentScrollHits++; }); + { + gea::framework::events::PointerEvent ev{}; + ev.type = gea::framework::events::PointerEventType::Scroll; + ev.targetId = perChildId; + ev.bubbles = false; + if (tree.dispatchEvent(ev) || parentScrollHits != 0) { + std::fprintf(stderr, "[%s] non-bubbling scroll reached parent without target listener\n", kTestName); + return 40; + } + } // Removing a node must reclaim its rare-data block (no leak, no stale fire). tree.removeNode(perChildId); diff --git a/packages/engine/ui/tree_events.cpp b/packages/engine/ui/tree_events.cpp index 6fb2641..d611c22 100644 --- a/packages/engine/ui/tree_events.cpp +++ b/packages/engine/ui/tree_events.cpp @@ -370,6 +370,7 @@ bool Tree::dispatchEvent(gea::framework::events::PointerEvent &event) event.target = gea::framework::events::EventTarget(event.targetId); bool dispatched = false; for (int node = event.targetId; node >= 0 && node < state.nodeCount; node = state.nodes[node].parent) { + if (!event.bubbles && node != event.targetId) break; NodeRareData *rd = rareDataFor(node); if (!rd) continue; auto *initialList = rd->listeners.listenersFor(event.typeName()); @@ -411,7 +412,6 @@ bool Tree::dispatchEvent(gea::framework::events::PointerEvent &event) cursor = nextId; (*callback)(event); dispatched = true; - if (event.propagationStopped) break; } if (event.propagationStopped || !event.bubbles) break; } From 692dbef338cd02780f9e883b057e9f5930202078 Mon Sep 17 00:00:00 2001 From: dogukani Date: Thu, 24 Sep 2026 17:32:11 +0300 Subject: [PATCH 3/3] fix: lower Event listener parameters to the engine event --- packages/geatsc-plugin-gea/src/host-shims.ts | 17 ++++++++++++++++ .../test/host-shims-protocol.test.mjs | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/geatsc-plugin-gea/src/host-shims.ts b/packages/geatsc-plugin-gea/src/host-shims.ts index b6a85f1..69a6490 100644 --- a/packages/geatsc-plugin-gea/src/host-shims.ts +++ b/packages/geatsc-plugin-gea/src/host-shims.ts @@ -34,6 +34,9 @@ const NODE_HANDLE_RECEIVER_TYPES = [ // `NODE_HANDLE_RECEIVER_TYPES` above states. const EVENT_RECEIVER_TYPES = [ "gea::framework::events::PointerEvent", + // The base interface every event extends, and the parameter `onScroll` and + // `EventTarget.addEventListener(type, listener)` declare. + "Event", "PointerEvent", "TouchEvent", "RotaryEvent", @@ -318,6 +321,11 @@ export function createGeaHostShims(): HostShimDefinitions { PointerEvent: "gea::framework::events::PointerEvent", TouchEvent: "gea::framework::events::PointerEvent", RotaryEvent: "gea::framework::events::PointerEvent", + // The base interface, for `onScroll={e => ...}` and any listener typed by + // `EventTarget.addEventListener`. Without it `e` lowered to a synthesized + // record the listener adapter cannot build from `PointerEvent&`, so the + // app aborted on the first scroll. + Event: "gea::framework::events::PointerEvent", TouchPoint: "gea::framework::events::TouchPoint", // The text-input half of the same join. `onInput={e => ...}` / // `onKeyDown={e => ...}` declare `InputEvent` / `KeyEvent` (index.d.ts), @@ -1613,6 +1621,15 @@ export function createGeaHostShims(): HostShimDefinitions { // member spellings, so emission refused `TouchEvent.clientX` by name -- // the fail-closed guard reporting, correctly, that this table was the // thing missing. + // `Event.type`: the engine keeps the kind as an enum and names it with + // `typeName()` (events.cpp), the same string a listener was bound for. + type: [ + { + emit: "std::string(({receiver}).typeName())", + returnType: "std::string", + receiverTypes: EVENT_RECEIVER_TYPES, + }, + ], pointerId: [ { emit: "static_cast(({receiver}).pointerId)", diff --git a/packages/geatsc-plugin-gea/test/host-shims-protocol.test.mjs b/packages/geatsc-plugin-gea/test/host-shims-protocol.test.mjs index 2e02760..f5ce271 100644 --- a/packages/geatsc-plugin-gea/test/host-shims-protocol.test.mjs +++ b/packages/geatsc-plugin-gea/test/host-shims-protocol.test.mjs @@ -3,6 +3,26 @@ import test from 'node:test' import { createGeaHostShims } from '../dist/host-shims.js' +// A listener parameter declared as any event interface index.d.ts publishes is +// handed the engine's one event struct. `Event` is declared by `onScroll` and by +// `EventTarget.addEventListener(type, listener: (event: Event) => void)`; without +// a carrier it lowered to a synthesized record the runtime listener adapter +// cannot build from a PointerEvent, and the app aborted on the first scroll. +test('every declared event parameter type lowers to the engine event, including Event', () => { + const definitions = createGeaHostShims() + for (const name of ['Event', 'PointerEvent', 'TouchEvent', 'RotaryEvent', 'InputEvent', 'KeyEvent']) { + assert.equal(definitions.nativeTypes?.[name], 'gea::framework::events::PointerEvent', `${name} must lower to the engine event`) + } + for (const member of ['type', 'target', 'currentTarget']) { + const rows = definitions.nativeMemberPropertyGetters?.[member] ?? [] + assert.ok(rows.some(row => row.receiverTypes?.includes('Event')), `Event.${member} must be a native member read`) + } + for (const method of ['preventDefault', 'stopPropagation']) { + const rows = definitions.nativeMemberMethods?.[method] ?? [] + assert.ok(rows.some(row => row.receiverTypes?.includes('Event')), `Event.${method}() must be a native member call`) + } +}) + test('geolocation snapshot operations publish their non-throwing physical contract', () => { const definitions = createGeaHostShims() for (const namespace of ['Geolocation', 'geolocation']) {