From d178e8765bf5516b91c652ff109aa2247becabb1 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 06:50:36 -0700 Subject: [PATCH] M8 Features --- GUI_PLAN.md | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 5 deletions(-) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 2e45625..e294020 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -159,15 +159,136 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Screenshot tests gated behind `WARPPIPE_GUI_VISUAL_TESTS` CMake option (default OFF) - [x] `ctest --test-dir build` runs model + GUI tests -- [ ] Milestone 8 (Optional) - Advanced Features +- [ ] 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 `MiddleButton` → `m_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: `QHash m_nodeVolumeState` + - [ ] `setNodeVolumeState()` — update state + sync inline widget + - [ ] `nodeVolumeState()` — read current state + - [ ] Emit `nodeVolumeChanged(nodeId, previous, current)` signal + - [ ] Add "MIXER" tab to sidebar `QTabWidget`: + - [ ] `QScrollArea` with horizontal layout of channel strips + - [ ] Per-node strip: `AudioLevelMeter` + vertical `ClickSlider` (fader) + Mute (M) + Solo (S) buttons + node label + - [ ] Solo logic: when any node is soloed, all non-soloed nodes are muted + - [ ] Volume fader changes push `VolumeChangeCommand` onto undo stack + - [ ] `refreshMixerStrip()` — create strip when node appears + - [ ] `removeMixerStrip()` — destroy strip when node removed + - [ ] `updateMixerState()` — sync fader/mute from model state + - [ ] Include volume/mute states in preset save/load (`persistent_volumes`, `persistent_mutes`) + - [ ] Add tests for VolumeChangeCommand undo/redo and mixer strip lifecycle +- [ ] 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 + mixer meters + - [ ] Skip updates when widget is not visible (`isVisible()` check) + - [ ] Auto-manage per-node meters: + - [ ] Create meter when node has active links (`ensureNodeMeter()`) + - [ ] Remove meter when node removed or all links removed (`removeNodeMeter()`) + - [ ] Skip meter nodes (filter by name prefix) + - [ ] Add tests for AudioLevelMeter level clamping, hold/decay logic +- [ ] Milestone 8f (Optional) - Architecture and Routing Rules + - [ ] Event-driven updates: replace 500ms polling with signal/slot if core adds registry callbacks + - [ ] `nodeAdded(NodeInfo)`, `nodeRemoved(uint32_t)`, `nodeChanged(NodeInfo)` + - [ ] `linkAdded(LinkInfo)`, `linkRemoved(uint32_t)` + - [ ] Keep polling as fallback if signals not available + - [ ] Link intent system: remember intended links by stable key, restore when nodes reappear + - [ ] `rememberLinkIntent(LinkInfo)` — store stable_id:port_name pairs + - [ ] `tryRestoreLinks()` — called on node add, resolves stored intents + - [ ] Persist link intents in layout JSON - [ ] Add routing rule UI (separate panel or dialog) - [ ] List existing rules from `Client::ListRouteRules()` - [ ] Add/remove rules with RuleMatch fields - [ ] Show which nodes are affected by rules - - [ ] Add volume/mute controls (if warppipe adds port parameters API) - - [ ] Add audio level meters (requires PipeWire param monitoring) - - [ ] Add config save/load UI (call `Client::SaveConfig()/LoadConfig()`) - - [ ] Add presets system (save/restore full node+link+rule configuration) ---