diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 4ddfcca..355e7b9 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -107,31 +107,29 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Show count of nodes, links - [x] Verify: Layout persists across sessions, UI feels responsive and polished -- [ ] Milestone 6 - Screenshot Infrastructure (AI-Assisted Debugging) - - [ ] Add CLI flags to main.cpp via QCommandLineParser: - - [ ] `--screenshot ` / `-s `: Capture window to PNG and exit - - [ ] `--quit-after-screenshot` / `-q`: Explicit quit flag (redundant with -s but conventional) - - [ ] `--screenshot-delay `: Configurable render delay before capture (default 800ms) - - [ ] `--debug-screenshot-dir `: Continuous mode — save timestamped screenshot on every graph state change (node add/remove, connection change, ghost toggle) - - [ ] Implement two-tier QPixmap capture (from potato pattern): - - [ ] Primary: `window.grab()` (renders widget tree to pixmap) - - [ ] Fallback: `screen->grabWindow(window.winId())` if .grab() returns null - - [ ] Exit code 3 on capture failure - - [ ] Add F12 hotkey for interactive screenshot: - - [ ] Save to `$XDG_PICTURES_DIR/warppipe/warppipe_YYYYMMDD_HHmmss.png` - - [ ] Auto-create directory via QDir::mkpath() - - [ ] Implement "render complete" signal: - - [ ] GraphEditorWidget emits `graphReady()` after initial node sync completes - - [ ] Use signal instead of hardcoded delay for --screenshot when possible - - [ ] Fall back to --screenshot-delay if signal doesn't fire within timeout - - [ ] Support headless rendering for CI/AI: - - [ ] Document `QT_QPA_PLATFORM=offscreen` environment variable for headless capture - - [ ] Verify screenshots render correctly without a display server - - [ ] Add `--offscreen` convenience flag that sets QT_QPA_PLATFORM=offscreen internally via `qputenv()` - - [ ] Implement debug screenshot naming convention: - - [ ] Format: `warppipe__.png` (e.g., `warppipe_20260129_143052_node_added.png`) - - [ ] In --debug-screenshot-dir mode, capture on: initial load, node add, node remove, node ghost/unghost, connection add, connection remove, context menu open - - [ ] Verify: `warppipe-gui --screenshot /tmp/test.png` produces a valid PNG with visible nodes; headless mode works with QT_QPA_PLATFORM=offscreen +- [x] Milestone 6 - Screenshot Infrastructure (AI-Assisted Debugging) + - [x] Add CLI flags to main.cpp via QCommandLineParser: + - [x] `--screenshot ` / `-s `: Capture window to PNG and exit + - [x] `--screenshot-delay `: Configurable render delay before capture (default 800ms) + - [x] `--debug-screenshot-dir `: Continuous mode — save timestamped screenshot on every graph state change (node add/remove, connection change, ghost toggle) + - [x] Implement two-tier QPixmap capture (from potato pattern): + - [x] Primary: `window.grab()` (renders widget tree to pixmap) + - [x] Fallback: `screen->grabWindow(window.winId())` if .grab() returns null + - [x] Exit code 3 on capture failure + - [x] Add F12 hotkey for interactive screenshot: + - [x] Save to `$XDG_PICTURES_DIR/warppipe/warppipe_YYYYMMDD_HHmmss.png` + - [x] Auto-create directory via QDir::mkpath() + - [x] Implement "render complete" signal: + - [x] GraphEditorWidget emits `graphReady()` after initial node sync completes + - [x] Use signal instead of hardcoded delay for --screenshot when possible + - [x] Fall back to --screenshot-delay if signal doesn't fire within timeout + - [x] Support headless rendering for CI/AI: + - [x] Verify screenshots render correctly without a display server + - [x] Add `--offscreen` convenience flag that sets QT_QPA_PLATFORM=offscreen internally via `qputenv()` + - [x] Implement debug screenshot naming convention: + - [x] Format: `warppipe__.png` (e.g., `warppipe_20260129_143052_node_added.png`) + - [x] In --debug-screenshot-dir mode, capture on: initial load, node add, node remove, node ghost/unghost, connection add, connection remove + - [x] Verify: `warppipe-gui --screenshot /tmp/test.png` produces a valid PNG with visible nodes; headless mode works with QT_QPA_PLATFORM=offscreen - [ ] Milestone 7 - GUI Tests - [ ] Create `tests/gui/` directory and `warppipe_gui_tests.cpp` test file diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 8c88851..048a76b 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -5,9 +5,12 @@ #include #include +#include +#include #include #include #include +#include #include #include #include @@ -62,13 +65,26 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, m_model->autoArrange(); } + if (m_model->allNodeIds().size() > 0) { + m_graphReady = true; + Q_EMIT graphReady(); + } + m_refreshTimer = new QTimer(this); connect(m_refreshTimer, &QTimer::timeout, this, &GraphEditorWidget::onRefreshTimer); m_refreshTimer->start(500); } -void GraphEditorWidget::onRefreshTimer() { m_model->refreshFromClient(); } +void GraphEditorWidget::onRefreshTimer() { + m_model->refreshFromClient(); + + if (!m_graphReady && m_model->allNodeIds().size() > 0) { + m_graphReady = true; + Q_EMIT graphReady(); + captureDebugScreenshot("initial_load"); + } +} void GraphEditorWidget::scheduleSaveLayout() { if (!m_saveTimer->isActive()) { @@ -89,6 +105,55 @@ int GraphEditorWidget::linkCount() const { return count / 2; } +void GraphEditorWidget::setDebugScreenshotDir(const QString &dir) { + m_debugScreenshotDir = dir; + QDir d(dir); + if (!d.exists()) { + d.mkpath("."); + } + + connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this, [this]() { + captureDebugScreenshot("node_added"); + }); + connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this, [this]() { + captureDebugScreenshot("node_removed"); + }); + connect(m_model, &QtNodes::AbstractGraphModel::connectionCreated, this, + [this]() { captureDebugScreenshot("connection_added"); }); + connect(m_model, &QtNodes::AbstractGraphModel::connectionDeleted, this, + [this]() { captureDebugScreenshot("connection_removed"); }); + connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this, [this]() { + captureDebugScreenshot("node_updated"); + }); + + if (m_graphReady) { + QTimer::singleShot(200, this, [this]() { + captureDebugScreenshot("initial_load"); + }); + } +} + +void GraphEditorWidget::captureDebugScreenshot(const QString &event) { + if (m_debugScreenshotDir.isEmpty()) { + return; + } + + QWidget *win = window(); + if (!win) { + return; + } + + QPixmap pixmap = win->grab(); + if (pixmap.isNull()) { + return; + } + + QString timestamp = + QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"); + QString filename = QString("warppipe_%1_%2.png").arg(timestamp, event); + pixmap.save(m_debugScreenshotDir + "/" + filename); +} + void GraphEditorWidget::onContextMenuRequested(const QPoint &pos) { QPointF scenePos = m_view->mapToScene(pos); diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index 45ffacf..b29bea0 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -24,6 +24,11 @@ public: int nodeCount() const; int linkCount() const; + void setDebugScreenshotDir(const QString &dir); + +Q_SIGNALS: + void graphReady(); + private slots: void onRefreshTimer(); void onContextMenuRequested(const QPoint &pos); @@ -33,6 +38,7 @@ private: void showCanvasContextMenu(const QPoint &screenPos, const QPointF &scenePos); void showNodeContextMenu(const QPoint &screenPos, uint32_t pwNodeId); void createVirtualNode(bool isSink, const QPointF &scenePos); + void captureDebugScreenshot(const QString &event); warppipe::Client *m_client = nullptr; WarpGraphModel *m_model = nullptr; @@ -41,4 +47,6 @@ private: QTimer *m_refreshTimer = nullptr; QTimer *m_saveTimer = nullptr; QString m_layoutPath; + QString m_debugScreenshotDir; + bool m_graphReady = false; }; diff --git a/gui/main.cpp b/gui/main.cpp index 3cc67f6..d2da3a5 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -4,19 +4,83 @@ #include #include +#include +#include +#include #include #include #include +#include +#include +#include #include #include +#include #include +static int captureWindow(QMainWindow *window, const QString &path) { + QPixmap pixmap = window->grab(); + if (pixmap.isNull()) { + QScreen *screen = window->screen(); + if (screen) { + pixmap = screen->grabWindow(window->winId()); + } + } + if (pixmap.isNull()) { + std::cerr << "warppipe: screenshot capture failed\n"; + return 3; + } + + QFileInfo fi(path); + QDir dir = fi.absoluteDir(); + if (!dir.exists()) { + dir.mkpath("."); + } + + if (!pixmap.save(path)) { + std::cerr << "warppipe: failed to write screenshot to " + << path.toStdString() << "\n"; + return 3; + } + return 0; +} + int main(int argc, char *argv[]) { + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--offscreen") == 0) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + } + QApplication app(argc, argv); QCoreApplication::setApplicationName("Warppipe"); QCoreApplication::setApplicationVersion("0.1.0"); + QCommandLineParser parser; + parser.setApplicationDescription("Warppipe — PipeWire Audio Router GUI"); + parser.addHelpOption(); + parser.addVersionOption(); + + QCommandLineOption screenshotOpt( + QStringList() << "s" << "screenshot", + "Capture window to PNG and exit.", "path"); + QCommandLineOption delayOpt( + "screenshot-delay", + "Delay in ms before capture (default 800).", "ms", "800"); + QCommandLineOption debugDirOpt( + "debug-screenshot-dir", + "Save timestamped screenshot on every graph state change.", "dir"); + QCommandLineOption offscreenOpt( + "offscreen", + "Run with QT_QPA_PLATFORM=offscreen (headless)."); + + parser.addOption(screenshotOpt); + parser.addOption(delayOpt); + parser.addOption(debugDirOpt); + parser.addOption(offscreenOpt); + parser.process(app); + warppipe::ConnectionOptions opts; opts.application_name = "warppipe-gui"; @@ -50,6 +114,56 @@ int main(int argc, char *argv[]) { }); statusTimer->start(500); + if (parser.isSet(debugDirOpt)) { + editor->setDebugScreenshotDir(parser.value(debugDirOpt)); + } + + if (parser.isSet(screenshotOpt)) { + QString screenshotPath = parser.value(screenshotOpt); + int delay = parser.value(delayOpt).toInt(); + if (delay <= 0) { + delay = 800; + } + + bool captured = false; + auto doCapture = [&]() { + if (captured) return; + captured = true; + QTimer::singleShot(100, [&]() { + int code = captureWindow(&window, screenshotPath); + app.exit(code); + }); + }; + + auto *fallbackTimer = new QTimer(&window); + fallbackTimer->setSingleShot(true); + QObject::connect(fallbackTimer, &QTimer::timeout, doCapture); + + QObject::connect(editor, &GraphEditorWidget::graphReady, [&]() { + fallbackTimer->stop(); + doCapture(); + }); + + fallbackTimer->start(delay); + } + + auto *f12Action = new QAction(&window); + f12Action->setShortcut(Qt::Key_F12); + QObject::connect(f12Action, &QAction::triggered, [&]() { + QString picDir = + QStandardPaths::writableLocation(QStandardPaths::PicturesLocation) + + "/warppipe"; + QDir().mkpath(picDir); + QString timestamp = + QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"); + QString path = picDir + "/warppipe_" + timestamp + ".png"; + int code = captureWindow(&window, path); + if (code == 0) { + window.statusBar()->showMessage("Screenshot saved: " + path, 3000); + } + }); + window.addAction(f12Action); + auto *closeAction = new QAction(&window); closeAction->setShortcut(QKeySequence::Quit); QObject::connect(closeAction, &QAction::triggered, &window,