warp-pipe/GUI_PLAN.md
2026-01-31 11:21:28 -07:00

30 KiB
Raw Blame History

Warp Pipe 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-gui target 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
  • 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::NodeInfo to 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_name is non-empty and differs from name, use application_name as title
      • Otherwise use name field
      • Store synthesized title in nodeData(NodeRole::Caption)
    • 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
  • 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
    • Verify: Application nodes appear vibrant when active, fade when inactive, never disappear
  • 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
    • 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
  • 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()
    • 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
  • 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
    • 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
  • 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
    • 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:
      • 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
    • 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
    • Add warppipe-gui-tests CMake 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_TESTS CMake option (default OFF)
      • Launch warppipe-gui with --screenshot → CTest verifies exit code 0
    • 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_TESTS CMake option (default OFF)
      • ctest --test-dir build runs model + GUI tests
  • Milestone 8a - Undo/Redo, Clipboard, and Keyboard Shortcuts

    • Integrate QUndoStack via BasicGraphicsScene::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
    • Implement DeleteVirtualNodeCommand : QUndoCommand
      • redo(): destroy virtual node via Client::RemoveNode()
      • undo(): re-create virtual node via Client::CreateVirtualSink/Source() with same name/channels/rate
      • Store node position and restore on undo
    • Implement deleteSelection() for Del key
      • Collect selected NodeGraphicsObject items from m_scene->selectedItems()
      • Virtual nodes → push DeleteVirtualNodeCommand onto undo stack
      • Non-virtual nodes → push QtNodes::DeleteCommand (removes from graph only, not PipeWire)
      • Connection-only selection → push QtNodes::DeleteCommand
    • 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 QClipboard with custom MIME type application/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 PendingPasteLink queue (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
    • 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)
  • 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
    • 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
    • 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: eventFilter on viewport catches MiddleButtonm_view->centerOn(mapToScene(pos))
    • Add tests for view state save/load round-trip and ghost persistence
  • Milestone 8c - Sidebar and Preset System

    • Add QSplitter between 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 QTabWidget sidebar 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 PresetManager class:
      • 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::aboutToQuit signal
    • Add "Save Preset..." context menu action → QFileDialog::getSaveFileName()
    • Add "Load Preset..." context menu action → QFileDialog::getOpenFileName()
    • Add tests for preset save/load round-trip
  • Milestone 8d - Volume/Mute Controls (requires core API: SetNodeVolume())

    • Add NodeVolumeState struct: { 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) + mute QToolButton
      • Calls Client::SetNodeVolume(nodeId, volume, mute) on change
      • Styled: dark background, green slider fill, rounded mute button
    • 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)
    • Track volume states in model: std::unordered_map<NodeId, NodeVolumeState> m_volumeStates
      • setNodeVolumeState() — update state + sync inline widget + call Client API
      • nodeVolumeState() — read current state
      • Emit nodeVolumeChanged(nodeId, previous, current) signal
    • Add "MIXER" tab to sidebar QTabWidget:
      • QScrollArea with vertical layout of channel strips
      • Per-node strip: horizontal ClickSlider (fader) + Mute (M) button + node label
      • Volume fader changes push VolumeChangeCommand onto undo stack
      • rebuildMixerStrips() — create/remove strips when nodes appear/disappear
      • Mixer strips sync from model state via nodeVolumeChanged signal
    • Include volume/mute states in preset save/load (volumes array in JSON)
    • Add tests for volume state tracking, signal emission, widget sync, preset round-trip, cleanup on deletion
  • 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, call update()
      • sizeHint() → 40×160
    • 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)
    • 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
    • Auto-manage per-node meters:
      • Call EnsureNodeMeter() for each node during rebuild
      • Remove meter rows when nodes deleted
      • rebuildNodeMeters() wired to nodeCreated/nodeDeleted signals
    • Add tests for AudioLevelMeter level clamping, hold/decay logic, METERS tab existence, meter row creation
  • 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/remove
      • NotifyChange() uses dedicated change_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 + deferred ProcessSavedLinks()
      • LoadConfig() parses links into saved_links vector (stable node:port name pairs)
      • ProcessSavedLinks() resolves names → port IDs on each CoreDone, creates via CreateSavedLinkAsync()
      • Competing links from WirePlumber auto-removed after saved link creation
      • Persisted in config.json links array (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

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_name if non-empty (e.g., "Firefox", "Spotify")
  • Fallback to name if 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:

  1. Poll Client::ListNodes() periodically (e.g., every 500ms)
  2. Compare current list to previous list
  3. Nodes that disappeared → mark as ghost, keep in graph
  4. 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

  1. User drags from output port to input port in UI
  2. QtNodes calls WarpGraphModel::addConnection(connectionId)
  3. Model extracts port IDs from connectionId
  4. Model calls warppipe::Client::CreateLink(outputPortId, inputPortId, LinkOptions{})
  5. If success: add to m_connections, connection appears
  6. 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.name as 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(WARPPIPE_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::Core
    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)
  • QtNodes (nodeeditor) — fetched via CMake FetchContent
  • Catch2 v3 — required for GUI tests (via find_package)
  • 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:

  1. Run warppipe-gui --debug-screenshot-dir /tmp/warppipe-debug/
  2. Reproduce the issue
  3. 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