30 KiB
Warppipe GUI Plan (Qt6 Node-Based Audio Router)
Overview
A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. Visualizes PipeWire audio nodes, ports, and links as draggable nodes with connection lines. Supports creating virtual sinks/sources via context menu and displays ephemeral sources with visual fade when inactive.
Milestones
-
Milestone 0 - Qt6 Project Setup
- Create
gui/subdirectory in warppipe project - Add Qt6 + QtNodes to CMakeLists.txt (FetchContent for nodeeditor from github.com/paceholder/nodeeditor)
- Create
warppipe-guitarget with Qt6::Widgets and QtNodes dependencies - Enable CMAKE_AUTOMOC, CMAKE_AUTORCC, CMAKE_AUTOUIC
- Create minimal main.cpp with QApplication + QMainWindow
- Verify GUI launches and shows empty window
- Create
-
Milestone 1 - Core Model Integration
- Create
WarpGraphModel : public QtNodes::AbstractGraphModel- Implement AbstractGraphModel interface (newNodeId, allNodeIds, nodeData, portData, etc.)
- Add
warppipe::Client*member, connect to PipeWire on construction - Map
warppipe::NodeInfoto QtNodes NodeId via internal maps (m_nodes, m_pwToNode) - Implement node refresh: call Client::ListNodes() and sync graph
- Create
GraphEditorWidget : public QWidget- Instantiate WarpGraphModel, QtNodes::BasicGraphicsScene, QtNodes::GraphicsView
- Lay out view in widget
- Connect model signals to refresh handlers
- Synthesize display title from NodeInfo:
- If
application_nameis non-empty and differs fromname, useapplication_nameas title - Otherwise use
namefield - Store synthesized title in
nodeData(NodeRole::Caption)
- If
- Map warppipe ports to QtNodes ports:
- Input ports (is_input=true) appear on LEFT side of node (QtNodes PortType::In)
- Output ports (is_input=false) appear on RIGHT side of node (QtNodes PortType::Out)
- Use port name from PortInfo as port label
- Verify nodes appear in graph view with correct titles and ports
- Create
-
Milestone 2 - Visual Styling and Node Types
- Define node type classification based on
media_class:- Sink → "Hardware Sink" (blue-gray base color)
- Source → "Hardware Source" (blue-gray base color)
- Virtual sinks created by warppipe → "Virtual Sink" (green base color)
- Virtual sources created by warppipe → "Virtual Source" (green base color)
- Application audio streams (ephemeral) → "Application" (brown/orange base color)
- Implement custom NodeStyle via
nodeData(NodeRole::Style):- Return QtNodes::NodeStyle::toJson().toVariantMap()
- Set GradientColor0-3, NormalBoundaryColor, FontColor based on node type
- Reference potato's
nodeStyleVariant()function for color scheme
- Detect ephemeral (application) nodes:
- Track node appearance/disappearance via Client poll or registry events
- Mark node as "inactive" if it disappears (no audio playing)
- Persist inactive nodes in graph model (do NOT remove from visual graph)
- Apply "ghost" styling to inactive nodes:
- Set
Opacity = 0.6f(vs 1.0f for active) - Darken gradient colors (use
.darker(150-180)) - Fade font color (lighter gray)
- Keep connections visible with faded style
- Set
- Verify: Application nodes appear vibrant when active, fade when inactive, never disappear
- Define node type classification based on
-
Milestone 3 - Link Visualization and Drag-Connect
- Implement connection mapping:
- Call
Client::ListLinks()to get existing PipeWire links - For each Link, find corresponding NodeId and PortIndex for output/input
- Create QtNodes::ConnectionId from (outNodeId, outPortType, outPortIndex, inNodeId, inPortType, inPortIndex)
- Store in model's m_connections set
- Call
- Implement
addConnection(ConnectionId):- Extract output port and input port from ConnectionId
- Call
Client::CreateLink(outputPortId, inputPortId, LinkOptions{}) - If successful, add connection to m_connections
- If failed, emit error and do NOT add to graph
- Implement
deleteConnection(ConnectionId):- Find corresponding warppipe LinkId from connection
- Call
Client::RemoveLink(linkId) - Remove from m_connections
- Verify: Drag connection from output port to input port creates PipeWire link; delete removes it
- Implement connection mapping:
-
Milestone 4 - Context Menu and Virtual Node Creation
- Add context menu to GraphEditorWidget:
- Right-click on canvas (not on node) shows menu
- Menu items: "Create Virtual Sink", "Create Virtual Source"
- Implement "Create Virtual Sink":
- Prompt user for name (QInputDialog or inline text field)
- Call
Client::CreateVirtualSink(name, VirtualNodeOptions{})with default options - On success, node appears in graph at context menu position
- Implement "Create Virtual Source":
- Same as sink but call
Client::CreateVirtualSource()
- Same as sink but call
- Add context menu on nodes:
- Right-click on virtual node shows "Delete Node" option
- Call
Client::RemoveNode(nodeId)and remove from graph
- Verify: Can create/delete virtual sinks and sources via right-click
- Add context menu to GraphEditorWidget:
-
Milestone 5 - Layout Persistence and Polish
- Implement layout save/load:
- Save node positions to JSON file in
~/.config/warppipe-gui/layout.json - Store by stable ID (use NodeInfo.name as stable key)
- Save on position change (debounced)
- Load on startup and restore positions
- Save node positions to JSON file in
- Implement auto-arrange:
- Menu or button to auto-layout nodes (left-to-right: sources → sinks)
- Use simple grid or layered layout algorithm
- Add visual polish:
- Connection lines styled (color, width, curvature)
- Highlight connections on hover
- Port connection points visible and responsive
- Add status bar:
- Show connection status to PipeWire daemon
- Show count of nodes, links
- Verify: Layout persists across sessions, UI feels responsive and polished
- Implement layout save/load:
-
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--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
- Primary:
- Add F12 hotkey for interactive screenshot:
- Save to
$XDG_PICTURES_DIR/warppipe/warppipe_YYYYMMDD_HHmmss.png - Auto-create directory via QDir::mkpath()
- Save to
- 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
- GraphEditorWidget emits
- Support headless rendering for CI/AI:
- Verify screenshots render correctly without a display server
- Add
--offscreenconvenience flag that sets QT_QPA_PLATFORM=offscreen internally viaqputenv()
- 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
- Format:
- Verify:
warppipe-gui --screenshot /tmp/test.pngproduces a valid PNG with visible nodes; headless mode works with QT_QPA_PLATFORM=offscreen
- Add CLI flags to main.cpp via QCommandLineParser:
-
Milestone 7 - GUI Tests
- Create
tests/gui/directory andwarppipe_gui_tests.cpptest file - Add
warppipe-gui-testsCMake target linking warppipe, Qt6::Widgets, QtNodes, Catch2 - Model unit tests (no display server needed, pure logic):
- WarpGraphModel: inject nodes via WARPPIPE_TESTING helpers → verify allNodeIds(), nodeData(Caption), nodeData(Style)
- WarpGraphModel: inject ports → verify portData(PortCount), portData(Caption) for correct port labels
- WarpGraphModel: inject links → verify allConnectionIds(), connectionExists()
- WarpGraphModel: ghost state tracking — mark node ghost → verify isGhost, mark unghost → verify !isGhost
- WarpGraphModel: title synthesis — node with description → caption=description; node with application_name="Firefox" → caption="Firefox"; fallback to name
- WarpGraphModel: port orientation — is_input=true ports map to PortType::In; is_input=false → PortType::Out
- WarpGraphModel: node removal doesn't crash when connections exist
- WarpGraphModel: volume meter stream filtering (empty name + app_name skipped)
- Connection logic tests:
- Links from client appear as connections in model
- connectionPossible() rejects invalid port indices and nonexistent nodes
- deleteConnection() removes from model
- Screenshot smoke tests (require QT_QPA_PLATFORM=offscreen):
- Gated behind
WARPPIPE_GUI_VISUAL_TESTSCMake option (default OFF) - Launch warppipe-gui with --screenshot → CTest verifies exit code 0
- Gated behind
- Integration tests with warppipe test harness:
- Create Client with WARPPIPE_TESTING → inject nodes/ports/links → construct WarpGraphModel → verify graph state matches injected data
- Inject node, then remove → verify ghost state in model
- Inject node, remove, re-insert with same name → verify ghost reactivation
- Add CTest integration:
- Model tests run without display server (always)
- Screenshot tests gated behind
WARPPIPE_GUI_VISUAL_TESTSCMake option (default OFF) ctest --test-dir buildruns model + GUI tests
- Create
-
Milestone 8a - Undo/Redo, Clipboard, and Keyboard Shortcuts
- Integrate
QUndoStackviaBasicGraphicsScene::undoStack()- Undo/Redo already works for connection create/delete (built-in QtNodes
ConnectCommand/DisconnectCommand) - Verify Ctrl+Z / Ctrl+Shift+Z (or Ctrl+Y) work out of the box for connections
- Undo/Redo already works for connection create/delete (built-in QtNodes
- Implement
DeleteVirtualNodeCommand : QUndoCommandredo(): destroy virtual node viaClient::RemoveNode()undo(): re-create virtual node viaClient::CreateVirtualSink/Source()with same name/channels/rate- Store node position and restore on undo
- Implement
deleteSelection()for Del key- Collect selected
NodeGraphicsObjectitems fromm_scene->selectedItems() - Virtual nodes → push
DeleteVirtualNodeCommandonto undo stack - Non-virtual nodes → push
QtNodes::DeleteCommand(removes from graph only, not PipeWire) - Connection-only selection → push
QtNodes::DeleteCommand
- Collect selected
- Implement
copySelection()(Ctrl+C)- Serialize selected virtual nodes to JSON: stable_id, name, media_class, channels, rate, position
- Include links between selected nodes (source stable_id:port_name → target stable_id:port_name)
- Set
QClipboardwith custom MIME typeapplication/warppipe-virtual-graph
- Implement
pasteSelection()(Ctrl+V)- Parse clipboard JSON, create new virtual nodes with " Copy" name suffix
- Position pasted nodes at offset from originals
- Deferred link resolution via
PendingPasteLinkqueue (nodes may not exist yet) tryResolvePendingLinks()called on node add to wire up deferred links
- Implement
duplicateSelection()(Ctrl+D) — copy + paste with (40, 40) offset - Register keyboard shortcuts on
m_view:- Del →
deleteSelection() - Ctrl+C →
copySelection() - Ctrl+V →
pasteSelection() - Ctrl+D →
duplicateSelection() - Ctrl+L → auto-arrange + zoom fit
- Del →
- Remove default QtNodes copy/paste actions to avoid conflicts
- Add tests for undo/redo command state (push command → undo → verify node re-created → redo → verify deleted)
- Integrate
-
Milestone 8b - View and Layout Enhancements
- Add "Zoom Fit All" context menu action →
m_view->zoomFitAll() - Add "Zoom Fit Selected" context menu action →
m_view->zoomFitSelected() - Add "Save Layout As..." context menu action
QFileDialog::getSaveFileName()→ save layout JSON to custom path- Reuse existing
saveLayout()serialization, write to chosen path
- Add "Reset Layout" context menu action
- Clear saved positions, run
autoArrange(), save, zoom fit
- Clear saved positions, run
- Add "Refresh Graph" context menu action
- Reset model, re-sync from client, zoom fit
- Persist view state in layout JSON:
- Save view scale + center position (
m_view->getScale(),m_view->mapToScene(viewport center)) - Restore on load:
m_view->setupScale()+m_view->centerOn() - Fallback to
zoomFitAll()when no saved view state
- Save view scale + center position (
- Persist ghost nodes in layout JSON:
- Serialize ghost node stable_id, name, description, input/output ports (id + name), position
- Serialize ghost connections (out_stable_id, out_port_index, in_stable_id, in_port_index)
- Restore ghosts from layout on load (before live sync)
- Add middle-click center:
eventFilteron viewport catchesMiddleButton→m_view->centerOn(mapToScene(pos)) - Add tests for view state save/load round-trip and ghost persistence
- Add "Zoom Fit All" context menu action →
-
Milestone 8c - Sidebar and Preset System
- Add
QSplitterbetween graph view and sidebar panel- Graph view (stretch factor 1) on left, sidebar (stretch factor 0) on right
- Persist splitter sizes in layout JSON, restore on load
- Default sizes: graph 1200, sidebar 320
- Add
QTabWidgetsidebar with styled tabs (dark theme)- Tab styling: dark background, selected tab has accent underline
- Initially one tab: "PRESETS" (meters/mixer tabs added in M8d/M8e)
- Implement
PresetManagerclass:savePreset(path)→ serialize to JSON:- Virtual devices: name, description, media_class, channels, rate
- Routing: links by stable_id:port_name pairs
- UI layout: node positions, view state
loadPreset(path)→ apply from JSON:- Create missing virtual devices
- Re-create links from routing entries
- Apply layout positions
- Save on quit via
QCoreApplication::aboutToQuitsignal
- Add "Save Preset..." context menu action →
QFileDialog::getSaveFileName() - Add "Load Preset..." context menu action →
QFileDialog::getOpenFileName() - Add tests for preset save/load round-trip
- Add
-
Milestone 8d - Volume/Mute Controls (requires core API:
SetNodeVolume())- Add
NodeVolumeStatestruct:{ float volume; bool mute; } - Add
ClickSlider : QSlider— click jumps to position instead of page-stepping - Add inline volume widget per node via
nodeData(NodeRole::Widget):- Horizontal
ClickSlider(0-100) + muteQToolButton - Calls
Client::SetNodeVolume(nodeId, volume, mute)on change - Styled: dark background, green slider fill, rounded mute button
- Horizontal
- Implement
VolumeChangeCommand : QUndoCommand- Stores previous + next
NodeVolumeState, node ID undo()→ apply previous state;redo()→ apply next state- Push on slider release or mute toggle (not during drag)
- Stores previous + next
- Track volume states in model:
std::unordered_map<NodeId, NodeVolumeState> m_volumeStatessetNodeVolumeState()— update state + sync inline widget + call Client APInodeVolumeState()— read current state- Emit
nodeVolumeChanged(nodeId, previous, current)signal
- Add "MIXER" tab to sidebar
QTabWidget:QScrollAreawith vertical layout of channel strips- Per-node strip: horizontal
ClickSlider(fader) + Mute (M) button + node label - Volume fader changes push
VolumeChangeCommandonto undo stack rebuildMixerStrips()— create/remove strips when nodes appear/disappear- Mixer strips sync from model state via
nodeVolumeChangedsignal
- Include volume/mute states in preset save/load (
volumesarray in JSON) - Add tests for volume state tracking, signal emission, widget sync, preset round-trip, cleanup on deletion
- Add
-
Milestone 8e - Audio Level Meters (requires core API:
MeterPeak(),NodeMeterPeak(),EnsureNodeMeter())- Implement
AudioLevelMeter : QWidget- Custom
paintEvent: vertical bar from bottom, background(24,24,28) - Color thresholds: green (0-0.7), yellow (0.7-0.9), red (0.9-1.0)
- Peak hold indicator: white horizontal line, holds 6 frames then decays at 0.02/frame
setLevel(float)— clamp 0-1, update hold, callupdate()sizeHint()→ 40×160
- Custom
- Add "METERS" tab to sidebar
QTabWidget:- "MASTER OUTPUT" label + master
AudioLevelMeter - "NODE METERS" label + scrollable list of per-node meter rows
- Per-node row: elided label + compact
AudioLevelMeter(fixed 26px wide, min 70px tall)
- "MASTER OUTPUT" label + master
- Add 30fps meter update timer (33ms,
Qt::PreciseTimer)- Poll
Client::MeterPeak()→ master meter - Poll
Client::NodeMeterPeak(nodeId)→ per-node meters - Auto-rebuild node meters on node create/delete
- Poll
- Auto-manage per-node meters:
- Call
EnsureNodeMeter()for each node during rebuild - Remove meter rows when nodes deleted
rebuildNodeMeters()wired tonodeCreated/nodeDeletedsignals
- Call
- Add tests for AudioLevelMeter level clamping, hold/decay logic, METERS tab existence, meter row creation
- Implement
-
Milestone 8f - Architecture and Routing Rules
- Event-driven updates: core
SetChangeCallback()fires on registry changes, GUI debounces via 50ms QTimer + QueuedConnection marshal (2s polling kept as fallback)Client::SetChangeCallback(ChangeCallback)— fires from PW thread on node/port/link add/removeNotifyChange()uses dedicatedchange_cb_mutex(not cache_mutex) to avoid lock ordering issues- GUI marshals to Qt thread via
QMetaObject::invokeMethod(..., Qt::QueuedConnection)
- Link intent system: implemented via core
saved_links+ deferredProcessSavedLinks()LoadConfig()parses links intosaved_linksvector (stable node:port name pairs)ProcessSavedLinks()resolves names → port IDs on each CoreDone, creates viaCreateSavedLinkAsync()- Competing links from WirePlumber auto-removed after saved link creation
- Persisted in config.json
linksarray (not layout JSON — core owns link state)
- Add routing rule UI (RULES sidebar tab)
- List existing rules from
Client::ListRouteRules()as styled cards - Add rules via dialog with Application Name, Process Binary, Media Role, Target Node fields
- Delete rules via per-card ✕ button
- List existing rules from
- Event-driven updates: core
Architecture
warppipe/
├── CMakeLists.txt # Add Qt6 + QtNodes, warppipe-gui + test targets
├── gui/
│ ├── main.cpp # QApplication entry, CLI flags (--screenshot, --debug-screenshot-dir, --offscreen)
│ ├── WarpGraphModel.h # QtNodes::AbstractGraphModel implementation
│ ├── WarpGraphModel.cpp # Model logic, warppipe::Client integration
│ ├── GraphEditorWidget.h # Main UI widget with scene + view
│ ├── GraphEditorWidget.cpp # Event handlers, context menus, refresh logic, graphReady() signal
│ ├── NodeStyleHelper.h/cpp # Node styling functions (colors, ghost mode)
│ └── ScreenshotHelper.h/cpp # QPixmap capture, debug dir, naming conventions
├── tests/
│ ├── warppipe_tests.cpp # Existing library tests
│ └── gui/
│ └── warppipe_gui_tests.cpp # Model unit tests + screenshot smoke tests
├── include/warppipe/
│ └── warppipe.hpp # No changes needed
└── src/
└── warppipe.cpp # No changes needed
Design Notes
Node Title Synthesis
Display title priority: description > application_name > name
- Hardware/Virtual nodes: Use
description(PW_KEY_NODE_DESCRIPTION), e.g., "Speakers", "Headphones" - Application nodes: Use
application_nameif non-empty (e.g., "Firefox", "Spotify") - Fallback to
nameif both are empty
Port Orientation
- Input ports (is_input=true): LEFT side of node (QtNodes::PortType::In)
- Output ports (is_input=false): RIGHT side of node (QtNodes::PortType::Out)
Ephemeral Node Handling
Application audio streams are ephemeral — they appear when an app plays audio and can disappear when stopped.
- Do NOT remove from visual graph when inactive
- Mark as "ghost" and apply faded styling (opacity 0.6, darker colors)
- Persist connections visually even when node is inactive
- Re-activate styling when node reappears (audio resumes)
Tracking strategy:
- Poll
Client::ListNodes()periodically (e.g., every 500ms) - Compare current list to previous list
- Nodes that disappeared → mark as ghost, keep in graph
- Nodes that reappeared → restore active styling
Node Type Colors (inspired by potato)
| Type | Base Color | Description |
|---|---|---|
| Hardware Sink/Source | Blue-gray (72, 94, 118) | Physical audio devices |
| Virtual Sink/Source | Green (62, 122, 104) | Virtual nodes created by warppipe |
| Application | Brown/Orange (138, 104, 72) | Ephemeral app audio streams |
Active vs Ghost:
- Active:
Opacity = 1.0f, lighter gradient, bright font - Ghost:
Opacity = 0.6f, darker gradient (.darker(150-180)), faded font
Connection Creation Flow
- User drags from output port to input port in UI
- QtNodes calls
WarpGraphModel::addConnection(connectionId) - Model extracts port IDs from connectionId
- Model calls
warppipe::Client::CreateLink(outputPortId, inputPortId, LinkOptions{}) - If success: add to m_connections, connection appears
- If failure: show error, do NOT add connection to graph
Context Menu Actions
- On canvas: "Create Virtual Sink", "Create Virtual Source"
- On virtual node: "Delete Node"
- Future: "Add Routing Rule", "Set as Default", "Edit Properties"
Layout Persistence
- Save to
~/.config/warppipe-gui/layout.json(XDG_CONFIG_HOME) - Format:
{ "version": 1, "nodes": [ { "stable_id": "alsa_output.pci-0000_00_1f.3.analog-stereo", "position": {"x": 100.0, "y": 200.0} } ], "view": { "scale": 1.0, "center": {"x": 0.0, "y": 0.0} } } - Use
NodeInfo.nameas stable_id (unique across sessions)
Qt/CMake Integration
CMakeLists.txt additions
# After existing warppipe library target:
option(BUILD_GUI "Build Qt6 GUI application" ON)
if(BUILD_GUI)
find_package(Qt6 6.2 REQUIRED COMPONENTS Core Widgets)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
include(FetchContent)
FetchContent_Declare(
QtNodes
GIT_REPOSITORY https://github.com/paceholder/nodeeditor
GIT_TAG master
)
FetchContent_MakeAvailable(QtNodes)
add_executable(warppipe-gui
gui/main.cpp
gui/WarpGraphModel.cpp
gui/GraphEditorWidget.cpp
gui/NodeStyleHelper.cpp
)
target_link_libraries(warppipe-gui PRIVATE
warppipe
Qt6::Widgets
QtNodes
)
install(TARGETS warppipe-gui RUNTIME DESTINATION bin)
endif()
GUI test target
if(BUILD_GUI)
# Model unit tests (no display needed)
add_executable(warppipe-gui-tests
tests/gui/warppipe_gui_tests.cpp
gui/WarpGraphModel.cpp
gui/NodeStyleHelper.cpp
)
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)
target_link_libraries(warppipe-gui-tests PRIVATE
warppipe
Qt6::Widgets
QtNodes
Catch2::Catch2WithMain
)
# Screenshot smoke tests (opt-in, need Qt runtime)
option(WARPPIPE_GUI_VISUAL_TESTS "Enable screenshot-based visual tests" OFF)
if(WARPPIPE_GUI_VISUAL_TESTS)
add_test(NAME gui_screenshot_smoke
COMMAND ${CMAKE_COMMAND} -E env QT_QPA_PLATFORM=offscreen
$<TARGET_FILE:warppipe-gui> --screenshot ${CMAKE_BINARY_DIR}/test_screenshot.png
)
set_tests_properties(gui_screenshot_smoke PROPERTIES
LABELS "visual"
TIMEOUT 10
)
endif()
endif()
Dependencies
- Qt6 >= 6.2 (Core, Widgets)
- Qt6::Test (for GUI test target)
- QtNodes (nodeeditor) — fetched via CMake FetchContent
- Catch2 v3 — fetched via CMake FetchContent (shared with existing tests)
- warppipe library (existing)
Key QtNodes API Usage
AbstractGraphModel Interface
class WarpGraphModel : public QtNodes::AbstractGraphModel {
Q_OBJECT
public:
// Node management
QtNodes::NodeId newNodeId() override;
std::unordered_set<QtNodes::NodeId> allNodeIds() const override;
bool nodeExists(QtNodes::NodeId) const override;
QtNodes::NodeId addNode(QString const nodeType) override;
bool deleteNode(QtNodes::NodeId) override;
// Node data (caption, style, position, widget)
QVariant nodeData(QtNodes::NodeId, QtNodes::NodeRole) const override;
bool setNodeData(QtNodes::NodeId, QtNodes::NodeRole, QVariant) override;
// Port data (count, label, type, connection policy)
QVariant portData(QtNodes::NodeId, QtNodes::PortType, QtNodes::PortIndex, QtNodes::PortRole) const override;
// Connection management
std::unordered_set<QtNodes::ConnectionId> allConnectionIds(QtNodes::NodeId) const override;
bool connectionPossible(QtNodes::ConnectionId) const override;
void addConnection(QtNodes::ConnectionId) override;
bool deleteConnection(QtNodes::ConnectionId) override;
// Custom methods
void refreshFromClient(); // Poll warppipe::Client and sync graph
void markNodeAsGhost(QtNodes::NodeId, bool isGhost);
};
Scene and View Setup
// In GraphEditorWidget constructor:
m_model = new WarpGraphModel(client, this);
m_scene = new QtNodes::BasicGraphicsScene(*m_model, this);
m_view = new QtNodes::GraphicsView(m_scene);
QVBoxLayout *layout = new QVBoxLayout(this);
layout->addWidget(m_view);
// Connect signals
connect(m_scene, &QtNodes::BasicGraphicsScene::connectionCreated,
this, &GraphEditorWidget::onConnectionCreated);
connect(m_scene, &QtNodes::BasicGraphicsScene::connectionDeleted,
this, &GraphEditorWidget::onConnectionDeleted);
Testing Strategy
Three test tiers
Tier 1 — Model Unit Tests (no display server) Pure logic tests for WarpGraphModel. Use warppipe's WARPPIPE_TESTING helpers to inject fake nodes/ports/links into a Client, then construct the model and assert state. These tests run everywhere (CI, headless, local).
Coverage targets:
- Node mapping (PipeWire ID → QtNodes NodeId, caption synthesis, style by type)
- Port mapping (is_input orientation, port count, labels)
- Connection mapping (link → ConnectionId, add/delete roundtrip)
- Ghost state (inactive toggle, opacity values, ghost connections)
- Edge cases (duplicate IDs, remove with active connections, empty graph)
Tier 2 — Screenshot Smoke Tests (headless, QT_QPA_PLATFORM=offscreen)
Launch warppipe-gui with --screenshot in offscreen mode. Verify:
- Exit code 0 and PNG file produced
- File size heuristic (>10KB = non-trivial content rendered)
- Debug screenshot dir populates on state changes
Gated behind WARPPIPE_GUI_VISUAL_TESTS CMake option (default OFF) since they need Qt runtime.
Tier 3 — Manual QA with AI-Assisted Debugging
Run warppipe-gui with a live PipeWire daemon. Use --debug-screenshot-dir to capture every state transition. When something looks wrong:
- Run
warppipe-gui --debug-screenshot-dir /tmp/warppipe-debug/ - Reproduce the issue
- Hand the screenshot directory to the AI for visual analysis
Checklist:
- All audio nodes appear with correct titles
- Ports on correct sides (input=left, output=right)
- Dragging connections creates PipeWire links
- Virtual sink/source creation via context menu
- Ephemeral nodes fade when inactive, connections persist
- Layout persists across restarts
AI Debugging Workflow
The screenshot infrastructure in Milestone 6 is specifically designed so the AI can debug visual issues:
# Quick single screenshot for AI analysis
QT_QPA_PLATFORM=offscreen warppipe-gui --screenshot /tmp/gui-state.png
# Or use convenience flag
warppipe-gui --offscreen --screenshot /tmp/gui-state.png
# Continuous debug capture (every state change gets a timestamped screenshot)
warppipe-gui --debug-screenshot-dir /tmp/warppipe-debug/
# Then hand to AI:
# "Here are the screenshots from /tmp/warppipe-debug/, the ghost nodes
# aren't fading correctly — can you see what's wrong?"
The AI can then use its multimodal capabilities to examine the PNGs, compare expected vs actual visual state, and identify styling or layout bugs without needing a live display.
Future Enhancements
- Routing rule editor: Visual panel to add/edit/remove per-app routing rules
- Audio level meters: Real-time level monitoring (requires PipeWire param API)
- Volume/mute controls: Inline controls on nodes (requires warppipe volume API)
- Presets: Save/load full configurations (nodes + links + rules)
- Search/filter: Filter nodes by type, name, connection status
- Minimap: Overview of entire graph in large setups
- Themes: Light/dark mode, custom color schemes
- Keyboard shortcuts: Create nodes, delete selection, undo/redo
References
- potato project: ~/Projects/potato/ (reference implementation using QtNodes + PipeWire)
- QtNodes library: https://github.com/paceholder/nodeeditor
- warppipe API: include/warppipe/warppipe.hpp, docs/api.md
- Qt6 Widgets: https://doc.qt.io/qt-6/qtwidgets-index.html