From a9538caf5016f8c0c90021eaf28633b15ac86bee Mon Sep 17 00:00:00 2001 From: tettou771 Date: Thu, 16 Jul 2026 18:17:10 +0900 Subject: [PATCH 1/2] feat!: mouse press/release/move bubble up the parent chain (v0.7) - press/release/move now bubble from the front-most hit node to its ancestors until a handler consumes (returns true), matching how scroll has always propagated; the first press consumer becomes the grabbedNode and owns the drag and the matching release - RectNode defaults still consume, so existing UIs are unchanged; return false from onMousePress to let ancestors see the event - secondary windows now dispatch move/drag/scroll/key to the node tree and gate all tree dispatch on !consumed, matching the main window (previously only press/release reached the tree, ungated, so overlay consumers like imgui could not shield the tree) - Window gains dispatchMouseMove/Scroll/KeyPress/KeyReleaseToTree - add core/tests/mouseBubbling: 14 headless behavioral checks (bubble-to-consumer with per-level local coords, grab follows the consumer, unconsumed walk, front-most consumer stops, move bubbles) --- core/include/tc/app/tcWindow.h | 12 ++ core/include/tc/types/tcRectNode.h | 8 +- core/include/tcNode.h | 55 ++++++--- core/platform/linux/tcWindowLinux.cpp | 12 +- core/platform/mac/tcWindowMac.mm | 12 +- core/platform/win/tcWindowWin.cpp | 12 +- core/tests/mouseBubbling/.gitignore | 40 +++++++ core/tests/mouseBubbling/addons.make | 1 + core/tests/mouseBubbling/src/main.cpp | 156 ++++++++++++++++++++++++++ docs/ROADMAP.md | 2 +- docs/reference/api-reference.toml | 6 +- 11 files changed, 291 insertions(+), 25 deletions(-) create mode 100644 core/tests/mouseBubbling/.gitignore create mode 100644 core/tests/mouseBubbling/addons.make create mode 100644 core/tests/mouseBubbling/src/main.cpp diff --git a/core/include/tc/app/tcWindow.h b/core/include/tc/app/tcWindow.h index dc1d15d9..e2cfce42 100644 --- a/core/include/tc/app/tcWindow.h +++ b/core/include/tc/app/tcWindow.h @@ -89,6 +89,18 @@ class TC_PLATFORMS("macos,windows,linux") Window { void dispatchMouseReleaseToTree(const MouseEventArgs& e) { if (ctx_.rootNode) ctx_.rootNode->dispatchMouseRelease(e); } + void dispatchMouseMoveToTree(const internal::MouseEventRaw& e) { + if (ctx_.rootNode) ctx_.rootNode->dispatchMouseMove(e); + } + void dispatchMouseScrollToTree(const ScrollEventArgs& e) { + if (ctx_.rootNode) ctx_.rootNode->dispatchMouseScroll(e); + } + void dispatchKeyPressToTree(const KeyEventArgs& e) { + if (ctx_.rootNode) ctx_.rootNode->dispatchKeyPress(e); + } + void dispatchKeyReleaseToTree(const KeyEventArgs& e) { + if (ctx_.rootNode) ctx_.rootNode->dispatchKeyRelease(e); + } void tickTree() { if (!ctx_.rootNode) return; ctx_.rootNode->updateTree(); diff --git a/core/include/tc/types/tcRectNode.h b/core/include/tc/types/tcRectNode.h index 1d2ea028..ca012288 100644 --- a/core/include/tc/types/tcRectNode.h +++ b/core/include/tc/types/tcRectNode.h @@ -213,11 +213,17 @@ class RectNode : public Node { // These override the rich form and fire the corresponding Event. They also // forward to the simple (Vec2,int) virtual so that a subclass which overrode // the legacy simple form (pre-rich, oF-style) still gets called. + // + // Propagation: press/release/move/scroll all BUBBLE (v0.7) — returning + // false hands the event to the parent chain. RectNode consumes + // press/release/drag by default (return true); override and return false + // to let an ancestor (e.g. a draggable panel behind this label) take the + // gesture. The press consumer becomes the grab target for drag/release. bool onMousePress(const MouseEventArgs& e) override { MouseEventArgs args = e; // already localized to this node mousePressed.notify(args); onMousePress(e.pos, e.button); // legacy subclass hook - return true; // Consume event + return true; // Consume event (return false to bubble to parent) } bool onMouseRelease(const MouseEventArgs& e) override { diff --git a/core/include/tcNode.h b/core/include/tcNode.h index 3243522c..7ea1f136 100644 --- a/core/include/tcNode.h +++ b/core/include/tcNode.h @@ -938,19 +938,37 @@ class Node : public std::enable_shared_from_this { // Dispatch mouse event to tree (for 2D mode). `e` is in screen space // (pos == globalPos); each node receives a copy localized to its space. // return: node that handled event (nullptr if not handled) + // + // Propagation (v0.7): press/release/move BUBBLE like scroll always has — + // starting at the front-most hit node, the event walks the parent chain + // (localized per level, firing node + mod hooks) until a handler consumes + // it by returning true. The first consumer of a press becomes the + // grabbedNode: it owns the drag (fireMouseDrag) and the matching release. + // RectNode's default onMousePress still returns true (consume), so + // bubbling activates only where a handler explicitly returns false — a + // press that previously died silently now reaches the ancestors instead. + // Hover (enter/leave) is unaffected: it stays front-most-only, recomputed + // per frame by updateHoverState(). Ptr dispatchMousePress(const MouseEventArgs& e) { HitResult result = findHitNodeFromScreen(e.globalPos.x, e.globalPos.y); // Selection: clicking a node selects it; clicking empty space clears it. + // (Selection follows the front-most hit, not the consumer — it is a + // debugger/inspector concept, independent of event consumption.) internal::currentWindowContext().selectedNode = result.hit() ? result.node.get() : nullptr; if (result.hit()) { - MouseEventArgs local = result.node->localizeMouse(e); - if (result.node->fireMousePress(local)) { - // Set grabbed node for drag tracking - internal::currentWindowContext().grabbedNode = result.node.get(); - internal::currentWindowContext().grabbedButton = e.button; - return result.node; + // Bubble up from the hit node until consumed + Node* current = result.node.get(); + while (current) { + MouseEventArgs local = current->localizeMouse(e); + if (current->fireMousePress(local)) { + // The consumer grabs the pointer for drag tracking + internal::currentWindowContext().grabbedNode = current; + internal::currentWindowContext().grabbedButton = e.button; + return std::dynamic_pointer_cast(current->shared_from_this()); + } + current = current->getParent().get(); } } @@ -973,13 +991,17 @@ class Node : public std::enable_shared_from_this { return result; } - // Fallback: send to hit node + // Fallback (no grab): bubble from the hit node like press HitResult result = findHitNodeFromScreen(e.globalPos.x, e.globalPos.y); if (result.hit()) { - MouseEventArgs local = result.node->localizeMouse(e); - if (result.node->fireMouseRelease(local)) { - return result.node; + Node* current = result.node.get(); + while (current) { + MouseEventArgs local = current->localizeMouse(e); + if (current->fireMouseRelease(local)) { + return std::dynamic_pointer_cast(current->shared_from_this()); + } + current = current->getParent().get(); } } @@ -994,13 +1016,18 @@ class Node : public std::enable_shared_from_this { internal::currentWindowContext().grabbedNode->fireMouseDrag(internal::toDragArgs(local)); } - // Also send move event to hit node (for hover, etc.) + // Also send move event, bubbling from the hit node (hover itself is + // handled separately by updateHoverState and stays front-most-only) HitResult result = findHitNodeFromScreen(e.globalPos.x, e.globalPos.y); if (result.hit()) { - internal::MouseEventRaw local = result.node->localizeMouse(e); - if (result.node->fireMouseMove(internal::toMoveArgs(local))) { - return result.node; + Node* current = result.node.get(); + while (current) { + internal::MouseEventRaw local = current->localizeMouse(e); + if (current->fireMouseMove(internal::toMoveArgs(local))) { + return std::dynamic_pointer_cast(current->shared_from_this()); + } + current = current->getParent().get(); } } diff --git a/core/platform/linux/tcWindowLinux.cpp b/core/platform/linux/tcWindowLinux.cpp index 43f67011..20e887c7 100644 --- a/core/platform/linux/tcWindowLinux.cpp +++ b/core/platform/linux/tcWindowLinux.cpp @@ -144,7 +144,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { e.syncLegacy(); win->events().mousePressed.notify(e); if (win->getApp()) win->getApp()->mousePressed(e); - win->dispatchMousePressToTree(e); + if (!e.consumed) win->dispatchMousePressToTree(e); break; } case SAPP_EVENTTYPE_MOUSE_UP: { @@ -159,7 +159,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { e.syncLegacy(); win->events().mouseReleased.notify(e); if (win->getApp()) win->getApp()->mouseReleased(e); - win->dispatchMouseReleaseToTree(e); + if (!e.consumed) win->dispatchMouseReleaseToTree(e); break; } case SAPP_EVENTTYPE_MOUSE_ENTER: @@ -176,11 +176,16 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { MouseDragEventArgs e = internal::toDragArgs(raw); win->events().mouseDragged.notify(e); if (win->getApp()) win->getApp()->mouseDragged(e); + raw.consumed = e.consumed; } else { MouseMoveEventArgs e = internal::toMoveArgs(raw); win->events().mouseMoved.notify(e); if (win->getApp()) win->getApp()->mouseMoved(e); + raw.consumed = e.consumed; } + // Node tree: drag goes to the grabbed node, move bubbles from the + // hit node (same dispatchMouseMove path as the main window). + if (!raw.consumed) win->dispatchMouseMoveToTree(raw); break; } case SAPP_EVENTTYPE_MOUSE_LEAVE: { @@ -198,6 +203,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { e.syncLegacy(); win->events().mouseScrolled.notify(e); if (win->getApp()) win->getApp()->mouseScrolled(e); + if (!e.consumed) win->dispatchMouseScrollToTree(e); break; } case SAPP_EVENTTYPE_KEY_DOWN: { @@ -208,6 +214,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { if (!ev->key_repeat) ctx.keysPressed.insert(e.key); win->events().keyPressed.notify(e); if (win->getApp()) win->getApp()->keyPressed(e); + if (!e.consumed) win->dispatchKeyPressToTree(e); break; } case SAPP_EVENTTYPE_KEY_UP: { @@ -218,6 +225,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { ctx.keysPressed.erase(e.key); win->events().keyReleased.notify(e); if (win->getApp()) win->getApp()->keyReleased(e); + if (!e.consumed) win->dispatchKeyReleaseToTree(e); break; } case SAPP_EVENTTYPE_SUSPENDED: diff --git a/core/platform/mac/tcWindowMac.mm b/core/platform/mac/tcWindowMac.mm index 6820e36a..e981c598 100644 --- a/core/platform/mac/tcWindowMac.mm +++ b/core/platform/mac/tcWindowMac.mm @@ -146,7 +146,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { e.syncLegacy(); win->events().mousePressed.notify(e); if (win->getApp()) win->getApp()->mousePressed(e); - win->dispatchMousePressToTree(e); + if (!e.consumed) win->dispatchMousePressToTree(e); break; } case SAPP_EVENTTYPE_MOUSE_UP: { @@ -161,7 +161,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { e.syncLegacy(); win->events().mouseReleased.notify(e); if (win->getApp()) win->getApp()->mouseReleased(e); - win->dispatchMouseReleaseToTree(e); + if (!e.consumed) win->dispatchMouseReleaseToTree(e); break; } case SAPP_EVENTTYPE_MOUSE_ENTER: @@ -178,11 +178,16 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { MouseDragEventArgs e = internal::toDragArgs(raw); win->events().mouseDragged.notify(e); if (win->getApp()) win->getApp()->mouseDragged(e); + raw.consumed = e.consumed; } else { MouseMoveEventArgs e = internal::toMoveArgs(raw); win->events().mouseMoved.notify(e); if (win->getApp()) win->getApp()->mouseMoved(e); + raw.consumed = e.consumed; } + // Node tree: drag goes to the grabbed node, move bubbles from the + // hit node (same dispatchMouseMove path as the main window). + if (!raw.consumed) win->dispatchMouseMoveToTree(raw); break; } case SAPP_EVENTTYPE_MOUSE_LEAVE: { @@ -200,6 +205,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { e.syncLegacy(); win->events().mouseScrolled.notify(e); if (win->getApp()) win->getApp()->mouseScrolled(e); + if (!e.consumed) win->dispatchMouseScrollToTree(e); break; } case SAPP_EVENTTYPE_KEY_DOWN: { @@ -210,6 +216,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { if (!ev->key_repeat) ctx.keysPressed.insert(e.key); win->events().keyPressed.notify(e); if (win->getApp()) win->getApp()->keyPressed(e); + if (!e.consumed) win->dispatchKeyPressToTree(e); break; } case SAPP_EVENTTYPE_KEY_UP: { @@ -220,6 +227,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { ctx.keysPressed.erase(e.key); win->events().keyReleased.notify(e); if (win->getApp()) win->getApp()->keyReleased(e); + if (!e.consumed) win->dispatchKeyReleaseToTree(e); break; } case SAPP_EVENTTYPE_SUSPENDED: diff --git a/core/platform/win/tcWindowWin.cpp b/core/platform/win/tcWindowWin.cpp index 83156e66..bcbe6567 100644 --- a/core/platform/win/tcWindowWin.cpp +++ b/core/platform/win/tcWindowWin.cpp @@ -144,7 +144,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { e.syncLegacy(); win->events().mousePressed.notify(e); if (win->getApp()) win->getApp()->mousePressed(e); - win->dispatchMousePressToTree(e); + if (!e.consumed) win->dispatchMousePressToTree(e); break; } case SAPP_EVENTTYPE_MOUSE_UP: { @@ -159,7 +159,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { e.syncLegacy(); win->events().mouseReleased.notify(e); if (win->getApp()) win->getApp()->mouseReleased(e); - win->dispatchMouseReleaseToTree(e); + if (!e.consumed) win->dispatchMouseReleaseToTree(e); break; } case SAPP_EVENTTYPE_MOUSE_ENTER: @@ -176,11 +176,16 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { MouseDragEventArgs e = internal::toDragArgs(raw); win->events().mouseDragged.notify(e); if (win->getApp()) win->getApp()->mouseDragged(e); + raw.consumed = e.consumed; } else { MouseMoveEventArgs e = internal::toMoveArgs(raw); win->events().mouseMoved.notify(e); if (win->getApp()) win->getApp()->mouseMoved(e); + raw.consumed = e.consumed; } + // Node tree: drag goes to the grabbed node, move bubbles from the + // hit node (same dispatchMouseMove path as the main window). + if (!raw.consumed) win->dispatchMouseMoveToTree(raw); break; } case SAPP_EVENTTYPE_MOUSE_LEAVE: { @@ -198,6 +203,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { e.syncLegacy(); win->events().mouseScrolled.notify(e); if (win->getApp()) win->getApp()->mouseScrolled(e); + if (!e.consumed) win->dispatchMouseScrollToTree(e); break; } case SAPP_EVENTTYPE_KEY_DOWN: { @@ -208,6 +214,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { if (!ev->key_repeat) ctx.keysPressed.insert(e.key); win->events().keyPressed.notify(e); if (win->getApp()) win->getApp()->keyPressed(e); + if (!e.consumed) win->dispatchKeyPressToTree(e); break; } case SAPP_EVENTTYPE_KEY_UP: { @@ -218,6 +225,7 @@ void windowEvent(const sapp_event* ev, sapp_window swin, void* user) { ctx.keysPressed.erase(e.key); win->events().keyReleased.notify(e); if (win->getApp()) win->getApp()->keyReleased(e); + if (!e.consumed) win->dispatchKeyReleaseToTree(e); break; } case SAPP_EVENTTYPE_SUSPENDED: diff --git a/core/tests/mouseBubbling/.gitignore b/core/tests/mouseBubbling/.gitignore new file mode 100644 index 00000000..f16d06dc --- /dev/null +++ b/core/tests/mouseBubbling/.gitignore @@ -0,0 +1,40 @@ +# ============================================================================= +# TrussC Project .gitignore +# ============================================================================= + +# Generated by projectGenerator (regenerate with projectGenerator update) +CMakeLists.txt +CMakePresets.json + +# TrussC local config (path override, generated by projectGenerator) +.trussc + +# Build directories +build/ +build-*/ +emscripten/ +xcode*/ +vs/ + +# Build scripts (generated, OS dependent) +build-web.* + +# Binary output (keep data folder) +bin/* +!bin/data/ + +# IDE specific +.vscode/ +.vs/ +.cache/ + +# Generated shader headers (rebuilt by CMake) +*.glsl.h + +# OS specific +.DS_Store +Thumbs.db + +# Secrets (don't commit these!) +.env +secrets.* diff --git a/core/tests/mouseBubbling/addons.make b/core/tests/mouseBubbling/addons.make new file mode 100644 index 00000000..dc9fc4ff --- /dev/null +++ b/core/tests/mouseBubbling/addons.make @@ -0,0 +1 @@ +# TrussC addons - one addon per line diff --git a/core/tests/mouseBubbling/src/main.cpp b/core/tests/mouseBubbling/src/main.cpp new file mode 100644 index 00000000..5c3839a3 --- /dev/null +++ b/core/tests/mouseBubbling/src/main.cpp @@ -0,0 +1,156 @@ +// ============================================================================= +// mouseBubbling — behavioral regression test for input propagation (v0.7) +// +// press/release/move bubble from the front-most hit node up the parent chain +// until a handler consumes (returns true); the press consumer becomes the +// grab target for drag/release. Headless: drives Node::dispatch* directly +// through an App subclass (friend access), no window required. +// ============================================================================= + +#include + +#include +#include +#include + +using namespace std; +using namespace tc; + +static int g_fail = 0; +static void check(const char* name, bool ok) { + std::printf("%-64s %s\n", name, ok ? "PASS" : "FAIL"); + std::fflush(stdout); + if (!ok) ++g_fail; +} + +// A RectNode that records calls and consumes on demand. +class Probe : public RectNode { +public: + explicit Probe(bool consume) : consume_(consume) { + enableEvents(); // opt in to hit testing (off by default on RectNode) + } + bool onMousePress(const MouseEventArgs& e) override { + pressCount++; lastLocal = e.pos; + RectNode::onMousePress(e); // fire events / legacy hooks + return consume_; + } + bool onMouseRelease(const MouseEventArgs& e) override { + releaseCount++; + RectNode::onMouseRelease(e); + return consume_; + } + bool onMouseMove(const MouseMoveEventArgs& e) override { + moveCount++; + RectNode::onMouseMove(e); + return consume_; + } + bool onMouseDrag(const MouseDragEventArgs& e) override { + dragCount++; + RectNode::onMouseDrag(e); + return consume_; + } + int pressCount = 0, releaseCount = 0, moveCount = 0, dragCount = 0; + Vec2 lastLocal {}; + bool consume_; +}; + +// Dispatch driver: a headless Window is the friend-access doorway into +// Node's private dispatch functions (its ToTree helpers are public and the +// platform glue uses the same path). +class Driver { +public: + Driver() { + prev_ = tc::internal::currentWindowCtx; + tc::internal::currentWindowCtx = &win_.context(); // grab state lands here + } + ~Driver() { + win_.context().rootNode = nullptr; + tc::internal::currentWindowCtx = prev_; + } + void setRoot(Node* root) { win_.context().rootNode = root; } + tc::internal::WindowContext& ctx() { return win_.context(); } + + void press(float x, float y, int button = 0) { + MouseEventArgs e; + e.globalPos = Vec2(x, y); e.pos = e.globalPos; e.button = button; + e.syncLegacy(); + win_.dispatchMousePressToTree(e); + } + void release(float x, float y, int button = 0) { + MouseEventArgs e; + e.globalPos = Vec2(x, y); e.pos = e.globalPos; e.button = button; + e.syncLegacy(); + win_.dispatchMouseReleaseToTree(e); + } + void move(float x, float y) { + internal::MouseEventRaw e; + e.globalPos = Vec2(x, y); e.pos = e.globalPos; + e.globalDelta = Vec2(1, 0); e.delta = e.globalDelta; + win_.dispatchMouseMoveToTree(e); + } +private: + Window win_; + tc::internal::WindowContext* prev_ = nullptr; +}; + +int main() { + Driver drv; + + // Tree: root(0,0,800,600) > panel(100,100,400,300) > label(50,50,100,40) + // (positions are parent-relative; Probe enables events in its ctor) + auto root = make_shared(false); + root->setPos(0, 0); root->setSize(800, 600); + auto panel = make_shared(true); // consumes (a widget) + panel->setPos(100, 100); panel->setSize(400, 300); + auto label = make_shared(false); // does NOT consume (decorative) + label->setPos(50, 50); label->setSize(100, 40); + root->addChild(panel); + panel->addChild(label); + + drv.setRoot(root.get()); + auto& ctx = drv.ctx(); + + // --- 1. press on the label bubbles to the consuming panel ------------- + // label global rect = (150,150)-(250,190); hit (170, 160) + drv.press(170, 160); + check("press on non-consuming label reaches it first", label->pressCount == 1); + check("press bubbles to the consuming panel", panel->pressCount == 1); + check("root not reached (panel consumed)", root->pressCount == 0); + check("panel got PANEL-local coordinates", + panel->lastLocal.x == 70.0f && panel->lastLocal.y == 60.0f); + check("grab goes to the consumer (panel)", ctx.grabbedNode == panel.get()); + + // --- 2. drag + release follow the grab -------------------------------- + drv.move(180, 165); + check("drag follows the grabbed panel", panel->dragCount == 1); + check("label gets no drag", label->dragCount == 0); + drv.release(180, 165); + check("release goes to the grabbed panel", panel->releaseCount == 1); + check("grab cleared after release", ctx.grabbedNode == nullptr); + + // --- 3. nobody consumes: event bubbles through and dies --------------- + label->consume_ = false; panel->consume_ = false; + int rootBefore = root->pressCount; + drv.press(170, 160); + check("press walks the whole chain (root reached)", root->pressCount == rootBefore + 1); + check("unconsumed press grabs nothing", ctx.grabbedNode == nullptr); + + // --- 4. front-most consumer stops the walk ---------------------------- + label->consume_ = true; + int panelBefore = panel->pressCount; + drv.press(170, 160); + check("consuming label stops the bubble (grabs the pointer)", ctx.grabbedNode == label.get()); + check("panel not reached when label consumes", panel->pressCount == panelBefore); + drv.release(170, 160); + + // --- 5. move bubbles like scroll --------------------------------------- + label->consume_ = false; panel->consume_ = true; + int panelMoves = panel->moveCount; + drv.move(170, 160); + check("move bubbles past the label to the panel", panel->moveCount == panelMoves + 1); + + std::printf("\n%s (%d failure%s)\n", g_fail ? "FAILED" : "PASSED", + g_fail, g_fail == 1 ? "" : "s"); + std::fflush(stdout); + return g_fail ? 1 : 0; +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 829a5a9e..d0b9a410 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,7 +20,7 @@ | Audio device hot-plug handling | Detect device disconnect (USB DAC unplugged etc.) via miniaudio's `ma_device_notification_proc`. On detection, auto-fail-over to the system default device, then fire `AudioEngine::audioDeviceChanged` with the new device's info (today that event only fires from `init()` — initial + user re-init — not from unplug). Listeners can override the fallback by calling `init(settings)` themselves. Without this, calling `init(settings)` to switch devices after a hot-unplug will hang in `ma_device_uninit` waiting for the dead device's audio thread to join. Likely also wants a `cause` enum on `AudioDeviceChangedArgs` (InitialInit / UserRequest / DeviceDisconnect) so listeners can distinguish event sources. | Medium | | Custom-shader textured quad path (sgl-integrated) | A TrussC-level drawing path that issues `sg_apply_pipeline` + `sg_apply_bindings` + `sg_draw` with a user-supplied shader, while staying ordered with respect to surrounding `sokol_gl` commands. Built on the existing `suspendSwapchainPass()` / `resumeSwapchainPass()` machinery (already used by FBO), plus a partial-flush of the default sgl context so a `drawRect → font/customShader → drawCircle` sequence renders in submission order. Unlocks: R8 / RG8 / non-RGBA texture formats (current sgl shader forces RGBA8 with `(255,255,255,A)` padding), font atlas R8 storage (see below), sensor-data visualization (depth/IR/grayscale with palette mapping), SDF text, mask compositing, future Path glyph rendering. Probably surfaces as `Texture::drawWith(Shader&, ...)` or similar on the TrussC `gpu/` side; sgl_gl_tc fork ideally stays untouched. | High | | Font atlas R8 storage | Drop font atlas from RGBA8 to R8 (4× memory reduction — e.g. ~4 MB → ~1 MB on a large CJK atlas). Currently blocked on the custom-shader path above: the sgl built-in shader returns `(R, 0, 0, 1)` from an R8 sample, which renders as solid red instead of `(color.rgb, glyph.a)`. Once that path lands, swap the atlas pixel format + add a tiny font-specific fragment shader doing `out = vec4(uColor.rgb, sample.r * uColor.a)`. No quality loss (glyphs were already grayscale alpha stored as `(255,255,255,A)`). Sokol R8 is native on all current backends (Metal / D3D11 / GL 3.3 / GLES3 / WebGL2 / WebGPU — GLES2 / WebGL1 already dropped). Pair with this PR cycle's tategaki work to make Japanese / CJK use cases cheap on RAM. | Low (once shader path exists) | -| Mouse event bubbling (press/release/drag) — **v0.7.0, breaking** | Unify input propagation with the scroll path, which already bubbles ("return false to allow bubbling to parent", see `RectNode::onMouseScroll`). Today `dispatchMousePress` delivers to the front-most hit node ONLY — if its `fireMousePress` returns false the event silently dies (no ancestor walk, no occluded-sibling fallback), so press/release/drag and scroll follow different rules. Plan: when the hit node does not consume, walk the parent chain (localizing via `localizeMouse` per level, firing node + mod hooks), and the first consumer becomes `grabbedNode` for drag tracking. Keep `RectNode::onMousePress`'s `return true` (consume) default, so bubbling activates only where a handler explicitly returns false — a path that today just swallows the event, making the change near-compatible in practice but still semantically breaking (hence the 0.7.0 window). Kills the current anti-pattern where a child must know its parent to forward input (contradicts the "dependencies point downward only" rule) and unblocks drag-through-children cases (grab a window by its title bar across label children; drag-scroll a list over its buttons). Watch mouse enter/leave consistency along the chain. **Considered and deferred, revisit after bubbling lands:** flipping hit-testing to default-ON ("visible RectNode catches clicks", DOM/Unity-style, opt-out via `disableEvents()`). Only sane as a package with bubbling + consume-by-choice (make `RectNode::onMousePress` return whether a listener consumed instead of unconditional true); default-ON alone under single-target dispatch would make every decorative label/separator child swallow its parent widget's clicks. Perf is a non-issue either way: the hit-test walk already visits all active+visible nodes (Mat4 inverse + children snapshot per node) and `eventsEnabled_` only gates the final rect test. | Medium | +| Hit-testing default-ON (deferred from the bubbling work) | Bubbling itself SHIPPED in v0.7: press/release/move bubble like scroll (return false from a handler to hand the event to the parent chain; the press consumer becomes the grab target for drag/release — see `Node::dispatchMousePress`). What remains deferred is flipping hit-testing to default-ON ("visible RectNode catches clicks", DOM/Unity-style, opt-out via `disableEvents()`). Only sane as a package with consume-by-choice (make `RectNode::onMousePress` return whether a listener consumed instead of unconditional true); default-ON alone would make every decorative label/separator child swallow its parent widget's clicks — even WITH bubbling, a default-consuming label still eats the press. Perf is a non-issue: the hit-test walk already visits all active+visible nodes and `eventsEnabled_` only gates the final rect test. | Medium | | Coroutine sequencing (`wait` / `tween` / `event` awaiters) | Write time-spanning procedures as procedures: `move(); wait(1); move();` instead of callback chains or hand-rolled state machines in `update()`. Two layers, cheapest first. **Lua (TrussSketch)**: Lua coroutines are native — the host only needs a scheduler that resumes yielded coroutines next frame / after N seconds; `wait(1)` = sugar over `coroutine.yield(1)`. Scratch-style sequencing lands in sketches within days of work. **C++20**: TrussC already requires C++20, so `co_await` is available — a `Task` type + frame-loop-driven awaiters (`co_await wait(1.0)`, `co_await tween(...)`, `co_await event(node.mousePressed)` = "suspend until clicked"). Big win for installation flow control (idle → attract → interact → reset). Ship Lua first, stabilize the C++ API shape against real sketch usage. Purely additive — no existing path changes. | Medium (Lua) / High (C++) | | ScreenRecorder audio track | Record audio (app output and/or mic) into the video file alongside the frames. **The one item in this batch with real regression surface**: it modifies the working per-platform encode pipelines (AVFoundation / Media Foundation / ...) to mux an audio track with correct A/V sync — so it should ship as its own release, verified on real hardware per platform, not bundled with other work. Pairs naturally with the real-time audio events (`audioOut` already exposes the exact buffers to record). | Medium-High | | Audio analysis: onset / beat / Mel bands | FFT already exists (`tcFFT.h`); layer the standard music-visualization trio on top: **onset detection** (spectral flux — the moment a drum hit / note attack happens), **beat tracking** (period estimation over the onset train → BPM + phase), **Mel-band energies** (perceptual frequency bands, the usual input for reactive visuals). Makes "visuals that react tightly to music" a one-liner — a workshop staple that currently requires hand-rolling. Additive on the existing FFT chain. | Medium | diff --git a/docs/reference/api-reference.toml b/docs/reference/api-reference.toml index 0dd11594..bfd0f1ed 100644 --- a/docs/reference/api-reference.toml +++ b/docs/reference/api-reference.toml @@ -16638,9 +16638,9 @@ description.ja = "描画スコープを終了し、クリップ領域をポッ description.ko = "그리기 스코프를 종료하고 클립 영역을 팝한다." ["RectNode::onMousePress"] -description.en = "Fire the mousePressed event and forward to the legacy simple-form hook." -description.ja = "mousePressed イベントを発火し、旧来の単純フォームのフックへ転送する。" -description.ko = "mousePressed 이벤트를 발생시키고 레거시 단순 폼 훅으로 전달한다." +description.en = "Fire the mousePressed event and forward to the legacy simple-form hook. Returns true (consume); return false from an override to bubble the press to the parent chain — the consumer becomes the drag/release grab target." +description.ja = "mousePressed イベントを発火し、旧来の単純フォームのフックへ転送する。デフォルトは true(消費)。オーバーライドで false を返すと親へバブリングし、消費したノードがドラッグ/リリースのグラブ先になる。" +description.ko = "mousePressed 이벤트를 발생시키고 레거시 단순 폼 훅으로 전달한다. 기본은 true(소비). 오버라이드에서 false를 반환하면 부모로 버블링되며, 소비한 노드가 드래그/릴리스의 그랩 대상이 된다." ["RectNode::onMouseRelease"] description.en = "Fire the mouseReleased event and forward to the legacy simple-form hook." From d932c3013995ff051401427c7a61b08aa32405fa Mon Sep 17 00:00:00 2001 From: tettou771 Date: Thu, 16 Jul 2026 22:54:04 +0900 Subject: [PATCH 2/2] doc: hide Window tree-dispatch helpers from the reference (internal glue) --- docs/reference/api-reference.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/reference/api-reference.toml b/docs/reference/api-reference.toml index bfd0f1ed..b63dd3f8 100644 --- a/docs/reference/api-reference.toml +++ b/docs/reference/api-reference.toml @@ -10938,6 +10938,22 @@ keywords = ["internal"] hide = true keywords = ["internal"] +["Window::dispatchMouseMoveToTree"] +hide = true +keywords = ["internal"] + +["Window::dispatchMouseScrollToTree"] +hide = true +keywords = ["internal"] + +["Window::dispatchKeyPressToTree"] +hide = true +keywords = ["internal"] + +["Window::dispatchKeyReleaseToTree"] +hide = true +keywords = ["internal"] + ["Window::syncRootSize"] hide = true keywords = ["internal"]