diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index 93a702c..a6bfdf7 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -1,10 +1,16 @@ @@ -309,19 +334,37 @@
{$connected ? 'Connected' : 'Disconnected'} - + + - - - - - {$nodes.length} nodes | {$ports.length} ports | {$links.length} links + + + + + + + + + + + + + + {$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}
@@ -347,15 +390,21 @@ {@const inPos = getPortPos(link.input_port_id)} {#if outPos && inPos} {@const color = portColor(link.outPort.port_type)} + {@const isSelected = selectedEdge === String(link.id)} + {#if link.pinned} + + {/if} {/if} {/each} @@ -403,9 +452,7 @@ onmouseenter={() => { hoveredPort = port.id; }} onmouseleave={() => { hoveredPort = null; }} /> - - {shortName(port.name)} - + {shortName(port.name)} {:else} {/if} @@ -414,9 +461,7 @@ {#each nd.outPorts as port, i (port.id)} {@const py = nd.y + 22 + i * 16 + 8} {#if isPortVisible(port.port_type)} - - {shortName(port.name)} - + {shortName(port.name)} + {#if contextMenu} + {/if} + + + {#if showHideDialog} +
+
+ Node Hiding Rules + +
+
+

Hide nodes matching pattern (regex or plain text).

+
+ + +
+
+ {#each $patchbay.hide_rules as rule} +
+ {rule} + +
+ {/each} + {#if $patchbay.hide_rules.length === 0} +
No hiding rules.
+ {/if} +
+
+
+ {/if} + + + {#if showMergeDialog} +
+
+ Node Merging Rules + +
+
+

Merge nodes sharing a common name prefix into one block.

+
+ + +
+
+ {#each $patchbay.merge_rules as rule} +
+ {rule} + +
+ {/each} + {#if $patchbay.merge_rules.length === 0} +
No merge rules.
+ {/if} +
+
+
+ {/if} + + + {#if showProfileDialog} +
+
+ Profile Management + +
+
+
+ + +
+
+ {#each Object.entries($patchbay.profiles) as [name, profile]} +
+ {name} ({profile.connections.length} rules) + + +
+ {/each} + {#if Object.keys($patchbay.profiles).length === 0} +
No saved profiles.
+ {/if} +
+
+
+ {/if} + + + {#if showRuleDialog} +
+
+ Rule Management + +
+
+ {#if $patchbay.active_profile && $patchbay.profiles[$patchbay.active_profile]} + {@const profile = $patchbay.profiles[$patchbay.active_profile]} +

Active profile: {profile.name} ({profile.connections.length} rules)

+
+ {#each profile.connections as rule, i} +
+ {rule.output_node_name}:{shortName(rule.output_port_name)} => {rule.input_node_name}:{shortName(rule.input_port_name)} + {rule.output_port_type} + {#if rule.pinned}PIN{/if} +
+ {/each} +
+ {:else} +
No active profile. Save a profile first via Profiles.
+ {/if} +
{/if} @@ -448,19 +606,20 @@ .toolbar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; - display: flex; align-items: center; gap: 8px; - padding: 4px 12px; + display: flex; align-items: center; gap: 6px; + padding: 4px 10px; background: rgba(16, 16, 24, 0.97); border-bottom: 1px solid #333; font-size: 11px; color: #aaa; font-family: monospace; + flex-wrap: wrap; } .dot { width: 7px; height: 7px; border-radius: 50%; background: #a44; flex-shrink: 0; } .dot.on { background: #4a9; } - .sep { width: 1px; height: 16px; background: #444; margin: 0 4px; } + .sep { width: 1px; height: 16px; background: #444; margin: 0 2px; flex-shrink: 0; } .filter { display: flex; align-items: center; gap: 3px; cursor: pointer; - padding: 2px 5px; border-radius: 3px; font-size: 10px; + padding: 2px 5px; border-radius: 3px; font-size: 10px; user-select: none; } .filter:hover { background: rgba(255,255,255,0.05); } .filter input { display: none; } @@ -472,31 +631,86 @@ .filter:not(.active) { opacity: 0.4; } .filter:not(.active) .fc { background: #555 !important; } - .toolbar button { - padding: 2px 8px; background: #2a2a3e; border: 1px solid #444; + .toolbar button, .toggle { + padding: 2px 7px; background: #2a2a3e; border: 1px solid #444; color: #aaa; font-size: 10px; cursor: pointer; border-radius: 3px; font-family: monospace; + white-space: nowrap; } - .toolbar button:hover { background: #3a3a4e; } - - .pbname { - width: 100px; padding: 2px 6px; background: #1a1a2e; border: 1px solid #444; - color: #aaa; font-size: 10px; border-radius: 3px; font-family: monospace; - } + .toolbar button:hover, .toggle:hover { background: #3a3a4e; } + .toggle.active { background: #1a3a2a; border-color: #4a9; color: #6c9; } .stats { margin-left: auto; color: #555; } + /* Context menu */ .ctx { position: fixed; z-index: 100; background: #2a2a3e; border: 1px solid #555; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6); } .ctx button { - display: block; width: 100%; padding: 6px 20px; + display: block; width: 100%; padding: 5px 20px; background: none; border: none; color: #ccc; font-size: 12px; cursor: pointer; text-align: left; font-family: monospace; } .ctx button:hover { background: #444; } + /* Dialogs */ + .dialog { + position: absolute; top: 40px; right: 10px; z-index: 20; + background: #1e1e2e; border: 1px solid #555; border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.6); + width: 360px; max-height: 80vh; overflow: hidden; + display: flex; flex-direction: column; + font-family: monospace; + } + .dialog-header { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 12px; background: #2a2a3e; border-bottom: 1px solid #444; + font-size: 12px; color: #ddd; font-weight: bold; + } + .close { background: none; border: none; color: #888; cursor: pointer; font-size: 14px; padding: 0 4px; } + .close:hover { color: #fff; } + .dialog-body { padding: 10px; overflow-y: auto; } + .hint { font-size: 10px; color: #666; margin: 0 0 8px; } + + .input-row { display: flex; gap: 6px; margin-bottom: 8px; } + .dlg-input { + flex: 1; padding: 4px 8px; background: #14141e; border: 1px solid #444; + color: #ccc; font-size: 11px; border-radius: 3px; font-family: monospace; + } + .input-row button { + padding: 4px 10px; background: #2a2a3e; border: 1px solid #444; + color: #aaa; font-size: 11px; cursor: pointer; border-radius: 3px; font-family: monospace; + } + .input-row button:hover { background: #3a3a4e; } + + .rule-list { display: flex; flex-direction: column; gap: 2px; } + .rule-list.scrollable { max-height: 300px; overflow-y: auto; } + .rule-item { + display: flex; align-items: center; gap: 6px; + padding: 3px 6px; background: #14141e; border-radius: 3px; + font-size: 10px; color: #aaa; + } + .rule-item span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .rule-item button { + padding: 1px 6px; background: #2a2a3e; border: 1px solid #444; + color: #888; font-size: 9px; cursor: pointer; border-radius: 2px; font-family: monospace; + flex-shrink: 0; + } + .rule-item button:hover { color: #f88; border-color: #a44; } + .rule-item.small { font-size: 9px; } + .active-profile { color: #6c9; font-weight: bold; } + + .type-badge { + font-size: 8px; padding: 0 4px; border-radius: 2px; color: #fff; + } + .type-badge.audio { background: #4a9; } + .type-badge.midi { background: #a44; } + .type-badge.video { background: #49a; } + .pin-badge { font-size: 8px; padding: 0 3px; background: #aa4; color: #000; border-radius: 2px; } + + .empty { font-size: 11px; color: #555; padding: 8px 0; text-align: center; } + .port-circle { cursor: crosshair; } .port-circle:hover { filter: brightness(1.5); } .edge-path { cursor: pointer; pointer-events: stroke; } diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 6d13e81..73e2fbf 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -1,5 +1,5 @@ -import { writable, derived, get } from 'svelte/store'; -import type { Node, Port, Link, GraphMessage } from './types'; +import { writable, derived } from 'svelte/store'; +import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule } from './types'; import { subscribe, connectPorts, disconnectPorts } from './ws'; // Raw graph stores @@ -8,21 +8,33 @@ export const ports = writable([]); export const links = writable([]); export const connected = writable(false); -// Port lookup map +// Patchbay state +export const patchbay = writable({ + profiles: {}, + active_profile: '', + activated: false, + exclusive: false, + auto_pin: false, + auto_disconnect: false, + pinned_connections: [], + hide_rules: [], + merge_rules: [], +}); + +// Port/node lookups export const portById = derived(ports, ($ports) => { const map = new Map(); for (const p of $ports) map.set(p.id, p); return map; }); -// Node lookup map export const nodeById = derived(nodes, ($nodes) => { const map = new Map(); for (const n of $nodes) map.set(n.id, n); return map; }); -// Initialize: fetch via REST immediately, then subscribe to WS for updates +// SSE init let unsubscribe: (() => void) | null = null; function applyGraph(graph: GraphMessage) { @@ -30,23 +42,61 @@ function applyGraph(graph: GraphMessage) { ports.set(graph.ports); links.set(graph.links); connected.set(true); + + // Auto-restore if activated + const pb = get_store_value(patchbay); + if (pb.activated && pb.active_profile) { + applyPatchbay(pb); + } + // Auto-pin new connections + if (pb.auto_pin) { + const current = get_store_value(links); + const pinned = [...pb.pinned_connections]; + for (const link of current) { + if (!pinned.includes(link.id)) { + pinned.push(link.id); + } + } + patchbay.update(pb => ({ ...pb, pinned_connections: pinned })); + } + // Exclusive mode: disconnect unpinned links not in profile + if (pb.activated && pb.exclusive && pb.active_profile) { + enforceExclusive(pb); + } +} + +// Simple store value getter (for reading outside components) +function get_store_value(store: { subscribe: (fn: (val: T) => void) => () => void }): T { + let val: T; + const unsub = store.subscribe(v => { val = v; }); + unsub(); + return val!; } export async function initGraph() { if (unsubscribe) return; - // 1. Fetch initial state via REST API (works immediately, no WS needed) + // Load patchbay state from server + try { + const res = await fetch('/api/patchbay'); + if (res.ok) { + const data = await res.json(); + if (data && data.profiles) { + patchbay.set(data as PatchbayState); + } + } + } catch {} + + // Fetch initial graph try { const res = await fetch('/api/graph'); if (res.ok) { - const graph: GraphMessage = await res.json(); - applyGraph(graph); + applyGraph(await res.json()); } } catch (e) { console.warn('[graph] REST fetch failed:', e); } - // 2. Subscribe to WebSocket for live updates unsubscribe = subscribe((graph: GraphMessage) => { applyGraph(graph); }); @@ -60,5 +110,252 @@ export function destroyGraph() { } } -// Re-export connection functions +// Patchbay operations +function linkKey(link: { output_port_id: number; input_port_id: number }): string { + return `${link.output_port_id}:${link.input_port_id}`; +} + +function ruleKey(rule: ConnectionRule): string { + return `${rule.output_node_name}:${rule.output_port_name}=>${rule.input_node_name}:${rule.input_port_name}`; +} + +export async function savePatchbayState() { + const pb = get_store_value(patchbay); + await fetch('/api/patchbay', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(pb), + }); +} + +async function applyPatchbay(pb: PatchbayState) { + if (!pb.active_profile || !pb.profiles[pb.active_profile]) return; + const profile = pb.profiles[pb.active_profile]; + const currentNodes = get_store_value(nodes); + const currentPorts = get_store_value(ports); + const currentLinks = get_store_value(links); + const existingKeys = new Set(currentLinks.map(linkKey)); + + // Build name lookup for current graph + const portByName = new Map(); + const nodeByIdMap = new Map(); + for (const n of currentNodes) nodeByIdMap.set(n.id, n); + for (const p of currentPorts) { + const node = nodeByIdMap.get(p.node_id); + if (node) { + const key = `${node.name}:${p.name}:${p.mode}:${p.port_type}`; + portByName.set(key, p); + } + } + + for (const rule of profile.connections) { + const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`; + const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`; + const outPort = portByName.get(outKey); + const inPort = portByName.get(inKey); + if (outPort && inPort) { + const lk = `${outPort.id}:${inPort.id}`; + if (!existingKeys.has(lk)) { + connectPorts(outPort.id, inPort.id); + } + } + } +} + +async function enforceExclusive(pb: PatchbayState) { + if (!pb.active_profile || !pb.profiles[pb.active_profile]) return; + const profile = pb.profiles[pb.active_profile]; + const currentNodes = get_store_value(nodes); + const currentPorts = get_store_value(ports); + const currentLinks = get_store_value(links); + const nodeByIdMap = new Map(); + for (const n of currentNodes) nodeByIdMap.set(n.id, n); + + // Build set of allowed connection keys from profile + const allowed = new Set(); + const portByName = new Map(); + for (const p of currentPorts) { + const node = nodeByIdMap.get(p.node_id); + if (node) { + portByName.set(`${node.name}:${p.name}:${p.mode}:${p.port_type}`, p); + } + } + for (const rule of profile.connections) { + const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`; + const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`; + const outPort = portByName.get(outKey); + const inPort = portByName.get(inKey); + if (outPort && inPort) { + allowed.add(`${outPort.id}:${inPort.id}`); + } + } + + // Also keep pinned connections + for (const linkId of pb.pinned_connections) { + const link = currentLinks.find(l => l.id === linkId); + if (link) allowed.add(linkKey(link)); + } + + // Disconnect everything else + for (const link of currentLinks) { + if (!allowed.has(linkKey(link))) { + disconnectPorts(link.output_port_id, link.input_port_id); + } + } +} + +export function togglePin(linkId: number) { + patchbay.update(pb => { + const idx = pb.pinned_connections.indexOf(linkId); + if (idx >= 0) { + pb.pinned_connections.splice(idx, 1); + } else { + pb.pinned_connections.push(linkId); + } + return { ...pb }; + }); + savePatchbayState(); +} + +export function addHideRule(pattern: string) { + patchbay.update(pb => { + if (!pb.hide_rules.includes(pattern)) { + pb.hide_rules.push(pattern); + } + return { ...pb }; + }); + savePatchbayState(); +} + +export function removeHideRule(pattern: string) { + patchbay.update(pb => { + return { ...pb, hide_rules: pb.hide_rules.filter(r => r !== pattern) }; + }); + savePatchbayState(); +} + +export function addMergeRule(prefix: string) { + patchbay.update(pb => { + if (!pb.merge_rules.includes(prefix)) { + pb.merge_rules.push(prefix); + } + return { ...pb }; + }); + savePatchbayState(); +} + +export function removeMergeRule(prefix: string) { + patchbay.update(pb => { + return { ...pb, merge_rules: pb.merge_rules.filter(r => r !== prefix) }; + }); + savePatchbayState(); +} + +export function setActivated(active: boolean) { + patchbay.update(pb => { + const newPb = { ...pb, activated: active }; + if (active && pb.active_profile) { + setTimeout(() => applyPatchbay(newPb), 100); + } + if (!active && pb.exclusive) { + // Auto-disconnect: remove all pinned connections when deactivated + if (pb.auto_disconnect) { + const currentLinks = get_store_value(links); + for (const linkId of pb.pinned_connections) { + const link = currentLinks.find(l => l.id === linkId); + if (link) disconnectPorts(link.output_port_id, link.input_port_id); + } + } + } + return newPb; + }); + savePatchbayState(); +} + +export function setExclusive(exclusive: boolean) { + patchbay.update(pb => ({ ...pb, exclusive })); + if (exclusive) { + const pb = get_store_value(patchbay); + if (pb.activated) enforceExclusive(pb); + } + savePatchbayState(); +} + +export function setAutoPin(val: boolean) { + patchbay.update(pb => ({ ...pb, auto_pin: val })); + savePatchbayState(); +} + +export function setAutoDisconnect(val: boolean) { + patchbay.update(pb => ({ ...pb, auto_disconnect: val })); + savePatchbayState(); +} + +export async function saveProfile(name: string) { + const currentLinks = get_store_value(links); + const currentPorts = get_store_value(ports); + const currentNodes = get_store_value(nodes); + const pb = get_store_value(patchbay); + + const portByIdMap = new Map(); + for (const p of currentPorts) portByIdMap.set(p.id, p); + const nodeByIdMap = new Map(); + for (const n of currentNodes) nodeByIdMap.set(n.id, n); + + const connections: ConnectionRule[] = []; + for (const link of currentLinks) { + const outPort = portByIdMap.get(link.output_port_id); + const inPort = portByIdMap.get(link.input_port_id); + if (!outPort || !inPort) continue; + const outNode = nodeByIdMap.get(outPort.node_id); + const inNode = nodeByIdMap.get(inPort.node_id); + if (!outNode || !inNode) continue; + connections.push({ + output_node_name: outNode.name, + output_port_name: outPort.name, + output_port_type: outPort.port_type, + input_node_name: inNode.name, + input_port_name: inPort.name, + input_port_type: inPort.port_type, + pinned: pb.pinned_connections.includes(link.id), + }); + } + + const profile: PatchbayProfile = { + name, + connections, + hide_rules: [...pb.hide_rules], + merge_rules: [...pb.merge_rules], + }; + + patchbay.update(pb => ({ + ...pb, + profiles: { ...pb.profiles, [name]: profile }, + active_profile: name, + })); + savePatchbayState(); +} + +export function loadProfile(name: string) { + patchbay.update(pb => ({ ...pb, active_profile: name })); + const pb = get_store_value(patchbay); + if (pb.activated) { + applyPatchbay(pb); + } + savePatchbayState(); +} + +export function deleteProfile(name: string) { + patchbay.update(pb => { + const profiles = { ...pb.profiles }; + delete profiles[name]; + return { + ...pb, + profiles, + active_profile: pb.active_profile === name ? '' : pb.active_profile, + }; + }); + savePatchbayState(); +} + export { connectPorts, disconnectPorts }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 02c3057..454a3c9 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -45,3 +45,33 @@ export interface DisconnectMessage { } export type WsMessage = ConnectMessage | DisconnectMessage; + +// Patchbay types +export interface ConnectionRule { + output_port_name: string; + output_node_name: string; + output_port_type: string; + input_port_name: string; + input_node_name: string; + input_port_type: string; + pinned?: boolean; +} + +export interface PatchbayProfile { + name: string; + connections: ConnectionRule[]; + hide_rules?: string[]; + merge_rules?: string[]; +} + +export interface PatchbayState { + profiles: Record; + active_profile: string; + activated: boolean; + exclusive: boolean; + auto_pin: boolean; + auto_disconnect: boolean; + pinned_connections: number[]; + hide_rules: string[]; + merge_rules: string[]; +}