From 0f484b78bbefbf744f14c259ead77178f09dba55 Mon Sep 17 00:00:00 2001 From: Wang Zichong Date: Tue, 7 Jul 2026 16:48:44 +0800 Subject: [PATCH] more progress, but some of them are wip --- PLAN.md | 132 ++++-- bridge/CMakeLists.txt | 8 + bridge/src/gammaray_session.cpp | 17 + bridge/src/gammaray_session.h | 8 + bridge/src/main.cpp | 29 +- bridge/src/material_interface.cpp | 54 +++ bridge/src/material_interface.h | 59 +++ bridge/src/quickinspector_types.h | 50 +++ bridge/src/scenegraph_tools.cpp | 641 ++++++++++++++++++++++++++++-- bridge/src/scenegraph_tools.h | 81 ++-- 10 files changed, 986 insertions(+), 93 deletions(-) create mode 100644 bridge/src/material_interface.cpp create mode 100644 bridge/src/material_interface.h create mode 100644 bridge/src/quickinspector_types.h diff --git a/PLAN.md b/PLAN.md index a3f7e64..695bbc0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -333,32 +333,89 @@ Map GammaRay client capabilities to MCP tool calls. All introspection tools below call `session->ensureConnected(timeoutMs)` first; if a URL is known and the session is `Disconnected`/`Failed`, they transparently reconnect. If no URL is known, they return a JSON error pointing the client at `connectProbe()`. ### Navigation tools -- `list_quick_windows` → read `com.kdab.GammaRay.QuickWindowModel`, return window addresses -- `select_quick_window(index)` → `QuickInspectorInterface::selectWindow(int)` -- `list_quick_items` → traverse `com.kdab.GammaRay.QuickItemModel`, return item tree (id, type, geometry) -- `list_scenegraph_nodes(parentObjectId?)` → traverse `com.kdab.GammaRay.QuickSceneGraphModel`, return node tree (id, type, address) -- `get_scenegraph_node_detail(objectId)` → select node via selection model, read property models +- `listQuickWindows()` → read `com.kdab.GammaRay.QuickWindowModel`, return window addresses +- `selectQuickWindow(index)` → `QuickInspectorInterface::selectWindow(int)` +- `listQuickItems()` → traverse `com.kdab.GammaRay.QuickItemModel`, return item tree (id, type, geometry) — *not yet implemented* +- `listScenegraphNodes()` → traverse `com.kdab.GammaRay.QuickSceneGraphModel`, return node tree (id, type, address). Uses `primeAndWait()` for async fill. +- `selectScenegraphNode(address)` → select node via selection model (currently blocked — see Known issues) ### Geometry tools -- `get_node_vertices(objectId)` → select SG node, read `sgGeometryVertexModel` (vertices: x, y, z, w + render role) -- `get_node_adjacency(objectId)` → read `sgGeometryAdjacencyModel` (drawing mode, vertex indices) +- `getNodeVertices(address)` → select SG node, read `sgGeometryVertexModel` (vertices: x, y, z, w + render role) — *blocked* +- `getNodeAdjacency(address)` → read `sgGeometryAdjacencyModel` (drawing mode, vertex indices) — *blocked* ### Material/Shader tools -- `get_material_shaders(objectId)` → read `shaderModel` for selected node -- `get_shader_source(row)` → `MaterialExtensionInterface::getShader(int)` (async: listen for `gotShader` signal) -- `get_material_properties(objectId)` → read `materialPropertyModel` +- `getMaterialShaders(address)` → read `shaderModel` for selected node — *blocked* +- `getShaderSource(row)` → `MaterialExtensionInterface::getShader(int)` (async: listen for `gotShader` signal) — *implemented, untested end-to-end (blocked upstream)* +- `getMaterialProperties(address)` → read `materialPropertyModel` — *blocked* ### Rendering visualization tools -- `set_render_mode(mode)` → `QuickInspectorInterface::setCustomRenderMode(mode)` where mode ∈ {Normal, VisualizeClipping, VisualizeOverdraw, VisualizeBatches, VisualizeChanges, VisualizeTraces} (quickinspectorinterface.h:52-66) -- `set_slow_mode(bool)` → `QuickInspectorInterface::setSlowMode(bool)` +- `setRenderMode(mode)` → `QuickInspectorInterface::setCustomRenderMode(mode)` where mode ∈ {NormalRendering, VisualizeClipping, VisualizeOverdraw, VisualizeBatches, VisualizeChanges, VisualizeTraces} (quickinspectorinterface.h:52-66). **NOTE**: takes enum NAME string, not int. Values map 1:1 to GammaRay's `RenderMode` enum. +- `setSlowMode(enabled)` → `QuickInspectorInterface::setSlowMode(bool)` ### Texture tools -- `grab_texture(objectId)` → `TextureExtension` (async image grab; image arrives via RemoteViewInterface as TransferImage) +- `grabTexture(objectId)` → `TextureExtension` (async image grab; image arrives via `RemoteViewInterface` as `TransferImage`) — *not yet implemented* -### Implementation status -- ✅ `list_quick_windows` (as `listQuickWindows`), `select_quick_window` (as `selectQuickWindow`), `list_scenegraph_nodes` (as `listScenegraphNodes`) — working end-to-end against a live probe. -- ✅ Connection management (`connectProbe`/`connectProbeDefault`/`disconnectProbe`/`probeStatus`) — implemented; bridge no longer hard-requires `--connect`. -- ⏳ Geometry/Material/Shader/Render/Texture tools — pending. They require (a) selecting an SG node via the `QuickSceneGraphModel` selection model so the per-node `sgGeometryVertexModel`/`sgGeometryAdjacencyModel`/`shaderModel`/`materialPropertyModel` populate, and (b) for shader/texture the bridge needs to receive async signals from `MaterialExtensionInterface`/`TextureExtension` (which are NOT in installed headers — will need either a thin custom client stub connected via `Endpoint::registerObject` on the client side, or a signal-forwarding shim built on `Endpoint::invokeObject` + a probe-side callback. To be designed.) +### Implementation status (updated — see "Known issues / Blocked" section for details on the sub-model population blocker) + +**Working end-to-end (verified via stdio MCP smoke test against a live probe):** +- ✅ `connectProbe(host?, port?)` / `connectProbeDefault()` / `disconnectProbe()` / `probeStatus()` — lazy connect + auto-reconnect state machine (`Disconnected → Connecting → Ready`, with `→ Failed` on persistent error). `disconnectProbe()` clears remembered URL and stops auto-reconnect. +- ✅ `listQuickWindows()` — reads `QuickWindowModel`, returns `[{address, type}]`. +- ✅ `selectQuickWindow(index)` — forwards via `Endpoint::invokeObject("com.kdab.GammaRay.QuickInspectorInterface/1.0", "selectWindow", {index})`. +- ✅ `listScenegraphNodes()` — returns real QSGNode tree (verified: returns 11 GeometryNodes on the test QML). Uses signal-driven `primeAndWait()` (see Implementation findings) — first call may show "Loading..." for deep nodes, second call returns the full tree. +- ✅ `setRenderMode(mode)` — accepts render-mode enum **name** string (NOT int): `NormalRendering` / `VisualizeClipping` / `VisualizeOverdraw` / `VisualizeBatches` / `VisualizeChanges` / `VisualizeTraces`. Calls `invokeObject(..., "setCustomRenderMode", {QVariant::fromValue(mode)})`. Cross-process QVariant serialization works thanks to `quickinspector_types.h` `RenderMode` enum + `Q_DECLARE_METATYPE` with matching fully-qualified name `GammaRay::QuickInspectorInterface::RenderMode`. +- ✅ `setSlowMode(enabled)` — calls `invokeObject(..., "setSlowMode", {bool})`. Verified by reading back state. + +**Implemented but BLOCKED by selection sync issue — DISABLED (wrapped in `#if 0` with TODO markers; registration removed from `main.cpp`). Re-enable when the bug is fixed:** +- ⏳ `selectScenegraphNode(address)` (disabled) — finds node in SG tree, selects via `ObjectBroker::selectionModel(sgModel)` + direct `Message` send via `Protocol::fromQModelIndex`. Caches `m_selectedAddress` to avoid redundant reselection. Selection IS sent and IS received by the probe (`sgSelectionChanged()` fires), but the probe-side selection maps to a `QSGTransformNode` (row 0) instead of the target `QSGGeometryNode`. Sub-models stay empty. +- ⏳ `getNodeVertices(address)` (disabled) — reads `sgGeometryVertexModel` (display + headers + `kIsCoordinateRole=257`). Returns `rowCount=0` because the model is never populated (selection wrong on probe side). +- ⏳ `getNodeAdjacency(address)` (disabled) — reads `sgGeometryAdjacencyModel` (drawing mode + indices, `kDrawingModeRole=257`, `kRenderRole=258`). Same blocker. +- ⏳ `getMaterialShaders(address)` (disabled) — reads `shaderModel` (shader stage names). Same blocker. +- ⏳ `getShaderSource(row)` (disabled) — async path implemented: `MaterialExtensionInterface` proxy (`material_interface.{h,cpp}`) with matching IID `com.kdab.GammaRay.MaterialExtensionInterface`, self-registers via `ObjectBroker::registerObject`, factory registered in `initOnce()`. Calls `invokeObject(..., "getShader", {row})` and waits for `gotShader(QString)` signal. **Untested end-to-end** because the upstream `shaderModel` is never populated. +- ⏳ `getMaterialProperties(address)` (disabled) — reads `materialPropertyModel`. Same blocker. + +**Not yet implemented:** +- ❌ `grabTexture(objectId)` — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Not started (depends on selection working first to populate the texture list). + +### Known issues / Blocked — sub-models not populating after SG node selection + +This is the single biggest open issue. Extensive debugging has narrowed it down but not yet fixed it. + +**Symptom**: After `selectScenegraphNode(address)` selects a QSGGeometryNode, all four sub-models (`sgGeometryVertexModel` / `sgGeometryAdjacencyModel` / `shaderModel` / `materialPropertyModel`) stay at `rowCount=0` / `columnCount=0`. Tested against all 11 GeometryNodes in the test QML — none populate. + +**What's been verified working**: +- All four sub-model addresses are valid on the probe (vertex=161, adjacency=162, shader=154, materialProp=153, selectionModel=142). +- The client-side selection model name is correct: `"com.kdab.GammaRay.QuickSceneGraphModel.selection"` (NOT `".selection"` — `_diag_dotSelAddr=0` confirms the dotted form is wrong). +- `sourceModelForProxy(sgFilterProxy)` returns `sgFilterProxy` itself (it's registered), so `SelectionModelServer` is created directly for it with the name matching the client's `SelectionModelClient`. +- Client-side selection IS correct: `hasSelection()=true`, `selectedRows()` returns the expected index with the full path matching the target address. +- `Endpoint::isConnected()=true` — selection messages ARE being sent. +- Probe-side `QuickInspector::sgSelectionChanged()` IS being called (confirmed via `qWarning()` debug logging in the journal — added to `/home/blumia/Sources/GammaRay/plugins/quickinspector/quickinspector.cpp:737`). + +**Root cause identified (still being narrowed down)**: +The server's `sendSelection()` (triggered by `SelectionModelStateRequest` from `SelectionModelClient`'s 125ms timer, OR by a model-change timer) selects the DEFAULT item (row 0 at root = `QSGTransformNode`) and sends `SelectionModelSelect` back to the client, overriding our selection. Even with: a 2000ms settle before selecting, re-sending the selection twice, manually constructing `SelectionModelSelect` messages via `Protocol::fromQModelIndex`, and turning off slow mode — the sub-models still return `rowCount=0`. + +The second `sgSelectionChanged` call shows `parentValid=true parentRow=0` — selection IS reaching a child node, but it's a `QSGTransformNode`, NOT our `QSGGeometryNode`. The path (0,0)→(0,0)→...→(0,0) is either: + (a) being truncated on the wire, + (b) mapping to the wrong node on the server side via `translateSelection()` / `toQModelIndex()`, OR + (c) the server's default-selection response is arriving AFTER our selection and overriding it. + +**Things tried that did NOT fix it**: +- Waiting longer (2s settle before + after selection, re-sending selection twice). +- Disabling slow mode before selection. +- Manually constructing `SelectionModelSelect` messages via `Protocol::fromQModelIndex` (bypassing `SelectionModelClient`). +- Turning off slow mode. + +**Things NOT yet tried** (see Next steps): +- Add diagnostic logging to the probe-side `NetworkSelectionModel::translateSelection()` and `toQModelIndex()` to trace exactly what path arrives and how it's walked. +- Check whether the server's `sendSelection()` response is arriving AFTER our selection and overriding it (could disable the `SelectionModelClient`'s `requestSelection` 125ms one-shot timer, or call `sendSelection()` on the server after our selection to force-sync). +- Patch `QuickInspector::selectSGNode()` (line 474, currently NOT Q_INVOKABLE) to make it Q_INVOKABLE so the bridge can call it directly via `invokeObject`, bypassing the whole selection-sync mechanism. +- Run the GammaRay GUI client (`xvfb-run` or `QT_QPA_PLATFORM=offscreen`) against the same probe to verify the selection mechanism works at all with this probe setup — if the GUI also fails to populate sub-models, the bug is in the probe plugin, not the bridge. + +### Half-finished artifacts to clean up once the blocker is resolved + +- **Debug `qWarning()` logging in probe plugin**: `/home/blumia/Sources/GammaRay/plugins/quickinspector/quickinspector.cpp:737` (`SGDBG:` lines). Must rebuild+install the probe plugin after any change: `cmake --build /home/blumia/Sources/GammaRay/build -j --target gammaray_quickinspector && cmake --install /home/blumia/Sources/GammaRay/build --prefix $(pwd)/install-prefix`. +- **`m_lastSelectPath` diagnostic field in `SceneGraphTools`**: added to `scenegraph_tools.{h,cpp}` to log the serialized selection path. Pure diagnostic — should be removed (or kept behind a `#ifndef QT_NO_DEBUG`) once the issue is fixed. +- **Disabled tool registrations in `main.cpp` + `scenegraph_tools.{h,cpp}`**: 6 broken tools (`selectScenegraphNode` / `getNodeVertices` / `getNodeAdjacency` / `getMaterialShaders` / `getShaderSource` / `getMaterialProperties`) wrapped in `#if 0` or commented out with `TODO` markers. Remove the `#if 0` / uncomment when the blocker is fixed. +- `listScenegraphNodes` first-call fill stability — first call still shows "Loading..." for deep nodes; second call returns the full tree. The `primeAndWait()` loop already handles this, but a single-call fix would be nicer. ## Step 7: Launch and run @@ -450,24 +507,34 @@ When in doubt, these files have the ground truth: - ✅ **Step 1 done**: GammaRay 3.4.0 built + installed to `install-prefix/`. Protocol version 38. - ✅ **Minimal client built & run**: `minimal-client/` links `gammaray_client` + `gammaray_common`, connects to a live probe, receives `ready()`, fetches `QuickSceneGraphModel` / `QuickWindowModel` via `ObjectBroker` — clean exit. Proves the client ABI + protocol path is viable for the bridge. - ✅ **qtmcp FetchContent viable**: `Qt6::McpServer`/`Qt6::McpCommon` build & link from a `FetchContent_MakeAvailable()` call. No separate clone/install. -- ✅ **Bridge scaffold built & working end-to-end** (`bridge/`): `qml-sg-mcp-bridge` links GammaRay client + qtmcp, runs as a stdio MCP server, and against a live probe returns REAL data: +- ✅ **Bridge scaffold built & working end-to-end** (`bridge/`): `qml-sg-mcp-bridge` links GammaRay client + qtmcp, runs as a stdio MCP server. Smoke test (`/tmp/opencode/smoke_test.py`) against a live probe confirms: - `initialize` → serverInfo + capabilities + protocolVersion `2024-11-05` ✅ - - `tools/list` → `connectProbe`, `connectProbeDefault`, `disconnectProbe`, `probeStatus`, `listQuickWindows`, `selectQuickWindow`, `listScenegraphNodes` ✅ - - `connectProbe(host, port)` → connects + waits for handshake, returns `{connected, state, url, error?}` ✅ - - `probeStatus()` → `{state, ready, url, error?}` (verified: returns `disconnected` with empty url when no probe configured; returns `ready` after `connectProbe`) ✅ - - `listQuickWindows` → `[{"address":"0x... (QQuickWindowQmlImpl)"}]` ✅ - - `selectQuickWindow(index)` → forwards via `Endpoint::invokeObject("com.kdab.GammaRay.QuickInspectorInterface/1.0", "selectWindow", {index})` ✅ - - `listScenegraphNodes` → nested QSGNode tree (Root Node → Transform Node → ...) ✅ -- ✅ **Connection model revised**: bridge no longer hard-requires `--connect` at startup. Lazy connect + `connectProbe` MCP tool + auto-reconnect on dropped session. See "Connection model (decided)" in Step 7. State machine (`Disconnected → Connecting → Ready`, with `→ Failed` on persistent error) exposed via `probeStatus()`. Verified via standalone stdio test: with no `--connect`, `probeStatus` returns `disconnected`/empty URL; `listQuickWindows` returns a JSON error hinting at `connectProbe`; after `connectProbe(127.0.0.1, 11732)` against a live probe, tools return real data. + - `tools/list` → 15 tools registered: `connectProbe`, `connectProbeDefault`, `disconnectProbe`, `getMaterialProperties`, `getMaterialShaders`, `getNodeAdjacency`, `getNodeVertices`, `getShaderSource`, `listQuickWindows`, `listScenegraphNodes`, `probeStatus`, `selectQuickWindow`, `selectScenegraphNode`, `setRenderMode`, `setSlowMode` ✅ + - `probeStatus` (before connect) → `{"ready":false,"state":"disconnected","url":""}` ✅ + - `connectProbeDefault()` → `{"connected":true,"state":"ready","url":"tcp://127.0.0.1:11732"}` ✅ + - `probeStatus` (after connect) → `{"ready":true,"state":"ready","url":"tcp://127.0.0.1:11732"}` ✅ + - `listQuickWindows` → `[{"address":"0x... (ApplicationWindow_QMLTYPE_0)","type":""}]` ✅ + - `selectQuickWindow(0)` → `{"index":0,"status":"selected"}` ✅ + - `setSlowMode(true)` → `{"slowMode":true}` ✅ + - `setRenderMode("VisualizeOverdraw")` → `{"renderMode":"VisualizeOverdraw"}` ✅ (NOTE: takes enum name string, NOT int — see "Known issues") + - `setRenderMode("NormalRendering")` → `{"renderMode":"NormalRendering"}` ✅ + - `disconnectProbe()` → `{"ready":false,"state":"disconnected","url":""}` ✅ + - `listScenegraphNodes` (round 2, after 1s sleep) → full QSGNode tree with 11 GeometryNodes ✅ +- ✅ **Connection model revised**: bridge no longer hard-requires `--connect` at startup. Lazy connect + `connectProbe` MCP tool + auto-reconnect on dropped session. See "Connection model (decided)" in Step 7. State machine (`Disconnected → Connecting → Ready`, with `→ Failed` on persistent error) exposed via `probeStatus()`. ### Key implementation findings (new, verified during scaffold work) - **`Endpoint::invokeObject()` needs the object name == interface IID, NOT the bare name**. `QuickInspectorInterface` is registered via `ObjectBroker::registerObject(this)` (quickinspectorinterface.cpp:59), which derives the name from `qobject_interface_iid` = `"com.kdab.GammaRay.QuickInspectorInterface/1.0"`. The bare `"com.kdab.GammaRay.QuickInspectorInterface"` is NOT in the client's name map → `Q_ASSERT(obj)` crash in debug builds. Also the method arg is the bare slot name `"selectWindow"` (no `(int)` signature), matching `QuickInspectorClient::selectWindow`. - **No client-side stub object needed for `invokeObject`**: it only uses the name→address mapping (synced at handshake `ready()`), not a local object. So `selectQuickWindow` works without registering any factory/stub — `Endpoint::invokeObject` is public in the installed `endpoint.h`. (This resolves open question #1's "approach (b)" more cleanly than expected.) -- **RemoteModel is lazily fetched** (client/remotemodel.cpp): `data()`, `rowCount()`, `hasChildren()` return placeholders ("Loading..." / 0 / false) and queue server fetches whose results arrive async via `dataChanged`/`rowsInserted`. So a tool that reads a model right after `ready()` gets stale/empty data. The scaffold handles this with: `waitForReady()` (block until handshake), `waitForData()` (poll column 0 until non-"Loading..."), and for the SG tree a `primeTree()` + `settle()` round-robin (walk touching every cell + rowCount to queue fetches, run the event loop ~600ms, repeat 4x) before snapshotting with `walkChildren()`. +- **RemoteModel is lazily fetched** (client/remotemodel.cpp): `data()`, `rowCount()`, `hasChildren()` return placeholders ("Loading..." / 0 / false) and queue server fetches whose results arrive async via `dataChanged`/`rowsInserted`. So a tool that reads a model right after `ready()` gets stale/empty data. The scaffold handles this with: `waitForReady()` (block until handshake), `waitForData()` (poll column 0 until non-"Loading..."), and for the SG tree a `primeAndWait()` (see below) before snapshotting with `walkChildren()`. +- **`primeAndWait()` — signal-driven tree loading** (new): replaces the old fixed-time `primeTree()`+`settle()` loop. Tracks `dataChanged`/`rowsInserted` signals on the model, settles until quiet, then a final long settle. Signature `primeAndWait(m, rounds, settleMs, quietMs, finalSettleMs)` — 6 rounds of prime+settle (300ms settle, 500ms quiet), then final 2s settle + re-prime. Fixes "Loading..." for deep nodes: second call to `listScenegraphNodes` returns the full tree (verified: 11 GeometryNodes on test QML). First call may still show "Loading..." for deep nodes because RemoteModel's `requestDataAndFlags()` sets a cell to "Loading" state and won't re-request until the response arrives — the second call is the workaround. - **`QApplication` required** (already noted): the headless bridge runs `QApplication` + `QT_QPA_PLATFORM=offscreen`. - **FetchContent + qtmcp CMake scope gotcha**: a top-level `find_package(Qt6)`/`find_package(GammaRay)` BEFORE `FetchContent_MakeAvailable(qtmcp)` creates `Threads::Threads` at top scope; qtmcp's subdirectory then fails to promote it to global ("not built in this directory"). Fix: run `FetchContent_MakeAvailable(qtmcp)` FIRST (so qtmcp's own `find_package(Qt6)` creates+promotes 3rd-party targets inside its subdirectory), THEN `find_package(GammaRay)`. - **qtmcp tool return types**: only `void`/`bool`/`QString`/`QStringList`/`QImage` (sync) + `QFuture>` (async). `QJsonObject` hits `qFatal`. Structured results → JSON-serialized `QString` (see Step 2 tool section). +- **Cross-process QVariant enum serialization** (new, verified): `setRenderMode` needs to send a `RenderMode` enum value through `Endpoint::invokeObject()` → `QDataStream` → probe. The bridge defines a minimal `RenderMode` enum in `quickinspector_types.h` with `Q_DECLARE_METATYPE(GammaRay::QuickInspectorInterface::RenderMode)` so the enum's fully-qualified name matches GammaRay's. Values match exactly: `NormalRendering=0`, `Overdraw=1`, `Batches=2`, `Changes=3` (and `VisualizeClipping`/`VisualizeTraces` from the interface). Without the matching `Q_DECLARE_METATYPE`, the enum fails to serialize and the probe silently ignores the call. +- **`MaterialExtensionInterface` proxy pattern** (new, implemented): `MaterialExtensionInterface` and `MaterialExtensionClient` are NOT in installed headers — they're plugin-private. To receive the `gotShader(QString)` signal from the probe, the bridge defines a minimal `MaterialExtensionInterface` proxy class with the SAME IID (`com.kdab.GammaRay.MaterialExtensionInterface`), a `gotShader(QString)` signal, and a `getShader(int row)` slot that forwards via `Endpoint::invokeObject("com.kdab.GammaRay.QuickSceneGraph.material", "getShader", {row})`. The concrete `MaterialExtensionProxy` self-registers via `ObjectBroker::registerObject(name, this)` and the factory is registered via `registerClientObjectFactoryCallback()` in `initOnce()`. The `material_interface.moc` include is required (it's a header-only Q_OBJECT class — `#include "material_interface.moc"` at the bottom of the .cpp). +- **Direct `Message` construction for selection** (new, for the blocked selection work): `bridge/CMakeLists.txt` adds `/home/blumia/Sources/GammaRay/common` to `target_include_directories` so the bridge can include `` and `` directly from the GammaRay source tree. This lets the bridge manually construct a `SelectionModelSelect` message via `Protocol::fromQModelIndex(idx)` and send it with `Endpoint::send(msg)`, bypassing the `SelectionModelClient`'s state machine. `Protocol::fromQModelIndex()` serializes the QModelIndex as a path of (row, column) pairs — NOT internal pointers — so this works across processes. (See "Known issues / Blocked" — this bypass did NOT fix the sub-model population issue, but is kept for future debugging.) +- **Selection mechanism chain** (verified by reading source): `ObjectBroker::selectionModel(sgModel)` creates a `SelectionModelClient` (name = `model->objectName() + ".selection"`), which syncs to the probe's `SelectionModelServer`. The probe's `QuickInspector::sgSelectionChanged()` reads `ObjectModel::ObjectRole` → `QSGNode*`, then calls `m_sgPropertyController->setObject(node, className)`, which calls `setObject()` on all extensions (SGGeometryExtension, MaterialExtension, etc.) which populate the sub-models. `sourceModelForProxy()` stops at the first REGISTERED model (in `ObjectBroker`'s model map), not at the root source model — for `sgFilterProxy` (which IS registered), it returns `sgFilterProxy` itself, so `SelectionModelServer` is created directly for it. ### Running the bridge @@ -485,4 +552,15 @@ cmake --build build ``` `run.sh` sets `LD_LIBRARY_PATH` (GammaRay + qtmcp libs) and `QT_PLUGIN_PATH` (qtmcp `mcpserverbackend/libqmcpserverstdio.so`) and `QT_QPA_PLATFORM=offscreen`. -- ⏳ **Next**: implement the remaining Step 6 tools (`get_node_vertices`/`get_node_adjacency` → `sgGeometryVertexModel`/`sgGeometryAdjacencyModel`, `get_material_shaders`/`get_shader_source` → `shaderModel` + `MaterialExtensionInterface::getShader` async, `get_material_properties`, `set_render_mode`/`set_slow_mode` via `QuickInspectorInterface` invokeObject, `grab_texture`). The geometry/material models require selecting a SG node first (via a selection model on `QuickSceneGraphModel`), which is the next pattern to wire up. +## Next steps + +1. **Unblock sub-model population** — the single highest-priority issue. Try (in order of likelihood): + 1. **Run the GammaRay GUI client** (`xvfb-run install-prefix/bin/gammaray-client` or `QT_QPA_PLATFORM=offscreen`) against the same probe and click a GeometryNode. If the GUI ALSO fails to populate the vertex/adjacency/shader/material-property sub-models, the bug is in the probe plugin (or the offscreen platform), not the bridge — re-investigate `SGGeometryExtension::setObject()` and the `typeName == "QSGGeometryNode"` check in `sggeometryextension.cpp`. If the GUI WORKS, the bug is in our selection-sync path. + 2. **Add diagnostic logging to probe-side `NetworkSelectionModel::translateSelection()` and `toQModelIndex()`** (in `/home/blumia/Sources/GammaRay/common/networkselectionmodel.cpp` and `common/protocol.cpp`) to trace exactly what path arrives on the wire and how it's walked back into a QModelIndex. The current `SGDBG:` logging only shows the FINAL node, not the path translation. + 3. **Check if `sendSelection()` response arrives AFTER our selection and overrides it** — disable the `SelectionModelClient`'s `requestSelection` 125ms one-shot timer (`client/selectionmodelclient.cpp:21`), OR call `sendSelection()` on the server AFTER our selection to force-sync the client's state to OUR selection instead of the server's default. + 4. **Patch `QuickInspector::selectSGNode()` to be Q_INVOKABLE** (`/home/blumia/Sources/GammaRay/plugins/quickinspector/quickinspector.cpp:474`). This bypasses the entire selection-sync mechanism — the bridge calls `invokeObject("com.kdab.GammaRay.QuickInspectorInterface/1.0", "selectSGNode", {QVariant::fromValue(nodePtr)})` directly. Requires the bridge to send the raw QSGNode pointer (which it has from the `QuickSceneGraphModel`'s `ObjectRole`). This is the most invasive option (modifies GammaRay source) but the most likely to work. +2. **Clean up debug artifacts** once the blocker is resolved — remove the `SGDBG:` `qWarning()` logging in `quickinspector.cpp:737`, remove the `m_lastSelectPath` diagnostic field in `scenegraph_tools.{h,cpp}`. +3. **Test `getShaderSource` end-to-end** once `shaderModel` populates — verify the `MaterialExtensionInterface` proxy + `invokeObject("getShader", {row})` + `gotShader(QString)` signal flow works. +4. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the same `MaterialExtensionInterface` proxy pattern: a thin `TextureExtensionInterface` proxy with matching IID. Depends on selection working first to populate the texture list. +5. **Fix `listScenegraphNodes` first-call fill stability** — currently the first call shows "Loading..." for deep nodes and the second call returns the full tree. Either: (a) make `primeAndWait()` smarter (detect when a "Loading..." cell has been requested but not yet answered and wait for the `dataChanged`), or (b) just call `primeAndWait()` twice internally within a single `listScenegraphNodes` call. +6. **Pin qtmcp `GIT_TAG`** to a specific commit/tag in `bridge/CMakeLists.txt` for reproducibility (currently tracks `main`). diff --git a/bridge/CMakeLists.txt b/bridge/CMakeLists.txt index 254e2d5..cdec134 100644 --- a/bridge/CMakeLists.txt +++ b/bridge/CMakeLists.txt @@ -42,6 +42,9 @@ add_executable(qml-sg-mcp-bridge src/gammaray_session.h src/scenegraph_tools.cpp src/scenegraph_tools.h + src/material_interface.cpp + src/material_interface.h + src/quickinspector_types.h ) target_link_libraries(qml-sg-mcp-bridge PRIVATE @@ -55,6 +58,11 @@ target_link_libraries(qml-sg-mcp-bridge PRIVATE Qt6::McpCommon ) +# Add GammaRay source common/ for message.h (not in installed headers) +target_include_directories(qml-sg-mcp-bridge PRIVATE + /home/blumia/Sources/GammaRay/common +) + # Runtime: qtmcp builds SHARED libs + the stdio backend plugin into the build # tree. Set RPATH so the bridge finds libQt6Mcp*.so, and set QT_PLUGIN_PATH at # run time (see run.sh) so Qt loads mcpserverbackend/libqmcpserverstdio.so. diff --git a/bridge/src/gammaray_session.cpp b/bridge/src/gammaray_session.cpp index 6846a05..531dcb1 100644 --- a/bridge/src/gammaray_session.cpp +++ b/bridge/src/gammaray_session.cpp @@ -12,8 +12,13 @@ #include #include +#include #include +// Defined in material_interface.cpp — registers the MaterialExtensionInterface +// client factory so ObjectBroker can create our proxy for async getShader. +extern void registerMaterialExtensionFactory(); + GammaRaySession::GammaRaySession(QObject *parent) : QObject(parent) { @@ -61,6 +66,11 @@ GammaRaySession::~GammaRaySession() = default; void GammaRaySession::initOnce() { GammaRay::ClientConnectionManager::init(); + // Register our MaterialExtensionInterface proxy factory. The real factory + // is in QuickInspectorUiFactory::initUi() (a GUI plugin we don't load). + // Without this, ObjectBroker::object() would + // Q_ASSERT (no factory registered for the IID). + registerMaterialExtensionFactory(); } void GammaRaySession::connectToHost(const QUrl &url) @@ -123,6 +133,13 @@ QAbstractItemModel *GammaRaySession::model(const QString &name) const return GammaRay::ObjectBroker::model(name); } +QItemSelectionModel *GammaRaySession::selectionModel(QAbstractItemModel *model) const +{ + if (m_state != State::Ready || !model) + return nullptr; + return GammaRay::ObjectBroker::selectionModel(model); +} + QString GammaRaySession::stateString(State s) { switch (s) { diff --git a/bridge/src/gammaray_session.h b/bridge/src/gammaray_session.h index bc0418b..44c9c61 100644 --- a/bridge/src/gammaray_session.h +++ b/bridge/src/gammaray_session.h @@ -35,6 +35,7 @@ QT_BEGIN_NAMESPACE class QAbstractItemModel; +class QItemSelectionModel; QT_END_NAMESPACE namespace GammaRay { @@ -89,6 +90,13 @@ public: // disconnect/reconnect. QAbstractItemModel *model(const QString &name) const; + // Retrieve a synced selection model for the given model. Returns nullptr if + // not ready or no selection factory is registered. The selection model + // (a SelectionModelClient on the wire) automatically syncs selection + // changes to the probe, which triggers PropertyController::setObject() on + // the probe side and populates per-node sub-models (geometry, material, etc.). + QItemSelectionModel *selectionModel(QAbstractItemModel *model) const; + signals: void ready(); void disconnected(); diff --git a/bridge/src/main.cpp b/bridge/src/main.cpp index 495a9a5..2910f8b 100644 --- a/bridge/src/main.cpp +++ b/bridge/src/main.cpp @@ -76,22 +76,39 @@ int main(int argc, char **argv) SceneGraphTools tools(&session, &server); server.registerToolSet(&tools, { - // Connection management. The bridge no longer hard-requires --connect: - // a client calls connectProbe() to (re)establish a probe connection, - // and any introspection tool will also auto-reconnect if a URL was - // previously established. connectProbeDefault() is the zero-arg entry - // point for the common 127.0.0.1:11732 case. + // Connection management { QStringLiteral("connectProbe"), QStringLiteral("Connect to a GammaRay probe. host defaults to 127.0.0.1; pass port=0 to use the default 11732.") }, { QStringLiteral("connectProbe/host"), QStringLiteral("Probe host (default 127.0.0.1)") }, { QStringLiteral("connectProbe/port"), QStringLiteral("Probe port (default 11732; pass 0 to use default)") }, { QStringLiteral("connectProbeDefault"), QStringLiteral("Connect to a GammaRay probe at 127.0.0.1:11732 (convenience for connectProbe with no args)") }, { QStringLiteral("disconnectProbe"), QStringLiteral("Drop the current probe connection and forget the URL") }, { QStringLiteral("probeStatus"), QStringLiteral("Report current probe connection state, last URL and last error") }, - // Introspection + // Window-level navigation { QStringLiteral("listQuickWindows"), QStringLiteral("List QQuickWindows in the target app") }, { QStringLiteral("selectQuickWindow"), QStringLiteral("Select a Quick window (by index into listQuickWindows) so the scene graph is introspected for it") }, { QStringLiteral("selectQuickWindow/index"), QStringLiteral("0-based index of the window in listQuickWindows") }, { QStringLiteral("listScenegraphNodes"), QStringLiteral("List the QSGNode tree of the selected Quick window") }, + // SG node selection + // TODO: re-enable when the selection-sync / sub-model-population bug is fixed + // { QStringLiteral("selectScenegraphNode"), QStringLiteral("Select a SG node by address (from listScenegraphNodes) so geometry/material sub-models populate for it") }, + // { QStringLiteral("selectScenegraphNode/address"), QStringLiteral("Node address (e.g. 0x56396e6d7400, from listScenegraphNodes)") }, + // Geometry + // { QStringLiteral("getNodeVertices"), QStringLiteral("Get vertex data for a GeometryNode (vertices, attributes, isCoordinate flags)") }, + // { QStringLiteral("getNodeVertices/address"), QStringLiteral("Node address (must be a GeometryNode)") }, + // { QStringLiteral("getNodeAdjacency"), QStringLiteral("Get adjacency/index data for a GeometryNode (drawing mode + index list)") }, + // { QStringLiteral("getNodeAdjacency/address"), QStringLiteral("Node address (must be a GeometryNode)") }, + // Material/Shader + // { QStringLiteral("getMaterialShaders"), QStringLiteral("List shader stages (Vertex, Fragment, ...) for a node's material") }, + // { QStringLiteral("getMaterialShaders/address"), QStringLiteral("Node address (must be a GeometryNode with a material)") }, + // { QStringLiteral("getShaderSource"), QStringLiteral("Get shader source code for a shader stage (async: waits for probe response)") }, + // { QStringLiteral("getShaderSource/row"), QStringLiteral("Row index from getMaterialShaders (0 = first shader stage)") }, + // { QStringLiteral("getMaterialProperties"), QStringLiteral("Get material properties (name/value pairs) for a node's material") }, + // { QStringLiteral("getMaterialProperties/address"), QStringLiteral("Node address (must be a GeometryNode with a material)") }, + // Rendering visualization + { QStringLiteral("setRenderMode"), QStringLiteral("Set custom render mode for visualization") }, + { QStringLiteral("setRenderMode/mode"), QStringLiteral("Render mode: NormalRendering, VisualizeClipping, VisualizeOverdraw, VisualizeBatches, VisualizeChanges, or VisualizeTraces") }, + { QStringLiteral("setSlowMode"), QStringLiteral("Toggle slow animations mode (renders continuously instead of on-demand)") }, + { QStringLiteral("setSlowMode/enabled"), QStringLiteral("true to enable slow mode, false to disable") }, }); QObject::connect(&server, &QMcpServer::finished, &app, &QCoreApplication::quit); diff --git a/bridge/src/material_interface.cpp b/bridge/src/material_interface.cpp new file mode 100644 index 0000000..4eef6e2 --- /dev/null +++ b/bridge/src/material_interface.cpp @@ -0,0 +1,54 @@ +/* + material_interface.cpp — client-side MaterialExtension proxy. + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "material_interface.h" + +#include +#include + +using namespace GammaRay; + +// Concrete client proxy: getShader() forwards to the probe via invokeObject. +// The probe-side MaterialExtension::getShader(row) emits gotShader(source), +// which the protocol delivers back to this proxy's gotShader signal. +class MaterialExtensionProxy : public GammaRay::MaterialExtensionInterface +{ + Q_OBJECT +public: + explicit MaterialExtensionProxy(const QString &name, QObject *parent = nullptr) + : MaterialExtensionInterface(name, parent) + { + // Self-register in the ObjectBroker so the protocol can find us by name + // to deliver the gotShader signal. This mirrors the real + // MaterialExtensionInterface ctor (materialextensioninterface.cpp:23). + ObjectBroker::registerObject(name, this); + } + +public slots: + void getShader(int row) override + { + auto *ep = Endpoint::instance(); + if (ep) + ep->invokeObject(name(), "getShader", QVariantList{row}); + } +}; + +// Factory callback for ObjectBroker — called when ObjectBroker::objectInternal() +// needs to create a client-side proxy for a MaterialExtensionInterface. +static QObject *createMaterialExtensionProxy(const QString &name, QObject *parent) +{ + return new MaterialExtensionProxy(name, parent); +} + +// Public registration function — called from GammaRaySession::initOnce(). +void registerMaterialExtensionFactory() +{ + ObjectBroker::registerClientObjectFactoryCallback( + createMaterialExtensionProxy); +} + +#include "material_interface.moc" diff --git a/bridge/src/material_interface.h b/bridge/src/material_interface.h new file mode 100644 index 0000000..d89fd03 --- /dev/null +++ b/bridge/src/material_interface.h @@ -0,0 +1,59 @@ +/* + material_interface.h — minimal MaterialExtensionInterface proxy for the bridge. + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later + + The real MaterialExtensionInterface (plugins/quickinspector/materialextension/ + materialextensioninterface.h) is NOT in the installed GammaRay headers. But the + bridge needs to: + 1. Call getShader(row) — forwards via Endpoint::invokeObject to the probe. + 2. Receive the gotShader(QString) signal — delivered by the GammaRay protocol + to a client-side proxy object registered under the same name. + + This class replicates the minimal interface: a QObject with the `gotShader` + signal and `getShader` slot, the same IID ("com.kdab.GammaRay.MaterialExtensionInterface"), + and a constructor that self-registers via ObjectBroker::registerObject(name, this) + — exactly like the real MaterialExtensionInterface base class does. + + The GammaRay protocol forwards signal emissions by object name + signal + signature. As long as our proxy has `gotShader(QString)` in its meta-object + (guaranteed by Q_OBJECT + signals:), the signal arrives correctly. +*/ + +#ifndef MATERIAL_INTERFACE_H +#define MATERIAL_INTERFACE_H + +#include +#include + +namespace GammaRay { +class MaterialExtensionInterface : public QObject +{ + Q_OBJECT +public: + explicit MaterialExtensionInterface(const QString &name, QObject *parent = nullptr) + : QObject(parent) + , m_name(name) + { + } + + const QString &name() const { return m_name; } + +signals: + void gotShader(const QString &shaderSource); + +public slots: + virtual void getShader(int row) = 0; + +protected: + QString m_name; +}; +} // namespace GammaRay + +QT_BEGIN_NAMESPACE +Q_DECLARE_INTERFACE(GammaRay::MaterialExtensionInterface, + "com.kdab.GammaRay.MaterialExtensionInterface") +QT_END_NAMESPACE + +#endif // MATERIAL_INTERFACE_H diff --git a/bridge/src/quickinspector_types.h b/bridge/src/quickinspector_types.h new file mode 100644 index 0000000..b250992 --- /dev/null +++ b/bridge/src/quickinspector_types.h @@ -0,0 +1,50 @@ +/* + quickinspector_types.h — minimal enum definitions matching the probe's + QuickInspectorInterface, for use with Endpoint::invokeObject. + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later + + The bridge cannot include the real quickinspectorinterface.h (not in installed + headers — see PLAN.md open question #1). But setCustomRenderMode() takes a + RenderMode enum by value, and Endpoint::invokeObject serializes QVariant args + via QDataStream which uses the QVariant typeName() for custom types. So we + need a type whose fully-qualified name matches "GammaRay::QuickInspectorInterface::RenderMode" + exactly, and which is registered via Q_DECLARE_METATYPE on both sides. + + This header defines just that — a nested enum inside a class with the same + namespace/class structure. Q_DECLARE_METATYPE registers it with the matching + type name, enabling cross-process QVariant serialization. +*/ + +#ifndef QUICKINSPECTOR_TYPES_H +#define QUICKINSPECTOR_TYPES_H + +#include +#include + +namespace GammaRay { + +// Minimal stand-in for the real QuickInspectorInterface — just the RenderMode +// enum. Do NOT add Q_OBJECT (we don't need MOC for this header-only class; +// Q_DECLARE_METATYPE below handles metatype registration). +class QuickInspectorInterface +{ +public: + // Values MUST match the probe's enum (quickinspectorinterface.h:52-66). + enum RenderMode + { + NormalRendering, + VisualizeClipping, + VisualizeOverdraw, + VisualizeBatches, + VisualizeChanges, + VisualizeTraces, + }; +}; + +} // namespace GammaRay + +Q_DECLARE_METATYPE(GammaRay::QuickInspectorInterface::RenderMode) + +#endif // QUICKINSPECTOR_TYPES_H diff --git a/bridge/src/scenegraph_tools.cpp b/bridge/src/scenegraph_tools.cpp index ee96f3a..5b4f29e 100644 --- a/bridge/src/scenegraph_tools.cpp +++ b/bridge/src/scenegraph_tools.cpp @@ -7,12 +7,18 @@ #include "scenegraph_tools.h" #include "gammaray_session.h" +#include "material_interface.h" +#include "quickinspector_types.h" -#include +#include +#include +#include +#include #include #include #include +#include #include #include #include @@ -23,13 +29,17 @@ // Model registration names (from plugins/quickinspector/quickinspector.cpp). static const char *kQuickWindowModel = "com.kdab.GammaRay.QuickWindowModel"; static const char *kQuickSceneGraphModel = "com.kdab.GammaRay.QuickSceneGraphModel"; +static const char *kBaseName = "com.kdab.GammaRay.QuickSceneGraph"; +static const char *kMaterialIface = "com.kdab.GammaRay.MaterialExtensionInterface"; +static const char *kQuickInspectorIface = "com.kdab.GammaRay.QuickInspectorInterface/1.0"; + +// Custom roles from sggeometrymodel.h / materialshadermodel.h +static constexpr int kIsCoordinateRole = 257; +static constexpr int kDrawingModeRole = 257; +static constexpr int kRenderRole = 258; + +// --- async helpers (from earlier scaffold) --- -// RemoteModel populates asynchronously from the probe and fetches cell data -// lazily: data() returns a "Loading..." placeholder and queues a server fetch; -// the real value arrives later via dataChanged. So we must (a) trigger the -// fetches by calling data(), and (b) wait for the values to settle. We poll in -// a local event loop until the first column display is non-empty AND not the -// "Loading..." placeholder, or until the total budget elapses. static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs) { if (!m) @@ -39,7 +49,6 @@ static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs) const int step = 150; while (waited < timeoutMs) { if (m->rowCount() > 0) { - // Trigger/refresh the cell fetch for the first row's target column. const auto v = m->data(m->index(0, column), Qt::DisplayRole).toString(); if (!v.isEmpty() && v != QLatin1String("Loading...")) return; @@ -55,7 +64,6 @@ static QJsonObject errorJson(const QString &msg) return { { QStringLiteral("error"), msg } }; } -// Recursive tree walker for tree-shaped remote models. static QJsonArray walkChildren(const QAbstractItemModel *m, const QModelIndex &parent, int depth) { QJsonArray out; @@ -76,13 +84,6 @@ static QJsonArray walkChildren(const QAbstractItemModel *m, const QModelIndex &p return out; } -// RemoteModel fetches cell data AND child row counts lazily from the probe: -// data()/rowCount()/hasChildren() trigger server fetches and return a -// placeholder ("Loading..." / 0 / false) until the response arrives. So to get -// a populated tree we must (1) prime: walk the model touching every cell and -// rowCount() to queue all fetches, (2) let the event loop run so responses -// arrive, (3) read. We repeat prime+settle a few rounds so deeper levels -// become reachable as their parents' row counts arrive. static void primeTree(const QAbstractItemModel *m, const QModelIndex &parent, int depth) { if (depth <= 0) @@ -102,6 +103,101 @@ static void settle(QEventLoop &loop, int ms) loop.exec(); } +// Count how many cells in the tree are still "Loading..." (column 0). +// Used to decide whether more prime+settle rounds are needed. +static int countLoading(const QAbstractItemModel *m, const QModelIndex &parent, int depth) +{ + if (depth <= 0) + return 0; + int count = 0; + for (int r = 0; r < m->rowCount(parent); ++r) { + const QModelIndex idx = m->index(r, 0, parent); + const QString v = m->data(idx, Qt::DisplayRole).toString(); + if (v == QLatin1String("Loading...")) + ++count; + if (m->hasChildren(idx)) + count += countLoading(m, idx, depth - 1); + } + return count; +} + +// Signal-driven tree loading: prime the tree, then settle until no +// dataChanged/rowsInserted signals arrive for quietMs. Repeat up to maxRounds +// or until no cells are "Loading...". After the main rounds, do one final +// long settle (finalSettleMs) to let any in-flight fetch responses arrive, +// then re-check. This handles the common case where the deepest cells' fetch +// responses arrive just after the quiet window expires. +static void primeAndWait(const QAbstractItemModel *m, int maxRounds, int settleMs, int quietMs, + int finalSettleMs = 2000) +{ + QEventLoop loop; + for (int round = 0; round < maxRounds; ++round) { + bool signalActivity = false; + const auto dcConn = QObject::connect(m, &QAbstractItemModel::dataChanged, + [&signalActivity]() { signalActivity = true; }); + const auto riConn = QObject::connect(m, &QAbstractItemModel::rowsInserted, + [&signalActivity]() { signalActivity = true; }); + + primeTree(m, QModelIndex(), 16); + + int quiet = 0; + while (quiet < quietMs) { + signalActivity = false; + QTimer::singleShot(settleMs, &loop, &QEventLoop::quit); + loop.exec(); + quiet += settleMs; + if (signalActivity) + quiet = 0; + } + QObject::disconnect(dcConn); + QObject::disconnect(riConn); + + if (countLoading(m, QModelIndex(), 16) == 0) + return; + } + + // Final long settle: let any in-flight fetch responses arrive, then re-prime. + if (countLoading(m, QModelIndex(), 16) > 0) { + settle(loop, finalSettleMs); + primeTree(m, QModelIndex(), 16); + settle(loop, finalSettleMs / 2); + } +} + +// Recursively search the SG tree for a node whose column-0 display string +// matches the given address. Primes the tree as it goes so deeper levels +// become available. Returns the matching index (column 0) or invalid. +static QModelIndex findNodeInTree(const QAbstractItemModel *m, const QModelIndex &parent, + const QString &address, int depth, QEventLoop &loop) +{ + if (depth <= 0) + return {}; + for (int r = 0; r < m->rowCount(parent); ++r) { + const QModelIndex idx = m->index(r, 0, parent); + const QString addr = m->data(idx, Qt::DisplayRole).toString(); + if (addr == address) + return idx; + // If this node is still "Loading...", settle and retry + if (addr == QLatin1String("Loading...")) { + settle(loop, 300); + const QString addr2 = m->data(idx, Qt::DisplayRole).toString(); + if (addr2 == address) + return idx; + } + if (m->hasChildren(idx)) { + // Prime children and recurse + m->rowCount(idx); // trigger child count fetch + settle(loop, 200); + const auto found = findNodeInTree(m, idx, address, depth - 1, loop); + if (found.isValid()) + return found; + } + } + return {}; +} + +// --- SceneGraphTools --- + SceneGraphTools::SceneGraphTools(GammaRaySession *session, QObject *parent) : QObject(parent) , m_session(session) @@ -113,13 +209,10 @@ QString SceneGraphTools::ensureSession() const if (!m_session) return QString::fromUtf8(QJsonDocument(errorJson( QStringLiteral("no session"))).toJson(QJsonDocument::Compact)); - // First, opportunistically bring up a connection if we have a URL. - if (m_session->lastUrl().isValid()) { + if (m_session->lastUrl().isValid()) m_session->ensureConnected(6000); - } if (m_session->isReady()) return {}; - // No URL known OR reconnect failed: tell the user to call connectProbe. QJsonObject err = errorJson( m_session->lastUrl().isValid() ? QStringLiteral("not connected to probe (last error: %1; call connectProbe to retry)") @@ -129,6 +222,8 @@ QString SceneGraphTools::ensureSession() const return QString::fromUtf8(QJsonDocument(err).toJson(QJsonDocument::Compact)); } +// --- Connection management --- + QString SceneGraphTools::connectProbe(const QString &host, int port) const { if (!m_session) @@ -181,6 +276,8 @@ QString SceneGraphTools::probeStatus() const return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); } +// --- Window-level navigation --- + QString SceneGraphTools::listQuickWindows() const { if (const QString e = ensureSession(); !e.isEmpty()) @@ -210,19 +307,11 @@ QString SceneGraphTools::selectQuickWindow(int index) const if (const QString e = ensureSession(); !e.isEmpty()) return e; - // The QuickInspectorInterface lives on the probe side. Its header isn't in - // the installed client headers, but Endpoint::invokeObject() can forward a - // method call to a remote object by name+address. The name→address mapping - // is synced during the handshake (ready()), so once connected we can call - // it directly — no client-side stub needed (invokeObject only uses the - // address, not a local object). The object name is the interface IID - // (quickinspectorinterface.cpp:59 registers via the IID), and the method - // name is the bare slot name "selectWindow" (matches QuickInspectorClient). auto *ep = GammaRay::Endpoint::instance(); if (!ep) return QString::fromUtf8(QJsonDocument(errorJson( QStringLiteral("no GammaRay endpoint"))).toJson(QJsonDocument::Compact)); - ep->invokeObject(QStringLiteral("com.kdab.GammaRay.QuickInspectorInterface/1.0"), + ep->invokeObject(QString::fromUtf8(kQuickInspectorIface), "selectWindow", QVariantList{index}); return QString::fromUtf8(QJsonDocument(QJsonObject{ { QStringLiteral("status"), QStringLiteral("selected") }, @@ -240,15 +329,493 @@ QString SceneGraphTools::listScenegraphNodes() const return QString::fromUtf8(QJsonDocument(errorJson( QStringLiteral("QuickSceneGraphModel not available (no Quick app attached?)"))).toJson(QJsonDocument::Compact)); - // Prime + settle across a few rounds so lazily-fetched cell data and child - // row counts have time to arrive before we snapshot the tree. - QEventLoop loop; - for (int round = 0; round < 4; ++round) { - primeTree(m, QModelIndex(), 16); - settle(loop, 600); - } + // Signal-driven tree loading: prime + settle until the model goes quiet + // and no cells are "Loading...". More rounds + longer settle for reliability. + primeAndWait(m, 6, 300, 500); - // Depth cap to keep responses bounded for large scenes. const QJsonArray tree = walkChildren(m, QModelIndex(), 16); return QString::fromUtf8(QJsonDocument(tree).toJson(QJsonDocument::Compact)); } + +// --- SG node selection --- +// TODO: all code below is disabled until the selection-sync / sub-model-population +// bug is fixed. See PLAN.md "Known issues / Blocked" section. +#if 0 + +QModelIndex SceneGraphTools::findNodeByAddress(QAbstractItemModel *m, const QString &address) const +{ + if (!m || address.isEmpty()) + return {}; + + // Signal-driven tree loading to maximize the chance of finding the node. + primeAndWait(m, 6, 300, 500); + + QEventLoop loop; + // Now search for the address. + const auto idx = findNodeInTree(m, QModelIndex(), address, 16, loop); + if (idx.isValid()) + return idx; + + // Maybe the tree was still loading — try a couple more rounds. + for (int round = 0; round < 3; ++round) { + primeAndWait(m, 2, 300, 500); + const auto idx2 = findNodeInTree(m, QModelIndex(), address, 16, loop); + if (idx2.isValid()) + return idx2; + } + return {}; +} + +void SceneGraphTools::waitForRows(QAbstractItemModel *m, int timeoutMs) const +{ + if (!m) + return; + QEventLoop loop; + int waited = 0; + const int step = 150; + while (waited < timeoutMs) { + if (m->rowCount() > 0) + return; + QTimer::singleShot(step, &loop, &QEventLoop::quit); + loop.exec(); + waited += step; + } +} + +QString SceneGraphTools::ensureNodeSelected(const QString &address) const +{ + if (const QString e = ensureSession(); !e.isEmpty()) + return e; + + if (address.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("address is required"))).toJson(QJsonDocument::Compact)); + + // If already selected, nothing to do. + if (address == m_selectedAddress) + return {}; + + auto *sgModel = m_session->model(QString::fromUtf8(kQuickSceneGraphModel)); + if (!sgModel) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("QuickSceneGraphModel not available"))).toJson(QJsonDocument::Compact)); + + const QModelIndex idx = findNodeByAddress(sgModel, address); + if (!idx.isValid()) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("node %1 not found in scene graph tree (still loading?)").arg(address))) + .toJson(QJsonDocument::Compact)); + + auto *selModel = m_session->selectionModel(sgModel); + if (!selModel) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("selection model not available"))).toJson(QJsonDocument::Compact)); + + // CRITICAL: Create sub-model RemoteModels BEFORE sending the selection. + // The probe populates and resets sub-models (vertex, adjacency, shader, + // materialProperty) when it receives the selection. The RemoteModelServer + // only forwards modelReset/rowsInserted to clients that are monitoring. + // By creating the RemoteModels first, their ObjectMonitored messages reach + // the server before the SelectionModelSelect (FIFO on same TCP socket), + // so the server is already monitoring when the reset fires. + const QString base = QString::fromUtf8(kBaseName); + auto *vModel = m_session->model(base + ".sgGeometryVertexModel"); + auto *aModel = m_session->model(base + ".sgGeometryAdjacencyModel"); + auto *sModel = m_session->model(base + ".shaderModel"); + auto *mModel = m_session->model(base + ".materialPropertyModel"); + + // Turn off slow mode before selecting to prevent SG tree updates from + // interfering with the selection (continuous rendering causes model resets + // that can clear the selection). + GammaRay::Endpoint::instance()->invokeObject( + QString::fromUtf8(kQuickInspectorIface), + "setSlowMode", QVariantList{ false }); + + // Wait long enough for: + // 1. ObjectMonitored messages to reach the server and start monitoring + // 2. SelectionModelStateRequest (125ms timer from SelectionModelClient ctor) + // to be sent and the server's response (default selection) to arrive + // 3. Any SG model updates from setSlowMode(false) to settle + QEventLoop loop; + settle(loop, 2000); + + // Now send the selection. The server should now be in a stable state with + // the default selection already processed. Our selection will override it. + selModel->select(idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows | QItemSelectionModel::Current); + m_selectedAddress = address; + + // Also manually construct and send a SelectionModelSelect message directly + // to the server's selection model address, bypassing SelectionModelClient. + // This eliminates any issues with SelectionModelClient state. + { + const auto selAddr = GammaRay::Endpoint::instance()->objectAddress( + QStringLiteral("%1.selection").arg(QString::fromUtf8(kQuickSceneGraphModel))); + if (selAddr != GammaRay::Protocol::InvalidObjectAddress) { + // Check the path length + const auto mi = GammaRay::Protocol::fromQModelIndex(idx); + int pathLen = mi.size(); + // Log the path for debugging + QString pathStr; + for (int i = 0; i < mi.size(); ++i) + pathStr += QStringLiteral("(%1,%2) ").arg(mi[i].row).arg(mi[i].column); + + GammaRay::Message msg(selAddr, GammaRay::Protocol::SelectionModelSelect); + msg << qint32(1); // 1 range + msg << mi << mi; // topLeft, bottomRight + msg << (QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows | QItemSelectionModel::Current); + GammaRay::Endpoint::send(msg); + + // Store path info for diagnostics + m_lastSelectPath = QStringLiteral("len=%1 %2").arg(pathLen).arg(pathStr); + } + } + + // Wait for the selection to be processed and sub-model data to arrive. + settle(loop, 2000); + + // Re-send via direct message to ensure it sticks. + { + const auto selAddr = GammaRay::Endpoint::instance()->objectAddress( + QStringLiteral("%1.selection").arg(QString::fromUtf8(kQuickSceneGraphModel))); + if (selAddr != GammaRay::Protocol::InvalidObjectAddress) { + GammaRay::Message msg(selAddr, GammaRay::Protocol::SelectionModelSelect); + const auto mi = GammaRay::Protocol::fromQModelIndex(idx); + msg << qint32(1); + msg << mi << mi; + msg << (QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows | QItemSelectionModel::Current); + GammaRay::Endpoint::send(msg); + } + } + settle(loop, 2000); + + // Prime sub-models to fetch their row/column counts and cell data. + if (vModel) { vModel->rowCount(); primeAndWait(vModel, 4, 300, 500, 1500); } + if (aModel) { aModel->rowCount(); primeAndWait(aModel, 4, 300, 500, 1500); } + if (sModel) { sModel->rowCount(); primeAndWait(sModel, 4, 300, 500, 1500); } + if (mModel) { mModel->rowCount(); primeAndWait(mModel, 4, 300, 500, 1500); } + return {}; +} + +QString SceneGraphTools::selectScenegraphNode(const QString &address) const +{ + m_selectedAddress.clear(); // force reselect + const QString e = ensureNodeSelected(address); + if (!e.isEmpty()) + return e; + + // Report what we know about the selected node. + auto *sgModel = m_session->model(QString::fromUtf8(kQuickSceneGraphModel)); + const QModelIndex idx = findNodeByAddress(sgModel, address); + QString type; + if (idx.isValid()) { + const QModelIndex typeIdx = sgModel->index(idx.row(), 1, idx.parent()); + type = sgModel->data(typeIdx, Qt::DisplayRole).toString(); + } + + // Check selection state and sub-model row counts + auto *sgModel2 = m_session->model(QString::fromUtf8(kQuickSceneGraphModel)); + auto *selModel = m_session->selectionModel(sgModel2); + auto *vModel = m_session->model(QStringLiteral("%1.sgGeometryVertexModel").arg(QString::fromUtf8(kBaseName))); + auto *aModel = m_session->model(QStringLiteral("%1.sgGeometryAdjacencyModel").arg(QString::fromUtf8(kBaseName))); + auto *sModel = m_session->model(QStringLiteral("%1.shaderModel").arg(QString::fromUtf8(kBaseName))); + auto *mModel = m_session->model(QStringLiteral("%1.materialPropertyModel").arg(QString::fromUtf8(kBaseName))); + + // Diagnostic: check if models are registered on the probe (have valid addresses) + auto *ep = GammaRay::Endpoint::instance(); + const auto vAddr = ep ? ep->objectAddress(QStringLiteral("%1.sgGeometryVertexModel").arg(QString::fromUtf8(kBaseName))) : 0; + const auto aAddr = ep ? ep->objectAddress(QStringLiteral("%1.sgGeometryAdjacencyModel").arg(QString::fromUtf8(kBaseName))) : 0; + const auto sAddr = ep ? ep->objectAddress(QStringLiteral("%1.shaderModel").arg(QString::fromUtf8(kBaseName))) : 0; + const auto mAddr = ep ? ep->objectAddress(QStringLiteral("%1.materialPropertyModel").arg(QString::fromUtf8(kBaseName))) : 0; + const auto selAddr = ep ? ep->objectAddress(QStringLiteral("%1.selection").arg(QString::fromUtf8(kQuickSceneGraphModel))) : 0; + // Also check the wrong name (in case source model name mismatch) + const auto dotSelAddr = ep ? ep->objectAddress(QStringLiteral(".selection")) : 0; + + // Check what the selected index looks like + QString selIdxInfo; + if (selModel && selModel->hasSelection()) { + auto rows = selModel->selectedRows(); + if (rows.isEmpty()) { + selIdxInfo = QStringLiteral("hasSelection=true but selectedRows() empty"); + } else { + // Walk the parent chain to get the full path + QStringList pathParts; + QModelIndex cur = rows.first(); + while (cur.isValid()) { + QString addr = cur.data(Qt::DisplayRole).toString(); + pathParts.prepend(QStringLiteral("r%1:%2").arg(cur.row()).arg(addr)); + cur = cur.parent(); + } + selIdxInfo = pathParts.join(" -> "); + } + } else { + selIdxInfo = QStringLiteral("no selection"); + } + + QJsonObject out = { + { QStringLiteral("address"), address }, + { QStringLiteral("type"), type }, + { QStringLiteral("hasGeometry"), vModel && vModel->rowCount() > 0 }, + { QStringLiteral("hasMaterial"), sModel && sModel->rowCount() > 0 }, + { QStringLiteral("_diag_epConnected"), ep ? ep->isConnected() : false }, + { QStringLiteral("_diag_selModelAddr"), qint64(selAddr) }, + { QStringLiteral("_diag_selHasSelection"), selModel ? selModel->hasSelection() : false }, + { QStringLiteral("_diag_selIdxInfo"), selIdxInfo }, + { QStringLiteral("_diag_vertexModelAddr"), qint64(vAddr) }, + { QStringLiteral("_diag_adjacencyModelAddr"), qint64(aAddr) }, + { QStringLiteral("_diag_shaderModelAddr"), qint64(sAddr) }, + { QStringLiteral("_diag_materialPropModelAddr"), qint64(mAddr) }, + { QStringLiteral("_diag_vertexRowCount"), vModel ? vModel->rowCount() : -1 }, + { QStringLiteral("_diag_adjacencyRowCount"), aModel ? aModel->rowCount() : -1 }, + { QStringLiteral("_diag_shaderRowCount"), sModel ? sModel->rowCount() : -1 }, + { QStringLiteral("_diag_materialPropRowCount"), mModel ? mModel->rowCount() : -1 }, + { QStringLiteral("_diag_vertexColCount"), vModel ? vModel->columnCount() : -1 }, + { QStringLiteral("_diag_vertexData00"), vModel && vModel->rowCount() > 0 && vModel->columnCount() > 0 ? vModel->data(vModel->index(0, 0)).toString() : QString() }, + }; + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); +} + +// --- Geometry --- + +QString SceneGraphTools::getNodeVertices(const QString &address) const +{ + const QString e = ensureNodeSelected(address); + if (!e.isEmpty()) + return e; + + auto *m = m_session->model(QStringLiteral("%1.sgGeometryVertexModel").arg(QString::fromUtf8(kBaseName))); + if (!m) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("sgGeometryVertexModel not available"))).toJson(QJsonDocument::Compact)); + + waitForRows(m, 3000); + + const int rows = m->rowCount(); + const int cols = m->columnCount(); + if (rows == 0) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("no vertex data (selected node may not be a GeometryNode)"))) + .toJson(QJsonDocument::Compact)); + + QJsonArray headers; + QJsonArray isCoordinate; + for (int c = 0; c < cols; ++c) { + headers.append(m->headerData(c, Qt::Horizontal, Qt::DisplayRole).toString()); + isCoordinate.append(m->data(m->index(0, c), kIsCoordinateRole).toBool()); + } + + QJsonArray vertices; + for (int r = 0; r < rows; ++r) { + QJsonArray row; + for (int c = 0; c < cols; ++c) + row.append(m->data(m->index(r, c), Qt::DisplayRole).toString()); + vertices.append(row); + } + + QJsonObject out = { + { QStringLiteral("vertexCount"), rows }, + { QStringLiteral("attributes"), headers }, + { QStringLiteral("isCoordinate"), isCoordinate }, + { QStringLiteral("vertices"), vertices }, + }; + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); +} + +QString SceneGraphTools::getNodeAdjacency(const QString &address) const +{ + const QString e = ensureNodeSelected(address); + if (!e.isEmpty()) + return e; + + auto *m = m_session->model(QStringLiteral("%1.sgGeometryAdjacencyModel").arg(QString::fromUtf8(kBaseName))); + if (!m) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("sgGeometryAdjacencyModel not available"))).toJson(QJsonDocument::Compact)); + + waitForRows(m, 3000); + + const int rows = m->rowCount(); + if (rows == 0) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("no adjacency data (selected node may not be a GeometryNode)"))) + .toJson(QJsonDocument::Compact)); + + // DrawingModeRole is the same for all rows — read from row 0. + int drawingMode = m->data(m->index(0, 0), kDrawingModeRole).toInt(); + QJsonArray indices; + for (int r = 0; r < rows; ++r) + indices.append(m->data(m->index(r, 0), kRenderRole).toInt()); + + QJsonObject out = { + { QStringLiteral("drawingMode"), drawingMode }, + { QStringLiteral("indexCount"), rows }, + { QStringLiteral("indices"), indices }, + }; + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); +} + +// --- Material/Shader --- + +QString SceneGraphTools::getMaterialShaders(const QString &address) const +{ + const QString e = ensureNodeSelected(address); + if (!e.isEmpty()) + return e; + + auto *m = m_session->model(QStringLiteral("%1.shaderModel").arg(QString::fromUtf8(kBaseName))); + if (!m) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("shaderModel not available"))).toJson(QJsonDocument::Compact)); + + waitForRows(m, 3000); + + const int rows = m->rowCount(); + if (rows == 0) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("no shaders (selected node may not have a material)"))) + .toJson(QJsonDocument::Compact)); + + QJsonArray out; + for (int r = 0; r < rows; ++r) { + const QString stage = m->data(m->index(r, 0), Qt::DisplayRole).toString(); + out.append(QJsonObject{ + { QStringLiteral("row"), r }, + { QStringLiteral("stage"), stage }, + }); + } + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); +} + +QString SceneGraphTools::getShaderSource(int row) const +{ + if (const QString e = ensureSession(); !e.isEmpty()) + return e; + + // Get or create the MaterialExtensionInterface proxy. The factory (registered + // in initOnce) creates a MaterialExtensionProxy that self-registers via + // ObjectBroker::registerObject. The probe forwards gotShader emissions to it. + auto *proxy = qobject_cast( + GammaRay::ObjectBroker::objectInternal( + QStringLiteral("%1.material").arg(QString::fromUtf8(kBaseName)), + QByteArray(kMaterialIface))); + if (!proxy) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("MaterialExtensionInterface proxy not available"))).toJson(QJsonDocument::Compact)); + + QString result; + QEventLoop loop; + const auto conn = QObject::connect(proxy, &GammaRay::MaterialExtensionInterface::gotShader, + &loop, [&](const QString &src) { + result = src; + loop.quit(); + }); + QTimer::singleShot(10000, &loop, &QEventLoop::quit); + proxy->getShader(row); // forwards via invokeObject to the probe + loop.exec(); + QObject::disconnect(conn); + + if (result.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("timeout waiting for shader source (row %1)").arg(row))) + .toJson(QJsonDocument::Compact)); + + QJsonObject out = { + { QStringLiteral("row"), row }, + { QStringLiteral("source"), result }, + }; + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); +} + +QString SceneGraphTools::getMaterialProperties(const QString &address) const +{ + const QString e = ensureNodeSelected(address); + if (!e.isEmpty()) + return e; + + auto *m = m_session->model(QStringLiteral("%1.materialPropertyModel").arg(QString::fromUtf8(kBaseName))); + if (!m) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("materialPropertyModel not available"))).toJson(QJsonDocument::Compact)); + + waitForRows(m, 3000); + + const int rows = m->rowCount(); + const int cols = m->columnCount(); + if (rows == 0) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("no material properties (selected node may not have a material)"))) + .toJson(QJsonDocument::Compact)); + + QJsonArray out; + for (int r = 0; r < rows; ++r) { + QJsonObject prop; + for (int c = 0; c < cols; ++c) { + const QString header = m->headerData(c, Qt::Horizontal, Qt::DisplayRole).toString(); + const QString val = m->data(m->index(r, c), Qt::DisplayRole).toString(); + prop.insert(header.isEmpty() ? QString::number(c) : header, val); + } + out.append(prop); + } + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); +} + +#endif // disabled until selection-sync bug is fixed + +// --- Rendering visualization --- + +QString SceneGraphTools::setRenderMode(const QString &mode) const +{ + if (const QString e = ensureSession(); !e.isEmpty()) + return e; + + auto *ep = GammaRay::Endpoint::instance(); + if (!ep) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("no GammaRay endpoint"))).toJson(QJsonDocument::Compact)); + + // Parse mode string → enum. Accept full enum name or short alias. + GammaRay::QuickInspectorInterface::RenderMode rm = GammaRay::QuickInspectorInterface::NormalRendering; + static const QHash map = { + { QStringLiteral("NormalRendering"), GammaRay::QuickInspectorInterface::NormalRendering }, + { QStringLiteral("normal"), GammaRay::QuickInspectorInterface::NormalRendering }, + { QStringLiteral("VisualizeClipping"), GammaRay::QuickInspectorInterface::VisualizeClipping }, + { QStringLiteral("clipping"), GammaRay::QuickInspectorInterface::VisualizeClipping }, + { QStringLiteral("VisualizeOverdraw"), GammaRay::QuickInspectorInterface::VisualizeOverdraw }, + { QStringLiteral("overdraw"), GammaRay::QuickInspectorInterface::VisualizeOverdraw }, + { QStringLiteral("VisualizeBatches"), GammaRay::QuickInspectorInterface::VisualizeBatches }, + { QStringLiteral("batches"), GammaRay::QuickInspectorInterface::VisualizeBatches }, + { QStringLiteral("VisualizeChanges"), GammaRay::QuickInspectorInterface::VisualizeChanges }, + { QStringLiteral("changes"), GammaRay::QuickInspectorInterface::VisualizeChanges }, + { QStringLiteral("VisualizeTraces"), GammaRay::QuickInspectorInterface::VisualizeTraces }, + { QStringLiteral("traces"), GammaRay::QuickInspectorInterface::VisualizeTraces }, + }; + if (map.contains(mode)) + rm = map.value(mode); + else + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("unknown render mode '%1' — valid: NormalRendering, VisualizeClipping, VisualizeOverdraw, VisualizeBatches, VisualizeChanges, VisualizeTraces").arg(mode))) + .toJson(QJsonDocument::Compact)); + + ep->invokeObject(QString::fromUtf8(kQuickInspectorIface), + "setCustomRenderMode", + QVariantList{QVariant::fromValue(rm)}); + return QString::fromUtf8(QJsonDocument(QJsonObject{ + { QStringLiteral("renderMode"), mode }, + }).toJson(QJsonDocument::Compact)); +} + +QString SceneGraphTools::setSlowMode(bool enabled) const +{ + if (const QString e = ensureSession(); !e.isEmpty()) + return e; + + auto *ep = GammaRay::Endpoint::instance(); + if (!ep) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("no GammaRay endpoint"))).toJson(QJsonDocument::Compact)); + + ep->invokeObject(QString::fromUtf8(kQuickInspectorIface), + "setSlowMode", QVariantList{enabled}); + return QString::fromUtf8(QJsonDocument(QJsonObject{ + { QStringLiteral("slowMode"), enabled }, + }).toJson(QJsonDocument::Compact)); +} diff --git a/bridge/src/scenegraph_tools.h b/bridge/src/scenegraph_tools.h index ec9dc8c..0adde06 100644 --- a/bridge/src/scenegraph_tools.h +++ b/bridge/src/scenegraph_tools.h @@ -15,6 +15,17 @@ established (via --connect at startup OR a prior connectProbe() call), a stale session is transparently reconnected. If no URL is known, the tool returns a JSON error pointing the user at connectProbe(). + + SG node selection model + ----------------------- + Geometry/material/texture sub-models are populated by the probe-side + PropertyController only for the *currently selected* SG node. Selection is + driven via ObjectBroker::selectionModel(sgModel) which returns a synced + SelectionModelClient. Calling select(index, ClearAndSelect) on it sends the + selection to the probe, which calls setObject() on all PropertyController + extensions, which in turn populate sgGeometryVertexModel, shaderModel, etc. + Tools that need per-node data call ensureNodeSelected(address) first, which + finds the node index in the (lazily-fetched) SG tree and selects it. */ #ifndef SCENEGRAPH_TOOLS_H @@ -22,6 +33,7 @@ #include #include +#include class GammaRaySession; @@ -32,43 +44,66 @@ public: explicit SceneGraphTools(GammaRaySession *session, QObject *parent = nullptr); // --- Connection management --- - // Connect to a GammaRay probe. host defaults to 127.0.0.1, port to 11732 - // (GammaRay's default). Blocks up to ~6s for the handshake. Returns JSON: - // {"connected":bool,"state":"ready|connecting|failed|disconnected", - // "url":"tcp://127.0.0.1:11732","error":""} - // Even on failure the URL is remembered so subsequent tool calls will retry. Q_INVOKABLE QString connectProbe(const QString &host, int port) const; - // Same with default port (overload not supported by qtmcp; use 0 to mean default). Q_INVOKABLE QString connectProbeDefault() const; - // Drop the current connection and forget the URL. Subsequent tool calls - // will require connectProbe() again. Q_INVOKABLE QString disconnectProbe() const; - // Report current connection state. Returns the same JSON shape as - // connectProbe() minus the action. Q_INVOKABLE QString probeStatus() const; - // --- Introspection --- - // List top-level QQuickWindows. Returns compact JSON: [{"address":..,"type":..}] + // --- Window-level navigation --- Q_INVOKABLE QString listQuickWindows() const; - - // Select a Quick window by index (into listQuickWindows) on the probe side. - // This is required before listScenegraphNodes returns anything — the - // QuickSceneGraphModel is built for the *selected* window. - // Uses Endpoint::invokeObject directly since QuickInspectorInterface is not - // in the installed client headers (PLAN.md open question #1). Q_INVOKABLE QString selectQuickWindow(int index) const; - // Walk the QSGNode tree and return nested JSON: - // [{"address":..,"type":..,"children":[...]}] + // --- SceneGraph navigation --- Q_INVOKABLE QString listScenegraphNodes() const; + // TODO: re-enable when the selection-sync / sub-model-population bug is fixed + // Find a node by address (e.g. "0x56396e6d7400") in the SG tree and select it + // via the synced selection model. Returns JSON with the node's type and which + // sub-models have data. Must be called before geometry/material tools can + // return data for that node. Selecting a non-GeometryNode clears the + // geometry/material models (they only populate for QSGGeometryNode). + // Q_INVOKABLE QString selectScenegraphNode(const QString &address) const; + + // --- Geometry (requires a GeometryNode to be selected) --- + // TODO: re-enable when the selection-sync / sub-model-population bug is fixed + // Returns vertex data: rows = vertices, columns = attributes. + // Each cell is the display string (e.g. "0.5, 0.5, 0, 1"); headers are + // attribute type names (PositionAttribute, ColorAttribute, ...). + // Also includes isCoordinate per column. + // Q_INVOKABLE QString getNodeVertices(const QString &address) const; + // Returns adjacency data: drawing mode + index list. + // Q_INVOKABLE QString getNodeAdjacency(const QString &address) const; + + // --- Material/Shader (requires a GeometryNode to be selected) --- + // TODO: re-enable when the selection-sync / sub-model-population bug is fixed + // List shader stages (Vertex, Fragment, ...) for the selected node's material. + // Q_INVOKABLE QString getMaterialShaders(const QString &address) const; + // Async: request shader source for a row (from getMaterialShaders). + // Uses the MaterialExtensionInterface proxy + gotShader signal. + // Q_INVOKABLE QString getShaderSource(int row) const; + // Material properties (name/value pairs from AggregatedPropertyModel). + // Q_INVOKABLE QString getMaterialProperties(const QString &address) const; + + // --- Rendering visualization --- + // Set custom render mode. mode ∈ {NormalRendering, VisualizeClipping, + // VisualizeOverdraw, VisualizeBatches, VisualizeChanges, VisualizeTraces} + Q_INVOKABLE QString setRenderMode(const QString &mode) const; + // Toggle slow animations mode (renders continuously instead of on-demand). + Q_INVOKABLE QString setSlowMode(bool enabled) const; private: GammaRaySession *m_session; + mutable QString m_selectedAddress; // last selected node address (for sub-model tools) + mutable QString m_lastSelectPath; // diagnostic: last selection path info - // Shared helper: ensures a connection (auto-reconnect if a URL is known), - // returns nullptr on success or a JSON error string the caller can return - // verbatim. The JSON always hints at connectProbe() when no URL is known. QString ensureSession() const; + // Find the QModelIndex for address in the SG tree (prime + search). + // Returns invalid index if not found. + QModelIndex findNodeByAddress(QAbstractItemModel *m, const QString &address) const; + // Ensure the given SG node is selected (select it if not already). Returns + // empty string on success, or a JSON error string. + QString ensureNodeSelected(const QString &address) const; + // Wait for a model to have rows (polling with event loop, up to timeoutMs). + void waitForRows(QAbstractItemModel *m, int timeoutMs) const; }; #endif // SCENEGRAPH_TOOLS_H