GUI Milestone 6
This commit is contained in:
parent
79cced017e
commit
0e67c19902
4 changed files with 211 additions and 26 deletions
48
GUI_PLAN.md
48
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 <path>` / `-s <path>`: Capture window to PNG and exit
|
||||
- [ ] `--quit-after-screenshot` / `-q`: Explicit quit flag (redundant with -s but conventional)
|
||||
- [ ] `--screenshot-delay <ms>`: Configurable render delay before capture (default 800ms)
|
||||
- [ ] `--debug-screenshot-dir <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_<timestamp>_<event>.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 <path>` / `-s <path>`: Capture window to PNG and exit
|
||||
- [x] `--screenshot-delay <ms>`: Configurable render delay before capture (default 800ms)
|
||||
- [x] `--debug-screenshot-dir <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_<timestamp>_<event>.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
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@
|
|||
#include <QtNodes/ConnectionStyle>
|
||||
#include <QtNodes/GraphicsView>
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QInputDialog>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QPixmap>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
114
gui/main.cpp
114
gui/main.cpp
|
|
@ -4,19 +4,83 @@
|
|||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QCommandLineParser>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QKeySequence>
|
||||
#include <QLabel>
|
||||
#include <QMainWindow>
|
||||
#include <QPixmap>
|
||||
#include <QScreen>
|
||||
#include <QStandardPaths>
|
||||
#include <QStatusBar>
|
||||
#include <QTimer>
|
||||
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue