diff --git a/PLAN.md b/PLAN.md
index ec3d1d3..60460ca 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -550,11 +550,62 @@ 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`.
+## Qt Widget support (planned)
+
+GammaRay 的 widget inspector 与 quick inspector 共享同一套底层架构(`PropertyController`、`SelectionModelClient`、`RemoteModel`),大部分 QML 工具的模式可直接复用。
+
+### 可提供的 widget 工具
+
+**导航(与 QML 类似,模型名不同)**
+- `listWidgets()` — 遍历 `com.kdab.GammaRay.WidgetTree` 模型,返回 widget 层次树
+- `selectWidget(address)` — 通过 `SelectionModelClient` 选取 widget,触发 `PropertyController` 填充子模型(属性、attribute 等)
+- `listTopLevelWindows()`(已有 `listQuickWindows`,可在 widget 应用中继续使用)
+
+**属性/attribute(与 QML 共享读取实现)**
+- `getWidgetProperties(address)` — 读取同一套 `AggregatedPropertyModel`(通过 `PropertyController`),模型对象名 `com.kdab.GammaRay.Widget.
.properties`,与 `getItemProperties` 共享实现代码
+- `getWidgetAttributes(address)` — 读取 `widgetAttributeModel`(`AttributeModel`),列出 Qt::WidgetAttribute 值
+
+**视觉/分析(新功能,需 RemoteViewInterface)**
+- `grabWidget(address)` — 通过 `WidgetRemoteView`(`com.kdab.GammaRay.WidgetRemoteView`)抓取 widget 截图,异步图像传输
+- `analyzePainting(address)` — 触发 `PaintAnalyzer` 捕获绘制操作
+- `exportWidgetAsSvg(address)` — 通过 `WidgetInspectorInterface` 导出 SVG
+- `exportWidgetAsUiFile(address)` — 导出 Qt Designer .ui 文件
+
+**控制**
+- `setInputRedirection(enabled)` — 允许将鼠标/键盘事件转发到目标 widget
+
+### 实现策略
+
+| 难度 | 工作项 | 说明 |
+|---|---|---|
+| 低 | `listWidgets()` / `selectWidget()` | 直接复用 `listQuickItems()` / `selectQuickItem()` 的代码模式,模型名改为 `com.kdab.GammaRay.WidgetTree` |
+| 低 | `getWidgetProperties()` | 与 `getItemProperties()` 完全一样,都读 `AggregatedPropertyModel`,可提取共享函数 |
+| 中 | `getWidgetAttributes()` | 需要读取 `widgetAttributeModel`(`PropertyController` 子模型),模式与 `getMaterialProperties()` 类似 |
+| 中 | `WidgetInspectorInterface` 代理 | IID `com.kdab.GammaRay.WidgetInspector`,用于 `export*`/`setInputRedirection` 等方法,类似 `QuickInspectorProxy` |
+| 高 | `grabWidget()` / `analyzePainting()` | 需要 `RemoteViewInterface` 的 `TransferImage` 异步路径,模式与 `MaterialExtensionInterface` 的 `gotShader` 信号类似 |
+
+### 与 QML 工具的关键区别
+
+- **选取机制不同**:QML 用 `QuickItemModel.selection`,Widget 用 `WidgetTree.selection` → 不同的 `PropertyController` 实例
+- **模型对象名不同**:property model 对象名为 `com.kdab.GammaRay.QuickItem..properties` vs `com.kdab.GammaRay.Widget..properties`
+- **Attribute 模型是 widget 独有的**:QML 项没有 `Qt::WidgetAttribute` 的概念
+- **Widget 没有 SceneGraph**:不需要 `getNodeVertices`/`getMaterialShaders` 等 SG 工具
+- **Widget 有截图和绘制分析**:QML 的 texture grab 类似但不同
+
## Next steps
-1. **Optimize test suite runtime** — share bridge across tests or reduce settle delays to bring ~8min down to ~2-3min.
-2. **Test `getShaderSource` async path end-to-end** — needs a QML app with real shaders (current test app uses software renderer where GeometryNodes have `nullptr` geometry).
-3. **Test geometry/material tools against a QML app with real QSGGeometry data** — current test app `relayout.qml` uses software renderer so `hasGeometry: false` and empty sub-models are correct behavior.
-4. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern.
-5. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call.
-6. **Build `--connect` startup flag integration** with systemd service for auto-attach during development.
+### QML SceneGraph 工具(当前阶段)
+
+1. **Test `getShaderSource` async path end-to-end** — needs a QML app with real shaders (current test app uses software renderer where GeometryNodes have `nullptr` geometry).
+2. **Test geometry/material tools against a QML app with real QSGGeometry data** — current test app `relayout.qml` uses software renderer so `hasGeometry: false` and empty sub-models are correct behavior.
+3. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern.
+4. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call.
+5. **Build `--connect` startup flag integration** with systemd service for auto-attach during development.
+
+### Qt Widget 支持(未来阶段)
+
+1. 创建 widget_tools.{h,cpp},注册 `listWidgets` / `selectWidget` / `getWidgetProperties` / `getWidgetAttributes` MCP 工具
+2. 从 `scenegraph_tools.cpp` 提取共享的 `AggregatedPropertyModel` 读取函数,供 QML 和 widget 工具共用
+3. 创建 `WidgetInspectorInterface` 代理(类似 `QuickInspectorProxy`),处理 `export*`/`setInputRedirection`
+4. 实现 `RemoteViewInterface` 异步图像传输路径(grabWidget/analyzePainting)
+5. 添加 widget 测试应用(简单 QWidget 窗口 + 按钮/布局),编写 widget 工具的集成测试
diff --git a/bridge/CMakeLists.txt b/bridge/CMakeLists.txt
index 8aeb7de..08cc2e3 100644
--- a/bridge/CMakeLists.txt
+++ b/bridge/CMakeLists.txt
@@ -48,6 +48,12 @@ add_executable(qml-sg-mcp-bridge
src/quickinspector_proxy.cpp
src/quickinspector_proxy.h
src/quickinspector_types.h
+ src/property_reader.cpp
+ src/property_reader.h
+ src/widget_tools.cpp
+ src/widget_tools.h
+ src/widget_inspector_proxy.cpp
+ src/widget_inspector_proxy.h
)
target_link_libraries(qml-sg-mcp-bridge PRIVATE
diff --git a/bridge/src/gammaray_session.cpp b/bridge/src/gammaray_session.cpp
index 9137094..2336f83 100644
--- a/bridge/src/gammaray_session.cpp
+++ b/bridge/src/gammaray_session.cpp
@@ -8,6 +8,7 @@
#include "gammaray_session.h"
#include "quickinspector_proxy.h"
+#include "widget_inspector_proxy.h"
#include
#include
@@ -39,6 +40,9 @@ GammaRaySession::GammaRaySession(QObject *parent)
// GammaRay's Endpoint cannot dispatch the signal.
GammaRay::registerQuickInspectorProxy();
+ // Register a minimal WidgetInspectorInterface proxy similarly.
+ GammaRay::registerWidgetInspectorProxy();
+
// Pre-fetch the remote models we expose as tools. ObjectBroker::model()
// lazily creates a RemoteModel which then syncs asynchronously from the
// probe (~1-2s for the first rows). Requesting them now at ready() means
diff --git a/bridge/src/main.cpp b/bridge/src/main.cpp
index 2f4ac8e..7a41c7e 100644
--- a/bridge/src/main.cpp
+++ b/bridge/src/main.cpp
@@ -16,6 +16,7 @@
#include "gammaray_session.h"
#include "scenegraph_tools.h"
+#include "widget_tools.h"
#include
@@ -70,9 +71,9 @@ int main(int argc, char **argv)
QMcpServer server(QStringLiteral("stdio"));
server.setInstructions(QStringLiteral(
- "QML SceneGraph introspection via GammaRay. Connects to a GammaRay probe "
- "injected into a Qt/QML application and exposes the scene graph, items, "
- "geometry, materials and shaders as tools."));
+ "QML SceneGraph & Qt Widget introspection via GammaRay. Connects to a "
+ "GammaRay probe injected into a Qt/QML application and exposes the scene "
+ "graph, QML items, widgets, geometry, materials and shaders as tools."));
SceneGraphTools tools(&session, &server);
server.registerToolSet(&tools, {
@@ -115,8 +116,20 @@ int main(int argc, char **argv)
{ 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") },
+ // Widget tree navigation
+ { QStringLiteral("listWidgets"), QStringLiteral("List the QWidget hierarchy (widget types, names, visibility)") },
+ // Widget selection + properties
+ { QStringLiteral("selectWidget"), QStringLiteral("Select a widget by address (from listWidgets) so widget properties populate for it") },
+ { QStringLiteral("selectWidget/address"), QStringLiteral("Widget address from listWidgets") },
+ { QStringLiteral("getWidgetProperties"), QStringLiteral("Get all Q_PROPERTY values for a selected widget (geometry, font, palette, etc.)") },
+ { QStringLiteral("getWidgetProperties/address"), QStringLiteral("Widget address (from listWidgets)") },
+ { QStringLiteral("getWidgetAttributes"), QStringLiteral("Get Qt::WidgetAttribute flags (acceptDrops, enabled, etc.) for a selected widget") },
+ { QStringLiteral("getWidgetAttributes/address"), QStringLiteral("Widget address (from listWidgets)") },
});
+ WidgetTools widgetTools(&session, &server);
+ server.registerToolSet(&widgetTools, {});
+
QObject::connect(&server, &QMcpServer::finished, &app, &QCoreApplication::quit);
if (probeUrl.isValid()) {
diff --git a/bridge/src/property_reader.cpp b/bridge/src/property_reader.cpp
new file mode 100644
index 0000000..ef48753
--- /dev/null
+++ b/bridge/src/property_reader.cpp
@@ -0,0 +1,142 @@
+/*
+ property_reader.cpp — shared AggregatedPropertyModel reader.
+ Extracted from scenegraph_tools.cpp getItemProperties().
+
+ SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#include "property_reader.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Poll until column-0 data arrives for at least one row (RemoteModel lazy-fetch).
+static void waitForPropertyData(QAbstractItemModel *m, int rows, int timeoutMs)
+{
+ QEventLoop loop;
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs);
+ bool gotData = false;
+ while (std::chrono::steady_clock::now() < deadline && !gotData) {
+ for (int r = 0; r < rows; ++r) {
+ const auto val = m->data(m->index(r, 0), Qt::DisplayRole).toString();
+ if (!val.isEmpty() && val != QStringLiteral("Loading...")) {
+ gotData = true;
+ break;
+ }
+ }
+ if (!gotData) {
+ QTimer::singleShot(200, &loop, &QEventLoop::quit);
+ loop.exec();
+ }
+ }
+}
+
+QJsonObject readAggregatedPropertyModel(QAbstractItemModel *m)
+{
+ if (!m)
+ return {};
+
+ // Wait for rows to become available (RemoteModel lazy-fetch).
+ {
+ QEventLoop loop;
+ int waited = 0;
+ const int step = 150;
+ while (waited < 3000) {
+ if (m->rowCount() > 0)
+ break;
+ QTimer::singleShot(step, &loop, &QEventLoop::quit);
+ loop.exec();
+ waited += step;
+ }
+ }
+
+ const int rows = m->rowCount();
+ if (rows == 0)
+ return {};
+
+ waitForPropertyData(m, rows, 5000);
+
+ const int actualRows = m->rowCount();
+ const int cols = m->columnCount();
+
+ // Pre-fetch children for grouped properties.
+ for (int r = 0; r < actualRows; ++r)
+ m->rowCount({ m->index(r, 0) });
+
+ if (actualRows > 0) {
+ QEventLoop l2;
+ QTimer::singleShot(500, &l2, &QEventLoop::quit);
+ l2.exec();
+ }
+
+ for (int r = 0; r < actualRows; ++r) {
+ const QModelIndex idx0 = m->index(r, 0);
+ if (m->hasChildren(idx0) && m->canFetchMore(idx0))
+ m->fetchMore(idx0);
+ }
+
+ if (actualRows > 0) {
+ QEventLoop l2b;
+ QTimer::singleShot(500, &l2b, &QEventLoop::quit);
+ l2b.exec();
+ }
+
+ QJsonObject simpleProps;
+ QJsonObject groups;
+
+ for (int r = 0; r < actualRows; ++r) {
+ const QString name = m->data(m->index(r, 0), Qt::DisplayRole).toString();
+ const QString val = m->data(m->index(r, 1), Qt::DisplayRole).toString();
+ const QString ptype = cols > 2 ? m->data(m->index(r, 2), Qt::DisplayRole).toString() : QString();
+
+ QJsonObject prop;
+ prop.insert(QStringLiteral("value"), val);
+ prop.insert(QStringLiteral("type"), ptype);
+
+ const QModelIndex idx0 = m->index(r, 0);
+ if (m->hasChildren(idx0)) {
+ const int childRows = m->rowCount(idx0);
+ if (childRows > 0) {
+ for (int cr = 0; cr < childRows; ++cr) {
+ m->data(m->index(cr, 0, idx0), Qt::DisplayRole);
+ m->data(m->index(cr, 1, idx0), Qt::DisplayRole);
+ }
+ {
+ QEventLoop l3;
+ QTimer::singleShot(500, &l3, &QEventLoop::quit);
+ l3.exec();
+ }
+ QJsonObject children;
+ for (int cr = 0; cr < childRows; ++cr) {
+ const QString cname = m->data(m->index(cr, 0, idx0), Qt::DisplayRole).toString();
+ const QString cval = m->data(m->index(cr, 1, idx0), Qt::DisplayRole).toString();
+ const QString ctype = cols > 2 ? m->data(m->index(cr, 2, idx0), Qt::DisplayRole).toString() : QString();
+ if (cname.isEmpty() || cname == QStringLiteral("Loading..."))
+ continue;
+ QJsonObject child;
+ child.insert(QStringLiteral("value"), cval);
+ child.insert(QStringLiteral("type"), ctype);
+ children.insert(cname, child);
+ }
+ if (!children.isEmpty())
+ prop.insert(QStringLiteral("children"), children);
+ }
+ groups.insert(name, prop);
+ } else {
+ simpleProps.insert(name, prop);
+ }
+ }
+
+ QJsonObject out;
+ out.insert(QStringLiteral("properties"), simpleProps);
+ if (!groups.isEmpty())
+ out.insert(QStringLiteral("groups"), groups);
+ return out;
+}
\ No newline at end of file
diff --git a/bridge/src/property_reader.h b/bridge/src/property_reader.h
new file mode 100644
index 0000000..7bef1ab
--- /dev/null
+++ b/bridge/src/property_reader.h
@@ -0,0 +1,16 @@
+/*
+ property_reader.h — shared AggregatedPropertyModel reader for QML items and widgets.
+
+ SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#ifndef PROPERTY_READER_H
+#define PROPERTY_READER_H
+
+class QAbstractItemModel;
+class QJsonObject;
+
+QJsonObject readAggregatedPropertyModel(QAbstractItemModel *m);
+
+#endif // PROPERTY_READER_H
\ No newline at end of file
diff --git a/bridge/src/quickinspector_proxy.cpp b/bridge/src/quickinspector_proxy.cpp
index 7b66d81..9013d6a 100644
--- a/bridge/src/quickinspector_proxy.cpp
+++ b/bridge/src/quickinspector_proxy.cpp
@@ -7,7 +7,9 @@
#include "quickinspector_proxy.h"
+#include
#include
+#include
#include
@@ -32,6 +34,16 @@ void registerQuickInspectorProxy()
static bool registered = false;
if (registered)
return;
+
+ // Only register if the QuickInspector plugin is actually active in the
+ // target process. Check via QuickWindowModel address.
+ auto *ep = Endpoint::instance();
+ if (!ep)
+ return;
+ if (ep->objectAddress(QStringLiteral("com.kdab.GammaRay.QuickWindowModel"))
+ == Protocol::InvalidObjectAddress)
+ return;
+
registered = true;
auto *proxy = new QuickInspectorProxy();
diff --git a/bridge/src/scenegraph_tools.cpp b/bridge/src/scenegraph_tools.cpp
index fc1afa5..6df2fe2 100644
--- a/bridge/src/scenegraph_tools.cpp
+++ b/bridge/src/scenegraph_tools.cpp
@@ -8,6 +8,7 @@
#include "scenegraph_tools.h"
#include "gammaray_session.h"
#include "material_interface.h"
+#include "property_reader.h"
#include "quickinspector_types.h"
#include
@@ -40,6 +41,19 @@ static constexpr int kIsCoordinateRole = 257;
static constexpr int kDrawingModeRole = 257;
static constexpr int kRenderRole = 258;
+// Returns a non-empty error string if the QuickInspector is not active.
+// Guards QML-specific tools against widget-only target processes.
+static QString ensureQuickInspector()
+{
+ auto *ep = GammaRay::Endpoint::instance();
+ if (!ep)
+ return QStringLiteral("no GammaRay endpoint");
+ if (ep->objectAddress(QString::fromUtf8(kQuickInspectorIface))
+ == GammaRay::Protocol::InvalidObjectAddress)
+ return QStringLiteral("QuickInspector not active — target app has no QQuickWindow");
+ return {};
+}
+
// --- async helpers (from earlier scaffold) ---
static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs)
@@ -324,6 +338,8 @@ QString SceneGraphTools::listQuickWindows() const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
+ if (const QString e = ensureQuickInspector(); !e.isEmpty())
+ return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
auto *m = m_session->model(QString::fromUtf8(kQuickWindowModel));
if (!m)
@@ -348,6 +364,8 @@ QString SceneGraphTools::selectQuickWindow(int index) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
+ if (const QString e = ensureQuickInspector(); !e.isEmpty())
+ return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
auto *ep = GammaRay::Endpoint::instance();
if (!ep)
@@ -367,6 +385,8 @@ QString SceneGraphTools::listQuickItems() const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
+ if (const QString e = ensureQuickInspector(); !e.isEmpty())
+ return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
auto *m = m_session->model(QString::fromUtf8(kQuickItemModel));
if (!m)
@@ -384,6 +404,8 @@ QString SceneGraphTools::listScenegraphNodes() const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
+ if (const QString e = ensureQuickInspector(); !e.isEmpty())
+ return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
auto *m = m_session->model(QString::fromUtf8(kQuickSceneGraphModel));
if (!m)
@@ -444,6 +466,8 @@ QString SceneGraphTools::ensureNodeSelected(const QString &address) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
+ if (const QString e = ensureQuickInspector(); !e.isEmpty())
+ return e;
if (address.isEmpty())
return QString::fromUtf8(QJsonDocument(errorJson(
@@ -739,6 +763,8 @@ QString SceneGraphTools::ensureItemSelected(const QString &address) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
+ if (const QString e = ensureQuickInspector(); !e.isEmpty())
+ return e;
if (address.isEmpty())
return QString::fromUtf8(QJsonDocument(errorJson(
@@ -840,127 +866,12 @@ QString SceneGraphTools::getItemProperties(const QString &address) const
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("item property model not available"))).toJson(QJsonDocument::Compact));
- // Item property properties model has 4 columns:
- // 0 = property name, 1 = value (display string), 2 = type name, 3 = class name
- waitForRows(m, 3000);
-
- const int rows = m->rowCount();
- if (rows == 0)
+ QJsonObject out = readAggregatedPropertyModel(m);
+ if (out.isEmpty())
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no properties available (did you select a valid QML item?)")))
.toJson(QJsonDocument::Compact));
- // The RemoteModel uses lazy-fetch: data() triggers a fetch and returns
- // "Loading..." until the fetch response arrives on the event loop.
- // Poll until column 0 data arrives for at least one row.
- {
- QEventLoop loop;
- const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
- bool gotData = false;
- while (std::chrono::steady_clock::now() < deadline && !gotData) {
- for (int r = 0; r < rows; ++r) {
- const QString val = m->data(m->index(r, 0), Qt::DisplayRole).toString();
- if (!val.isEmpty() && val != QStringLiteral("Loading...")) {
- gotData = true;
- break;
- }
- }
- if (!gotData) {
- QTimer::singleShot(200, &loop, &QEventLoop::quit);
- loop.exec();
- }
- }
- }
-
-// Re-read rowCount after polling (more rows may have arrived)
- const int actualRows = m->rowCount();
- const int cols = m->columnCount();
-
- QJsonObject out;
- QStringList headers;
- for (int c = 0; c < cols; ++c)
- headers.append(m->headerData(c, Qt::Horizontal, Qt::DisplayRole).toString());
-
- // Pre-fetch children for grouped properties. RemoteModel uses lazy-fetch:
- // rowCount(parent) triggers an async request and returns 0 until the reply
- // arrives on the event loop. After polling, hasChildren() works correctly.
- for (int r = 0; r < actualRows; ++r) {
- m->rowCount(m->index(r, 0));
- }
- // Poll to let row count responses arrive
- if (actualRows > 0) {
- QEventLoop l2;
- QTimer::singleShot(500, &l2, &QEventLoop::quit);
- l2.exec();
- }
- // Second pass: ensure child data is fetched for rows that have children
- for (int r = 0; r < actualRows; ++r) {
- const QModelIndex idx0 = m->index(r, 0);
- if (m->hasChildren(idx0) && m->canFetchMore(idx0))
- m->fetchMore(idx0);
- }
- if (actualRows > 0) {
- QEventLoop l2b;
- QTimer::singleShot(500, &l2b, &QEventLoop::quit);
- l2b.exec();
- }
-
- // Top-level properties (simple Q_PROPERTY values like x, y, width, height...)
- QJsonObject simpleProps;
- // Nested property groups (anchors, transform, etc.) will have children.
- QJsonObject groups;
- for (int r = 0; r < actualRows; ++r) {
- const QString name = m->data(m->index(r, 0), Qt::DisplayRole).toString();
- const QString val = m->data(m->index(r, 1), Qt::DisplayRole).toString();
- const QString ptype = cols > 2 ? m->data(m->index(r, 2), Qt::DisplayRole).toString() : QString();
-
- QJsonObject prop;
- prop.insert(QStringLiteral("value"), val);
- prop.insert(QStringLiteral("type"), ptype);
-
- const QModelIndex idx0 = m->index(r, 0);
- if (m->hasChildren(idx0)) {
- // For grouped properties (anchors, transform, etc.), collect children.
- // Children also use lazy-fetch, so poll if needed.
- const int childRows = m->rowCount(idx0);
- if (childRows > 0) {
- // Trigger child data fetches first
- for (int cr = 0; cr < childRows; ++cr) {
- m->data(m->index(cr, 0, idx0), Qt::DisplayRole);
- m->data(m->index(cr, 1, idx0), Qt::DisplayRole);
- }
- // Poll to let child data arrive
- {
- QEventLoop l3;
- QTimer::singleShot(500, &l3, &QEventLoop::quit);
- l3.exec();
- }
- // Read actual child data
- QJsonObject children;
- for (int cr = 0; cr < childRows; ++cr) {
- const QString cname = m->data(m->index(cr, 0, idx0), Qt::DisplayRole).toString();
- const QString cval = m->data(m->index(cr, 1, idx0), Qt::DisplayRole).toString();
- const QString ctype = cols > 2 ? m->data(m->index(cr, 2, idx0), Qt::DisplayRole).toString() : QString();
- if (cname.isEmpty() || cname == QStringLiteral("Loading..."))
- continue;
- QJsonObject child;
- child.insert(QStringLiteral("value"), cval);
- child.insert(QStringLiteral("type"), ctype);
- children.insert(cname, child);
- }
- if (!children.isEmpty())
- prop.insert(QStringLiteral("children"), children);
- }
- groups.insert(name, prop);
- } else {
- simpleProps.insert(name, prop);
- }
- }
-
- out.insert(QStringLiteral("properties"), simpleProps);
- if (!groups.isEmpty())
- out.insert(QStringLiteral("groups"), groups);
-
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
@@ -968,6 +879,8 @@ QString SceneGraphTools::setRenderMode(const QString &mode) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
+ if (const QString e = ensureQuickInspector(); !e.isEmpty())
+ return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
auto *ep = GammaRay::Endpoint::instance();
if (!ep)
@@ -1009,6 +922,8 @@ QString SceneGraphTools::setSlowMode(bool enabled) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
+ if (const QString e = ensureQuickInspector(); !e.isEmpty())
+ return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
auto *ep = GammaRay::Endpoint::instance();
if (!ep)
diff --git a/bridge/src/widget_inspector_proxy.cpp b/bridge/src/widget_inspector_proxy.cpp
new file mode 100644
index 0000000..14b4254
--- /dev/null
+++ b/bridge/src/widget_inspector_proxy.cpp
@@ -0,0 +1,49 @@
+/*
+ widget_inspector_proxy.cpp — Minimal WidgetInspectorInterface client proxy.
+
+ SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#include "widget_inspector_proxy.h"
+
+#include
+#include
+#include
+
+#include
+
+namespace GammaRay {
+
+WidgetInspectorProxy::WidgetInspectorProxy(QObject *parent)
+ : QObject(parent)
+{
+}
+
+void registerWidgetInspectorProxy()
+{
+ static bool registered = false;
+ if (registered)
+ return;
+
+ auto *ep = Endpoint::instance();
+ if (!ep)
+ return;
+
+ // Only register if the widget inspector plugin is actually active in the
+ // target process. The QuickInspector plugin activates for any QQuickWindow
+ // app, but WidgetInspector requires QWidget/QApplication. Check via the
+ // WidgetTree model address — if it's valid, the plugin is loaded.
+ if (ep->objectAddress(QStringLiteral("com.kdab.GammaRay.WidgetTree"))
+ == Protocol::InvalidObjectAddress)
+ return; // widget inspector not active — skip registration
+
+ registered = true;
+
+ auto *proxy = new WidgetInspectorProxy();
+ ObjectBroker::registerObject(
+ QStringLiteral("com.kdab.GammaRay.WidgetInspector"),
+ proxy);
+}
+
+} // namespace GammaRay
\ No newline at end of file
diff --git a/bridge/src/widget_inspector_proxy.h b/bridge/src/widget_inspector_proxy.h
new file mode 100644
index 0000000..02ac21d
--- /dev/null
+++ b/bridge/src/widget_inspector_proxy.h
@@ -0,0 +1,33 @@
+/*
+ widget_inspector_proxy.h — Minimal WidgetInspectorInterface client proxy.
+
+ SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
+ SPDX-License-Identifier: GPL-2.0-or-later
+
+ The probe emits WidgetInspectorInterface signals without a matching slot.
+ This proxy registers with the IID so GammaRay's Endpoint can dispatch signals.
+*/
+
+#ifndef WIDGET_INSPECTOR_PROXY_H
+#define WIDGET_INSPECTOR_PROXY_H
+
+#include
+
+namespace GammaRay {
+
+class WidgetInspectorProxy : public QObject
+{
+ Q_OBJECT
+ Q_CLASSINFO("IID", "com.kdab.GammaRay.WidgetInspector")
+public:
+ explicit WidgetInspectorProxy(QObject *parent = nullptr);
+
+public slots:
+ void featuresChanged() {}
+};
+
+void registerWidgetInspectorProxy();
+
+} // namespace GammaRay
+
+#endif // WIDGET_INSPECTOR_PROXY_H
\ No newline at end of file
diff --git a/bridge/src/widget_tools.cpp b/bridge/src/widget_tools.cpp
new file mode 100644
index 0000000..6978215
--- /dev/null
+++ b/bridge/src/widget_tools.cpp
@@ -0,0 +1,364 @@
+/*
+ widget_tools.cpp — Qt Widget introspection tools via GammaRay.
+
+ SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#include "widget_tools.h"
+#include "gammaray_session.h"
+#include "property_reader.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static const char *kWidgetTreeModel = "com.kdab.GammaRay.WidgetTree";
+static const char *kWidgetBaseName = "com.kdab.GammaRay.WidgetInspector";
+
+static constexpr int kWidgetFlagsRole = 261;
+
+static QJsonObject errorJson(const QString &msg)
+{
+ return { { QStringLiteral("error"), msg } };
+}
+
+static void settle(QEventLoop &loop, int ms)
+{
+ QTimer::singleShot(ms, &loop, &QEventLoop::quit);
+ loop.exec();
+}
+
+static void primeTree(const QAbstractItemModel *m, const QModelIndex &parent, int depth)
+{
+ if (depth <= 0)
+ return;
+ for (int r = 0; r < m->rowCount(parent); ++r) {
+ const QModelIndex idx = m->index(r, 0, parent);
+ m->data(idx, Qt::DisplayRole);
+ m->data(m->index(r, 1, parent), Qt::DisplayRole);
+ if (m->hasChildren(idx))
+ primeTree(m, idx, depth - 1);
+ }
+}
+
+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);
+ if (m->data(idx, Qt::DisplayRole).toString() == QLatin1String("Loading..."))
+ ++count;
+ if (m->hasChildren(idx))
+ count += countLoading(m, idx, depth - 1);
+ }
+ return count;
+}
+
+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;
+ }
+
+ if (countLoading(m, QModelIndex(), 16) > 0) {
+ settle(loop, finalSettleMs);
+ primeTree(m, QModelIndex(), 16);
+ settle(loop, finalSettleMs / 2);
+ }
+}
+
+static QModelIndex findWidgetInTree(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 (addr == QLatin1String("Loading...")) {
+ settle(loop, 300);
+ if (m->data(idx, Qt::DisplayRole).toString() == address)
+ return idx;
+ }
+ if (m->hasChildren(idx)) {
+ m->rowCount(idx);
+ settle(loop, 200);
+ const auto found = findWidgetInTree(m, idx, address, depth - 1, loop);
+ if (found.isValid())
+ return found;
+ }
+ }
+ return {};
+}
+
+static QJsonArray walkWidgetChildren(const QAbstractItemModel *m, const QModelIndex &parent, int depth)
+{
+ QJsonArray out;
+ if (depth <= 0)
+ return out;
+ for (int r = 0; r < m->rowCount(parent); ++r) {
+ const QModelIndex idx = m->index(r, 0, parent);
+ const QString name = m->data(idx, Qt::DisplayRole).toString();
+ const QModelIndex typeIdx = m->index(r, 1, parent);
+ const QString type = m->data(typeIdx, Qt::DisplayRole).toString();
+ const int flags = m->data(idx, kWidgetFlagsRole).toInt();
+ QJsonObject node;
+ node.insert(QStringLiteral("name"), name);
+ node.insert(QStringLiteral("type"), type);
+ if (flags & 1)
+ node.insert(QStringLiteral("invisible"), true);
+ if (m->hasChildren(idx))
+ node.insert(QStringLiteral("children"), walkWidgetChildren(m, idx, depth - 1));
+ out.append(node);
+ }
+ return out;
+}
+
+// --- WidgetTools ---
+
+WidgetTools::WidgetTools(GammaRaySession *session, QObject *parent)
+ : QObject(parent)
+ , m_session(session)
+{
+}
+
+QString WidgetTools::ensureSession() const
+{
+ if (!m_session)
+ return QString::fromUtf8(QJsonDocument(errorJson(
+ QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
+ if (m_session->lastUrl().isValid())
+ m_session->ensureConnected(6000);
+ if (m_session->isReady())
+ return {};
+ QJsonObject err = errorJson(
+ m_session->lastUrl().isValid()
+ ? QStringLiteral("not connected to probe (last error: %1; call connectProbe to retry)")
+ .arg(m_session->lastError().isEmpty() ? QStringLiteral("timeout") : m_session->lastError())
+ : QStringLiteral("no probe URL configured — call connectProbe(host, port) first"));
+ err.insert(QStringLiteral("state"), m_session->stateString());
+ return QString::fromUtf8(QJsonDocument(err).toJson(QJsonDocument::Compact));
+}
+
+void WidgetTools::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;
+ }
+}
+
+QModelIndex WidgetTools::findWidgetByAddress(QAbstractItemModel *m, const QString &address) const
+{
+ if (!m || address.isEmpty())
+ return {};
+
+ primeAndWait(m, 6, 300, 500);
+
+ QEventLoop loop;
+ const auto idx = findWidgetInTree(m, QModelIndex(), address, 16, loop);
+ if (idx.isValid())
+ return idx;
+
+ for (int round = 0; round < 3; ++round) {
+ primeAndWait(m, 2, 300, 500);
+ const auto idx2 = findWidgetInTree(m, QModelIndex(), address, 16, loop);
+ if (idx2.isValid())
+ return idx2;
+ }
+ return {};
+}
+
+QString WidgetTools::ensureWidgetSelected(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 (address == m_selectedWidgetAddress)
+ return {};
+
+ auto *treeModel = m_session->model(QString::fromUtf8(kWidgetTreeModel));
+ if (!treeModel)
+ return QString::fromUtf8(QJsonDocument(errorJson(
+ QStringLiteral("WidgetTree model not available (no QWidget app attached?)")))
+ .toJson(QJsonDocument::Compact));
+
+ const QModelIndex idx = findWidgetByAddress(treeModel, address);
+ if (!idx.isValid())
+ return QString::fromUtf8(QJsonDocument(errorJson(
+ QStringLiteral("widget %1 not found in widget tree (still loading?)").arg(address)))
+ .toJson(QJsonDocument::Compact));
+
+ auto *selModel = m_session->selectionModel(treeModel);
+
+ auto *propModel = m_session->model(QStringLiteral("%1.properties").arg(QString::fromUtf8(kWidgetBaseName)));
+
+ QEventLoop loop;
+ settle(loop, 300);
+
+ if (selModel && idx.isValid())
+ selModel->select(idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows | QItemSelectionModel::Current);
+
+ settle(loop, 2000);
+
+ m_selectedWidgetAddress = address;
+
+ if (propModel) {
+ propModel->rowCount();
+ settle(loop, 1500);
+ }
+
+ return {};
+}
+
+QString WidgetTools::listWidgets() const
+{
+ if (const QString e = ensureSession(); !e.isEmpty())
+ return e;
+
+ auto *m = m_session->model(QString::fromUtf8(kWidgetTreeModel));
+ if (!m)
+ return QString::fromUtf8(QJsonDocument(errorJson(
+ QStringLiteral("WidgetTree model not available (no QWidget app attached?)")))
+ .toJson(QJsonDocument::Compact));
+
+ primeAndWait(m, 6, 300, 500);
+
+ const QJsonArray tree = walkWidgetChildren(m, QModelIndex(), 16);
+ return QString::fromUtf8(QJsonDocument(tree).toJson(QJsonDocument::Compact));
+}
+
+QString WidgetTools::selectWidget(const QString &address) const
+{
+ m_selectedWidgetAddress.clear();
+ const QString e = ensureWidgetSelected(address);
+ if (!e.isEmpty())
+ return e;
+
+ auto *treeModel = m_session->model(QString::fromUtf8(kWidgetTreeModel));
+ QJsonObject out;
+ if (!treeModel) {
+ out.insert(QStringLiteral("error"), QStringLiteral("WidgetTree model gone"));
+ return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
+ }
+ const QModelIndex idx = findWidgetByAddress(treeModel, address);
+ if (!idx.isValid()) {
+ out.insert(QStringLiteral("error"), QStringLiteral("address %1 not found after selection").arg(address));
+ return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
+ }
+ const QModelIndex typeIdx = treeModel->index(idx.row(), 1, idx.parent());
+ const QString type = treeModel->data(typeIdx, Qt::DisplayRole).toString();
+ int propertyCount = 0;
+ auto *propModel = m_session->model(QStringLiteral("%1.properties").arg(QString::fromUtf8(kWidgetBaseName)));
+ if (propModel)
+ propertyCount = propModel->rowCount();
+
+ out.insert(QStringLiteral("selected"), address);
+ out.insert(QStringLiteral("type"), type);
+ out.insert(QStringLiteral("propertyCount"), propertyCount);
+ return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
+}
+
+QString WidgetTools::getWidgetProperties(const QString &address) const
+{
+ const QString e = ensureWidgetSelected(address);
+ if (!e.isEmpty())
+ return e;
+
+ auto *m = m_session->model(QStringLiteral("%1.properties").arg(QString::fromUtf8(kWidgetBaseName)));
+ if (!m)
+ return QString::fromUtf8(QJsonDocument(errorJson(
+ QStringLiteral("widget property model not available"))).toJson(QJsonDocument::Compact));
+
+ QJsonObject out = readAggregatedPropertyModel(m);
+ if (out.isEmpty())
+ return QString::fromUtf8(QJsonDocument(errorJson(
+ QStringLiteral("no properties available (did you select a valid widget?)")))
+ .toJson(QJsonDocument::Compact));
+
+ return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
+}
+
+QString WidgetTools::getWidgetAttributes(const QString &address) const
+{
+ const QString e = ensureWidgetSelected(address);
+ if (!e.isEmpty())
+ return e;
+
+ auto *m = m_session->model(QStringLiteral("%1.widgetAttributeModel").arg(QString::fromUtf8(kWidgetBaseName)));
+ if (!m)
+ return QString::fromUtf8(QJsonDocument(errorJson(
+ QStringLiteral("widget attribute model not available"))).toJson(QJsonDocument::Compact));
+
+ waitForRows(m, 3000);
+
+ const int rows = m->rowCount();
+ if (rows == 0)
+ return QString::fromUtf8(QJsonDocument(errorJson(
+ QStringLiteral("no widget attributes (did you select a valid widget?)")))
+ .toJson(QJsonDocument::Compact));
+
+ QJsonObject attrs;
+ for (int r = 0; r < rows; ++r) {
+ const QString name = m->data(m->index(r, 0), Qt::DisplayRole).toString();
+ const bool enabled = m->data(m->index(r, 1), Qt::DisplayRole).toBool();
+ const QString val = m->data(m->index(r, 1), Qt::DisplayRole).toString();
+ attrs.insert(name, QJsonObject{
+ { QStringLiteral("enabled"), enabled },
+ { QStringLiteral("value"), val },
+ });
+ }
+
+ QJsonObject out;
+ out.insert(QStringLiteral("attributes"), attrs);
+ out.insert(QStringLiteral("count"), rows);
+ return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
+}
\ No newline at end of file
diff --git a/bridge/src/widget_tools.h b/bridge/src/widget_tools.h
new file mode 100644
index 0000000..381faa0
--- /dev/null
+++ b/bridge/src/widget_tools.h
@@ -0,0 +1,42 @@
+/*
+ widget_tools.h — Q_INVOKABLE methods for Qt Widget introspection via GammaRay.
+
+ SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#ifndef WIDGET_TOOLS_H
+#define WIDGET_TOOLS_H
+
+#include
+#include
+#include
+
+class GammaRaySession;
+
+class WidgetTools : public QObject
+{
+ Q_OBJECT
+public:
+ explicit WidgetTools(GammaRaySession *session, QObject *parent = nullptr);
+
+ // --- Widget tree navigation ---
+ Q_INVOKABLE QString listWidgets() const;
+ // Select a widget by address, triggering property sub-model population.
+ Q_INVOKABLE QString selectWidget(const QString &address) const;
+ // Get all Q_PROPERTY values for a selected widget.
+ Q_INVOKABLE QString getWidgetProperties(const QString &address) const;
+ // Get Qt::WidgetAttribute flags for a selected widget.
+ Q_INVOKABLE QString getWidgetAttributes(const QString &address) const;
+
+private:
+ GammaRaySession *m_session;
+ mutable QString m_selectedWidgetAddress;
+
+ QString ensureSession() const;
+ QString ensureWidgetSelected(const QString &address) const;
+ QModelIndex findWidgetByAddress(QAbstractItemModel *m, const QString &address) const;
+ void waitForRows(QAbstractItemModel *m, int timeoutMs) const;
+};
+
+#endif // WIDGET_TOOLS_H
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
index 080fd84..f64bfd4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,6 +8,7 @@ before calling pytest. If you prefer to run pytest directly, set these:
GAMMARAY_LIB_DIR — directory containing GammaRay shared libraries
QML_RUNNER — path to the QML runtime executable
TEST_QML — path to the test QML application
+ WIDGET_TEST_APP — path to the widget test application (C++ QWidget binary)
BRIDGE_EXE — path to the bridge executable
BRIDGE_RUN_SCRIPT — path to bridge/run.sh
"""
@@ -41,6 +42,14 @@ TEST_QML = Path(os.environ.get(
"TEST_QML",
str(PROJECT_ROOT / "tests" / "testapp" / "main.qml"),
))
+WIDGET_TEST_APP_BIN = Path(os.environ.get(
+ "WIDGET_TEST_APP_BIN",
+ str(PROJECT_ROOT / "tests" / "testapp" / "widget_test_app"),
+))
+WIDGET_TEST_APP_SRC = Path(os.environ.get(
+ "WIDGET_TEST_APP_SRC",
+ str(PROJECT_ROOT / "tests" / "testapp" / "widget_test_app.cpp"),
+))
BRIDGE_EXE = Path(os.environ.get(
"BRIDGE_EXE",
str(PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge"),
@@ -51,6 +60,43 @@ BRIDGE_RUN_SCRIPT = Path(os.environ.get(
))
+def _compile_widget_app() -> Path:
+ """Compile the widget test app if needed. Returns the binary path."""
+ if WIDGET_TEST_APP_BIN.exists():
+ return WIDGET_TEST_APP_BIN
+ if not WIDGET_TEST_APP_SRC.exists():
+ pytest.skip(f"Widget test app source not found at {WIDGET_TEST_APP_SRC}")
+ # Find Qt6 via pkg-config
+ try:
+ import subprocess as sp
+ cflags = sp.check_output(
+ ["pkg-config", "--cflags", "Qt6Core", "Qt6Gui", "Qt6Widgets"],
+ text=True
+ ).strip()
+ libs = sp.check_output(
+ ["pkg-config", "--libs", "Qt6Core", "Qt6Gui", "Qt6Widgets"],
+ text=True
+ ).strip()
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ # Fallback: try qmake6
+ try:
+ qmake = sp.check_output(["which", "qmake6"], text=True).strip()
+ prefix = sp.check_output([qmake, "-query", "QT_INSTALL_PREFIX"], text=True).strip()
+ cflags = f"-I{prefix}/include -I{prefix}/include/QtCore -I{prefix}/include/QtGui -I{prefix}/include/QtWidgets"
+ libs = f"-L{prefix}/lib -lQt6Core -lQt6Gui -lQt6Widgets"
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ pytest.skip("Cannot find Qt6 build flags (need pkg-config or qmake6)")
+
+ subprocess.run(
+ ["g++", "-std=c++17", "-fPIC",
+ str(WIDGET_TEST_APP_SRC),
+ "-o", str(WIDGET_TEST_APP_BIN),
+ *cflags.split(), *libs.split()],
+ check=True, timeout=30,
+ )
+ return WIDGET_TEST_APP_BIN
+
+
def pytest_addoption(parser):
parser.addoption(
"--no-probe",
@@ -64,6 +110,12 @@ def pytest_addoption(parser):
default=15,
help="Seconds to wait for probe to be ready",
)
+ parser.addoption(
+ "--widget-app",
+ action="store_true",
+ default=False,
+ help="Use widget test app instead of QML app for probe target",
+ )
def pytest_collection_modifyitems(config, items):
@@ -103,12 +155,24 @@ def _probe_accepting_connections(timeout: int = 20) -> bool:
@pytest.fixture(scope="session")
def probe_process(request) -> subprocess.Popen | None:
- """Start a GammaRay probe process (session-scoped, one per entire test run)."""
+ """Start a GammaRay probe process (session-scoped, one per entire test run).
+
+ Uses either the QML test app or the widget test app depending on --widget-app.
+ """
if request.config.getoption("--no-probe"):
return None
if not GAMMARAY_BIN.exists():
pytest.skip(f"GammaRay probe binary not found at {GAMMARAY_BIN}")
+
+ if request.config.getoption("--widget-app"):
+ return _start_widget_probe(request)
+ else:
+ return _start_qml_probe(request)
+
+
+def _start_qml_probe(request) -> subprocess.Popen:
+ """Start probe with QML test app."""
if not TEST_QML.exists():
pytest.skip(f"Test QML file not found at {TEST_QML}")
if not QML_RUNNER.exists():
@@ -145,14 +209,72 @@ def probe_process(request) -> subprocess.Popen | None:
timeout = request.config.getoption("--probe-timeout")
if not _probe_accepting_connections(timeout):
- # Fallback: direct spawn
proc = subprocess.Popen(
[str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
"--injector", "preload", str(QML_RUNNER), str(TEST_QML)],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
if not _probe_accepting_connections(timeout):
- pytest.fail(f"Probe did not become ready within {timeout}s")
+ pytest.fail(f"QML probe did not become ready within {timeout}s")
+ else:
+ proc = subprocess.Popen(["true"])
+
+ def cleanup():
+ subprocess.run(
+ ["systemctl", "--user", "stop", "gammaray-probe-test.service"],
+ capture_output=True, timeout=5,
+ )
+ subprocess.run(
+ ["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"],
+ capture_output=True, timeout=5,
+ )
+
+ request.addfinalizer(cleanup)
+ return proc
+
+
+def _start_widget_probe(request) -> subprocess.Popen:
+ """Start probe with widget test app."""
+ widget_bin = _compile_widget_app()
+
+ env = os.environ.copy()
+ env["QT_QPA_PLATFORM"] = "offscreen"
+ env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR
+
+ subprocess.run(
+ ["systemctl", "--user", "stop", "gammaray-probe-test.service"],
+ capture_output=True, timeout=5,
+ )
+ subprocess.run(
+ ["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"],
+ capture_output=True, timeout=5,
+ )
+
+ result = subprocess.run(
+ [
+ "systemd-run", "--user", "--no-block",
+ "--unit=gammaray-probe-test",
+ "--same-dir",
+ "--working-directory", str(widget_bin.parent),
+ "-E", f"QT_QPA_PLATFORM={env['QT_QPA_PLATFORM']}",
+ "-E", f"LD_LIBRARY_PATH={env['LD_LIBRARY_PATH']}",
+ str(GAMMARAY_BIN),
+ "--inject-only", "--listen", "tcp://127.0.0.1:11732",
+ "--injector", "preload",
+ str(widget_bin),
+ ],
+ capture_output=True, text=True, timeout=10,
+ )
+
+ timeout = request.config.getoption("--probe-timeout")
+ if not _probe_accepting_connections(timeout):
+ proc = subprocess.Popen(
+ [str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
+ "--injector", "preload", str(widget_bin)],
+ env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ )
+ if not _probe_accepting_connections(timeout):
+ pytest.fail(f"Widget probe did not become ready within {timeout}s")
else:
proc = subprocess.Popen(["true"])
diff --git a/tests/run_widget_tests.sh b/tests/run_widget_tests.sh
new file mode 100755
index 0000000..5d1d3b5
--- /dev/null
+++ b/tests/run_widget_tests.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Run widget-specific tests with a QWidget test app as the probe target.
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Usage:
+# ./run_widget_tests.sh # full widget test suite
+# ./run_widget_tests.sh -v # verbose
+# ./run_widget_tests.sh -k "attr" # run only attribute-related tests
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+# ---------------------------------------------------------------------------
+# Configurable paths
+# ---------------------------------------------------------------------------
+: "${GAMMARAY_BIN:=$PROJECT_ROOT/install-prefix/bin/gammaray}"
+: "${GAMMARAY_LIB_DIR:=$PROJECT_ROOT/install-prefix/lib}"
+: "${WIDGET_TEST_APP_BIN:=$SCRIPT_DIR/testapp/widget_test_app}"
+: "${WIDGET_TEST_APP_SRC:=$SCRIPT_DIR/testapp/widget_test_app.cpp}"
+: "${BRIDGE_EXE:=$PROJECT_ROOT/bridge/build/qml-sg-mcp-bridge}"
+: "${BRIDGE_RUN_SCRIPT:=$PROJECT_ROOT/bridge/run.sh}"
+
+export GAMMARAY_BIN
+export GAMMARAY_LIB_DIR
+export WIDGET_TEST_APP_BIN
+export WIDGET_TEST_APP_SRC
+export BRIDGE_EXE
+export BRIDGE_RUN_SCRIPT
+
+# ---------------------------------------------------------------------------
+# Ensure the bridge is built
+# ---------------------------------------------------------------------------
+if [ ! -f "$BRIDGE_EXE" ]; then
+ echo "Building bridge first..."
+ cmake -S "$PROJECT_ROOT/bridge" -B "$PROJECT_ROOT/bridge/build" -G Ninja \
+ -DCMAKE_PREFIX_PATH="$PROJECT_ROOT/install-prefix"
+ cmake --build "$PROJECT_ROOT/bridge/build"
+fi
+
+# ---------------------------------------------------------------------------
+# Runtime environment
+# ---------------------------------------------------------------------------
+export LD_LIBRARY_PATH="$GAMMARAY_LIB_DIR"
+export QT_QPA_PLATFORM=offscreen
+export QT_PLUGIN_PATH="$PROJECT_ROOT/bridge/build/lib/x86_64-linux-gnu/qt6/plugins"
+
+cd "$SCRIPT_DIR"
+
+echo "=== Widget Test Run ==="
+echo "Widget app: $WIDGET_TEST_APP_BIN"
+echo "Bridge: $BRIDGE_EXE"
+
+exec uv run --frozen --directory "$PROJECT_ROOT" python "$SCRIPT_DIR/run_with_uv.py" --widget-app "$@"
\ No newline at end of file
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
new file mode 100644
index 0000000..4857d35
--- /dev/null
+++ b/tests/test_widgets.py
@@ -0,0 +1,126 @@
+"""Tests for Qt Widget introspection tools (listWidgets, selectWidget, etc.)."""
+
+import pytest
+
+
+class TestWidgetTools:
+ """Verify listWidgets, selectWidget, getWidgetProperties, getWidgetAttributes."""
+
+ @pytest.mark.probe
+ def test_tools_listed(self, bridge):
+ tools = bridge.list_tools()
+ names = [t["name"] for t in tools]
+ for expected in ["listWidgets", "selectWidget",
+ "getWidgetProperties", "getWidgetAttributes"]:
+ assert expected in names, f"Missing tool: {expected}"
+
+ @pytest.mark.probe
+ def test_list_widgets(self, connected_bridge):
+ widgets = connected_bridge.call_tool("listWidgets")
+ assert isinstance(widgets, list), f"Expected list, got: {type(widgets)}"
+ assert len(widgets) >= 1, "No widgets found"
+
+ # Should have a top-level window
+ types = [w.get("type", "") for w in widgets]
+ assert any("QWidget" in t for t in types), \
+ f"No QWidget-type root found: {types}"
+
+ @pytest.mark.probe
+ def test_select_widget(self, connected_bridge):
+ widgets = connected_bridge.call_tool("listWidgets")
+ assert isinstance(widgets, list) and len(widgets) > 0
+
+ # Find the first Button-like widget
+ def find_buttons(items_list):
+ buttons = []
+ for item in items_list:
+ if "Button" in (item.get("type", "")) or "PushButton" in (item.get("type", "")):
+ buttons.append(item.get("name", ""))
+ buttons.extend(find_buttons(item.get("children", [])))
+ return buttons
+
+ buttons = find_buttons(widgets)
+ if buttons:
+ addr = buttons[0]
+ else:
+ addr = widgets[0].get("name", "")
+
+ result = connected_bridge.call_tool("selectWidget", {"address": addr})
+ assert isinstance(result, dict), f"selectWidget failed: {result}"
+ assert result.get("propertyCount", 0) > 0, \
+ f"No properties for widget {addr}: {result}"
+
+ @pytest.mark.probe
+ def test_get_widget_properties(self, connected_bridge):
+ widgets = connected_bridge.call_tool("listWidgets")
+ assert isinstance(widgets, list) and len(widgets) > 0
+
+ addr = widgets[0].get("name", "")
+ if not addr:
+ pytest.skip("No widget address found")
+
+ props = connected_bridge.call_tool("getWidgetProperties", {"address": addr})
+ assert isinstance(props, dict), f"getWidgetProperties failed: {props}"
+ assert "properties" in props, f"No 'properties' key: {list(props.keys())}"
+
+ @pytest.mark.probe
+ def test_widget_has_geometry(self, connected_bridge):
+ """Verify a widget has geometry properties (x, y, width, height)."""
+ widgets = connected_bridge.call_tool("listWidgets")
+ assert isinstance(widgets, list) and len(widgets) > 0
+
+ addr = widgets[0].get("name", "")
+ if not addr:
+ pytest.skip("No widget address found")
+
+ props = connected_bridge.call_tool("getWidgetProperties", {"address": addr})
+ simple = props.get("properties", {})
+ for key in ("x", "y", "width", "height"):
+ assert key in simple, f"Widget missing '{key}' property"
+
+ @pytest.mark.probe
+ def test_widget_attributes(self, connected_bridge):
+ """Verify getWidgetAttributes returns attribute flags for a widget."""
+ widgets = connected_bridge.call_tool("listWidgets")
+ assert isinstance(widgets, list) and len(widgets) > 0
+
+ # Find the checkBox widget (has various attributes)
+ def find_named(items_list):
+ for item in items_list:
+ name = item.get("name", "")
+ if "check" in name.lower() or "CheckBox" in (item.get("type", "")):
+ return name
+ found = find_named(item.get("children", []))
+ if found:
+ return found
+ return None
+
+ addr = find_named(widgets)
+ if not addr:
+ addr = widgets[0].get("name", "")
+
+ attrs = connected_bridge.call_tool("getWidgetAttributes", {"address": addr})
+ assert isinstance(attrs, dict), f"getWidgetAttributes failed: {attrs}"
+ assert "attributes" in attrs, f"No 'attributes' key: {list(attrs.keys())}"
+ assert attrs.get("count", 0) > 0, "No widget attributes returned"
+ # Should at least have some standard attributes
+ attr_names = list(attrs.get("attributes", {}).keys())
+ assert len(attr_names) > 0, "Empty attributes list"
+
+ @pytest.mark.probe
+ def test_widget_enabled_attribute(self, connected_bridge):
+ """Verify attributes contain 'enabled' field."""
+ widgets = connected_bridge.call_tool("listWidgets")
+ assert isinstance(widgets, list) and len(widgets) > 0
+
+ addr = widgets[0].get("name", "")
+ if not addr:
+ pytest.skip("No widget address found")
+
+ attrs = connected_bridge.call_tool("getWidgetAttributes", {"address": addr})
+ attributes = attrs.get("attributes", {})
+ # Check at least one attribute has enabled/value keys
+ for attr_name, attr_data in attributes.items():
+ if isinstance(attr_data, dict) and "enabled" in attr_data:
+ return # Found one
+ pytest.skip("No attributes with 'enabled' field found")
\ No newline at end of file
diff --git a/tests/testapp/widget_test_app.cpp b/tests/testapp/widget_test_app.cpp
new file mode 100644
index 0000000..9042107
--- /dev/null
+++ b/tests/testapp/widget_test_app.cpp
@@ -0,0 +1,50 @@
+/*
+ widget_test_app.cpp — Minimal QWidget test application for GammaRay MCP widget tools.
+
+ SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
+ SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+int main(int argc, char **argv)
+{
+ QApplication app(argc, argv);
+ app.setApplicationName(QStringLiteral("GammaRay MCP Widget Test App"));
+
+ auto *mainWindow = new QWidget();
+ mainWindow->setWindowTitle(QStringLiteral("GammaRay Widget Test"));
+ mainWindow->resize(400, 300);
+
+ auto *layout = new QVBoxLayout(mainWindow);
+
+ auto *label = new QLabel(QStringLiteral("Hello from GammaRay MCP"));
+ layout->addWidget(label);
+
+ auto *lineEdit = new QLineEdit();
+ lineEdit->setPlaceholderText(QStringLiteral("Type something..."));
+ layout->addWidget(lineEdit);
+
+ auto *checkBox = new QCheckBox(QStringLiteral("Enable feature"));
+ checkBox->setChecked(true);
+ layout->addWidget(checkBox);
+
+ auto *groupBox = new QGroupBox(QStringLiteral("Options"));
+ auto *groupLayout = new QVBoxLayout(groupBox);
+ auto *innerCheck = new QCheckBox(QStringLiteral("Sub-option A"));
+ groupLayout->addWidget(innerCheck);
+ groupLayout->addWidget(new QCheckBox(QStringLiteral("Sub-option B")));
+ layout->addWidget(groupBox);
+
+ auto *button = new QPushButton(QStringLiteral("Click Me"));
+ layout->addWidget(button);
+
+ mainWindow->show();
+ return app.exec();
+}
\ No newline at end of file