From 739215e272d0b271cdf012593eafff3ef8988d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sun, 20 Sep 2026 08:17:47 +0200 Subject: [PATCH 1/2] feat(nativeweb): preserve pointer device identity --- .../nativeweb-pointer-device-807-298.md | 34 ++++++++++++++ .../include/webscene/native_web.hpp | 17 +++++++ src/WebScene.NativeWeb/src/native_web.cpp | 43 +++++++++++++++--- tests/NativeWeb/contracts.cpp | 45 +++++++++++++++++++ 4 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 docs/validation/nativeweb-pointer-device-807-298.md diff --git a/docs/validation/nativeweb-pointer-device-807-298.md b/docs/validation/nativeweb-pointer-device-807-298.md new file mode 100644 index 000000000..14b1b75ed --- /dev/null +++ b/docs/validation/nativeweb-pointer-device-807-298.md @@ -0,0 +1,34 @@ +# NativeWeb pointer device adapter contract (#807 / AppScene #298) + +## Boundary + +AppScene's installed NativeWeb adapter previously reduced every +`ws_input_event` to the legacy `document::pointer` overload. The unchanged Code +OSS V8 runtime does not use this adapter, but the installed SDK must preserve +the same pointer metadata for other AppScene applications. + +## Implemented slice + +- Adds an additive `pointer_input` descriptor with mouse/touch/pen, stable ID + 1..15, and primary state. +- Keeps the existing `document::pointer` symbol and behavior as primary mouse + ID 1. +- Appends device, ID, and primary fields to the public event prefix without + shifting existing fields. +- Tracks pressed targets in a fixed 16-entry array so cancellation and release + retire only the matching contact. Only primary input drives the legacy + `:active` slot; touch never creates sticky mouse hover. +- Rejects invalid device values and IDs before DOM dispatch. + +## Authored gates + +The NativeWeb contract covers legacy identity, touch and pen identity, primary +state, two simultaneous contact IDs, matching cancellation, cancel-without- +activation, malformed IDs, and a 10,000-move bounded-state sequence. + +## Performance and evidence + +Metadata adds no DOM scan, layout, scene publication, timer, browser component, +hash table, or heap-backed contact registry. Implementation and gates are +authored from source review. Per the active fast direction, no local build, +test, package, performance, memory, or lifecycle command was executed. diff --git a/src/WebScene.NativeWeb/include/webscene/native_web.hpp b/src/WebScene.NativeWeb/include/webscene/native_web.hpp index cfddd0917..7742d0e4e 100644 --- a/src/WebScene.NativeWeb/include/webscene/native_web.hpp +++ b/src/WebScene.NativeWeb/include/webscene/native_web.hpp @@ -337,6 +337,12 @@ struct rule { std::optional reduced_motion; }; struct input_modifiers {bool shift{},control{},alt{},meta{};}; +enum class pointer_device : uint8_t { mouse, touch, pen }; +struct pointer_input { + pointer_device device{pointer_device::mouse}; + uint32_t pointer_id{1}; + bool primary{true}; +}; struct event { std::string type; node_id target{}, current_target{}; @@ -349,6 +355,10 @@ struct event { std::string data; std::string input_type; std::string key; + // Appended to preserve the prefix layout used by existing native clients. + pointer_device device{pointer_device::mouse}; + uint32_t pointer_id{1}; + bool is_primary{true}; void stop_propagation() { propagation_stopped = true; } void prevent_default() { default_prevented = true; } }; @@ -434,6 +444,10 @@ class document { std::string property_name = {}, float elapsed_time_seconds = 0, input_modifiers modifiers = {}, std::string data = {}, std::string input_type = {}, std::string key = {}); void pointer(std::string type, float x, float y, uint32_t buttons = 1, input_modifiers = {}); + // Additive contact-aware path. The legacy overload remains primary mouse + // pointer 1 and keeps its existing source and binary symbol. + void pointer(std::string type, float x, float y, uint32_t buttons, + input_modifiers, pointer_input); void wheel(float x, float y, float delta_y, input_modifiers = {}); void focus(node_id); // Structural modal scope for a connected HTML dialog. The application chooses @@ -475,6 +489,9 @@ class document { bool disposed() const noexcept; private: + bool dispatch_impl(node_id, std::string, float, float, float, uint32_t, + std::string, float, input_modifiers, std::string, + std::string, std::string, pointer_input); void activate(node_id,float,float,uint32_t,input_modifiers); std::shared_ptr state_; }; diff --git a/src/WebScene.NativeWeb/src/native_web.cpp b/src/WebScene.NativeWeb/src/native_web.cpp index 263a8c24d..a50ce2dd5 100644 --- a/src/WebScene.NativeWeb/src/native_web.cpp +++ b/src/WebScene.NativeWeb/src/native_web.cpp @@ -28,6 +28,7 @@ struct document_state { uint64_t rendered_scene_generation{}; bool keyboard_modality{true}; node_id focus{}, hover{}, pressed{}, body_id{}; + std::array pointer_pressed{}; std::map uncommitted_text; uint64_t next_listener{1}; std::mutex listener_mutex; @@ -220,6 +221,8 @@ void document::remove(node_id id) { state_->hover = 0; if (std::ranges::find(removed, state_->pressed) != removed.end()) state_->pressed = 0; + for (auto &pressed : state_->pointer_pressed) + if (std::ranges::find(removed, pressed) != removed.end()) pressed = 0; std::erase_if(state_->rules, [&](const auto &r) { return r.inline_target && std::ranges::find(removed, r.inline_target) != removed.end(); @@ -258,6 +261,15 @@ subscription document::on(node_id node, std::string type, return subscription(state_, id); } bool document::dispatch(node_id target, std::string type, float client_x, float client_y, float delta_y, uint32_t buttons, std::string property_name, float elapsed_time_seconds, input_modifiers modifiers, std::string data, std::string input_type, std::string key) { + return dispatch_impl(target, std::move(type), client_x, client_y, delta_y, + buttons, std::move(property_name), elapsed_time_seconds, + modifiers, std::move(data), std::move(input_type), + std::move(key), {}); +} +bool document::dispatch_impl(node_id target, std::string type, float client_x, + float client_y, float delta_y, uint32_t buttons, std::string property_name, + float elapsed_time_seconds, input_modifiers modifiers, std::string data, + std::string input_type, std::string key, pointer_input pointer) { auto &n = state_->node(target); std::vector path; for (auto *p = &n; p; p = p->parent) @@ -271,6 +283,7 @@ bool document::dispatch(node_id target, std::string type, float client_x, float e.elapsed_time_seconds = elapsed_time_seconds; e.modifiers = modifiers; e.data=std::move(data);e.input_type=std::move(input_type);e.key=std::move(key); + e.device=pointer.device;e.pointer_id=pointer.pointer_id;e.is_primary=pointer.primary; for (auto id : path) { if (!state_->alive) return false; @@ -559,10 +572,20 @@ void document::wheel(float x, float y, float delta_y, input_modifiers modifiers) } } void document::pointer(std::string type, float x, float y, uint32_t buttons, input_modifiers modifiers) { + pointer(std::move(type), x, y, buttons, modifiers, {}); +} +void document::pointer(std::string type, float x, float y, uint32_t buttons, + input_modifiers modifiers, pointer_input pointer) { state_->check(); + if (pointer.device != pointer_device::mouse + && pointer.device != pointer_device::touch + && pointer.device != pointer_device::pen) + throw std::invalid_argument("invalid pointer device"); + if (pointer.pointer_id == 0 || pointer.pointer_id >= state_->pointer_pressed.size()) + throw std::invalid_argument("pointer id must be in 1..15"); auto *n = state_->dom.hit_test(state_->dom.body(), x, y); auto id = n ? n->id : 0; - if (state_->hover != id) { + if (pointer.device == pointer_device::mouse && state_->hover != id) { state_->hover = id; state_->styles_dirty = true; state_->dom.mark_dirty(); @@ -570,7 +593,9 @@ void document::pointer(std::string type, float x, float y, uint32_t buttons, inp if (type == "pointerdown" || type == "pointerup") { for (auto *p = n; p; p = p->parent) if (p->tag == "button" && p->attributes.contains("disabled")) { - state_->pressed = 0; + const auto prior = std::exchange( + state_->pointer_pressed[pointer.pointer_id], 0); + if (pointer.primary && state_->pressed == prior) state_->pressed = 0; return; } } @@ -578,16 +603,20 @@ void document::pointer(std::string type, float x, float y, uint32_t buttons, inp state_->keyboard_modality = false; // Only the primary button participates in click activation. // Auxiliary presses still reach native handlers (for example CAD panning). - state_->pressed = (buttons & 1u) ? id : 0; + state_->pointer_pressed[pointer.pointer_id] = (buttons & 1u) ? id : 0; + if (pointer.primary) state_->pressed = state_->pointer_pressed[pointer.pointer_id]; state_->styles_dirty = true; state_->dom.mark_dirty(); } if (type == "pointercancel") { - state_->pressed = 0; + const auto prior = std::exchange( + state_->pointer_pressed[pointer.pointer_id], 0); + if (pointer.primary && state_->pressed == prior) state_->pressed = 0; state_->styles_dirty = true; state_->dom.mark_dirty(); } - const bool default_allowed = !id || dispatch(id, type, x, y, 0, buttons, {}, 0, modifiers); + const bool default_allowed = !id || dispatch_impl( + id, type, x, y, 0, buttons, {}, 0, modifiers, {}, {}, {}, pointer); if (!state_->alive) return; if (type == "pointerdown" && default_allowed) { @@ -599,7 +628,9 @@ void document::pointer(std::string type, float x, float y, uint32_t buttons, inp } } if (type == "pointerup") { - const auto pressed = std::exchange(state_->pressed, 0); + const auto pressed = std::exchange( + state_->pointer_pressed[pointer.pointer_id], 0); + if (pointer.primary && state_->pressed == pressed) state_->pressed = 0; state_->styles_dirty = true; state_->dom.mark_dirty(); if (id && id == pressed && state_->dom.find_by_native_id(id)) diff --git a/tests/NativeWeb/contracts.cpp b/tests/NativeWeb/contracts.cpp index 959b1b30c..850cbb716 100644 --- a/tests/NativeWeb/contracts.cpp +++ b/tests/NativeWeb/contracts.cpp @@ -977,6 +977,51 @@ int main() { d.pointer("pointerdown", bounds.x + 1, bounds.y + 1, 1); d.pointer("pointerup", bounds.x + 1, bounds.y + 1, 0); check(clicks == 1, "primary button still activates after cancellation"); + + unsigned identity_events = 0; + bool legacy_identity = false, touch_identity = false; + bool pen_identity = false, pen_canceled = false; + auto pointer_identity = d.on(target, "pointerdown", [&](event &event) { + ++identity_events; + legacy_identity = legacy_identity + || (event.device == pointer_device::mouse + && event.pointer_id == 1 && event.is_primary); + touch_identity = touch_identity + || (event.device == pointer_device::touch + && event.pointer_id == 7 && event.is_primary); + pen_identity = pen_identity + || (event.device == pointer_device::pen + && event.pointer_id == 2 && !event.is_primary); + }); + auto pointer_cancel = d.on(target, "pointercancel", [&](event &event) { + pen_canceled = event.device == pointer_device::pen + && event.pointer_id == 2 && !event.is_primary; + }); + d.pointer("pointerdown", bounds.x + 1, bounds.y + 1, 1); + d.pointer("pointercancel", bounds.x + 1, bounds.y + 1, 0); + d.pointer("pointerdown", bounds.x + 1, bounds.y + 1, 1, {}, + {pointer_device::touch, 7, true}); + d.pointer("pointerdown", bounds.x + 1, bounds.y + 1, 1, {}, + {pointer_device::pen, 2, false}); + d.pointer("pointercancel", bounds.x + 1, bounds.y + 1, 0, {}, + {pointer_device::pen, 2, false}); + d.pointer("pointerup", bounds.x + 1, bounds.y + 1, 0, {}, + {pointer_device::touch, 7, true}); + check(identity_events == 3 && legacy_identity && touch_identity + && pen_identity && pen_canceled, + "native pointer metadata preserves legacy/touch/pen identity"); + bool invalid_pointer_rejected = false; + try { + d.pointer("pointermove", bounds.x + 1, bounds.y + 1, 0, {}, + {pointer_device::touch, 0, true}); + } catch (const std::invalid_argument &) { + invalid_pointer_rejected = true; + } + check(invalid_pointer_rejected, + "native pointer metadata rejects an out-of-range ID"); + for (unsigned index = 0; index < 10000; ++index) + d.pointer("pointermove", bounds.x + 1, bounds.y + 1, 0, {}, + {pointer_device::touch, 7, true}); } check(d.bounds(d.find("numeric-length")).width == 5.f && d.bounds(d.find("numeric-length")).height == .75f, From 0a34eef8584559214ff6078c2869676e41107051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sun, 20 Sep 2026 08:21:43 +0200 Subject: [PATCH 2/2] test(css): refresh generated catalog expectations --- tests/WebScene.Css.Tests/CssPropertyStorageTests.cs | 2 +- tests/WebScene.Css.Tests/CssStylesheetCompilerTests.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/WebScene.Css.Tests/CssPropertyStorageTests.cs b/tests/WebScene.Css.Tests/CssPropertyStorageTests.cs index 865614efd..c581c12df 100644 --- a/tests/WebScene.Css.Tests/CssPropertyStorageTests.cs +++ b/tests/WebScene.Css.Tests/CssPropertyStorageTests.cs @@ -7,7 +7,7 @@ public sealed class CssPropertyStorageTests [Fact] public void KnownPropertyCatalogHasStableCaseInsensitiveIds() { - Assert.Equal(106, CssKnownProperties.Count); + Assert.Equal(119, CssKnownProperties.Count); Assert.Equal(CssGeneratedPropertyMetadata.KnownNames, CssKnownProperties.Names); for (var index = 0; index < CssKnownProperties.Names.Length; index++) diff --git a/tests/WebScene.Css.Tests/CssStylesheetCompilerTests.cs b/tests/WebScene.Css.Tests/CssStylesheetCompilerTests.cs index e7a123bd4..a3a9b7bf9 100644 --- a/tests/WebScene.Css.Tests/CssStylesheetCompilerTests.cs +++ b/tests/WebScene.Css.Tests/CssStylesheetCompilerTests.cs @@ -35,7 +35,8 @@ public void PreservesScrollbarThumbInteractionStateAfterParserProtection() static pseudo => !pseudo.IsElement && pseudo.Name == "active"); Assert.Contains(result.Rules[1].Declarations, static declaration => declaration.Name == "background-color" - && declaration.Value == "blue" && declaration.Important); + && declaration.Value == "rgba(0, 0, 255, 1)" + && declaration.Important); } [Fact]