226 lines
7.0 KiB
QML
226 lines
7.0 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
|
|
// Session topology graph: nodes = sessions, edges = peer relationships
|
|
Item {
|
|
id: graphRoot
|
|
|
|
property var sessions: [] // [{id, state, model, agent}]
|
|
property var peerListFn: null // function(sessionId) => [peerId, ...]
|
|
|
|
// Layout: arrange nodes in a circle
|
|
property var nodePositions: ({})
|
|
|
|
// Pan and zoom state
|
|
property real panX: 0
|
|
property real panY: 0
|
|
property real zoom: 1.0
|
|
property real minZoom: 0.3
|
|
property real maxZoom: 3.0
|
|
|
|
clip: true
|
|
|
|
onSessionsChanged: layoutNodes()
|
|
onWidthChanged: layoutNodes()
|
|
onHeightChanged: layoutNodes()
|
|
|
|
function layoutNodes() {
|
|
let positions = {}
|
|
let count = sessions.length
|
|
if (count === 0) return
|
|
|
|
let cx = width / 2
|
|
let cy = height / 2
|
|
let radius = Math.min(width, height) * 0.35
|
|
|
|
// Single node goes in center
|
|
if (count === 1) {
|
|
positions[sessions[0].id] = {x: cx, y: cy}
|
|
} else {
|
|
for (let i = 0; i < count; i++) {
|
|
let angle = (2 * Math.PI * i / count) - Math.PI / 2
|
|
positions[sessions[i].id] = {
|
|
x: cx + radius * Math.cos(angle),
|
|
y: cy + radius * Math.sin(angle)
|
|
}
|
|
}
|
|
}
|
|
nodePositions = positions
|
|
canvas.requestPaint()
|
|
}
|
|
|
|
function stateColor(state) {
|
|
if (state === "idle") return "#4caf50"
|
|
if (state && state.startsWith("calling")) return "#ff9800"
|
|
if (state === "thinking") return "#2196f3"
|
|
return "#2196f3"
|
|
}
|
|
|
|
// Pan/zoom mouse handling
|
|
MouseArea {
|
|
id: panArea
|
|
anchors.fill: parent
|
|
property real lastX: 0
|
|
property real lastY: 0
|
|
|
|
onPressed: function(mouse) {
|
|
lastX = mouse.x
|
|
lastY = mouse.y
|
|
}
|
|
onPositionChanged: function(mouse) {
|
|
if (pressed) {
|
|
graphRoot.panX += (mouse.x - lastX)
|
|
graphRoot.panY += (mouse.y - lastY)
|
|
lastX = mouse.x
|
|
lastY = mouse.y
|
|
}
|
|
}
|
|
onWheel: function(wheel) {
|
|
let factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87
|
|
let newZoom = Math.max(graphRoot.minZoom, Math.min(graphRoot.maxZoom, graphRoot.zoom * factor))
|
|
// Zoom toward mouse position
|
|
let mx = wheel.x - graphRoot.panX
|
|
let my = wheel.y - graphRoot.panY
|
|
graphRoot.panX -= mx * (newZoom / graphRoot.zoom - 1)
|
|
graphRoot.panY -= my * (newZoom / graphRoot.zoom - 1)
|
|
graphRoot.zoom = newZoom
|
|
}
|
|
}
|
|
|
|
// Transformed content (pan + zoom)
|
|
Item {
|
|
id: graphContent
|
|
x: graphRoot.panX
|
|
y: graphRoot.panY
|
|
width: parent.width
|
|
height: parent.height
|
|
scale: graphRoot.zoom
|
|
transformOrigin: Item.TopLeft
|
|
|
|
// Edge canvas
|
|
Canvas {
|
|
id: canvas
|
|
anchors.fill: parent
|
|
|
|
onPaint: {
|
|
let ctx = getContext("2d")
|
|
ctx.clearRect(0, 0, width, height)
|
|
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.4)
|
|
ctx.lineWidth = 2
|
|
|
|
if (!peerListFn) return
|
|
|
|
for (let i = 0; i < sessions.length; i++) {
|
|
let sid = sessions[i].id
|
|
let peers = peerListFn(sid)
|
|
let fromPos = nodePositions[sid]
|
|
if (!fromPos) continue
|
|
|
|
for (let j = 0; j < peers.length; j++) {
|
|
let toPos = nodePositions[peers[j]]
|
|
if (!toPos) continue
|
|
|
|
ctx.beginPath()
|
|
ctx.moveTo(fromPos.x, fromPos.y)
|
|
ctx.lineTo(toPos.x, toPos.y)
|
|
ctx.stroke()
|
|
|
|
// Arrow head
|
|
let dx = toPos.x - fromPos.x
|
|
let dy = toPos.y - fromPos.y
|
|
let len = Math.sqrt(dx * dx + dy * dy)
|
|
if (len < 1) continue
|
|
let ux = dx / len
|
|
let uy = dy / len
|
|
// Arrow at 30px from target (node radius)
|
|
let ax = toPos.x - ux * 30
|
|
let ay = toPos.y - uy * 30
|
|
let arrowSize = 8
|
|
ctx.beginPath()
|
|
ctx.moveTo(ax, ay)
|
|
ctx.lineTo(ax - arrowSize * ux + arrowSize * 0.5 * uy,
|
|
ay - arrowSize * uy - arrowSize * 0.5 * ux)
|
|
ctx.lineTo(ax - arrowSize * ux - arrowSize * 0.5 * uy,
|
|
ay - arrowSize * uy + arrowSize * 0.5 * ux)
|
|
ctx.closePath()
|
|
ctx.fillStyle = Qt.rgba(0.5, 0.5, 0.5, 0.6)
|
|
ctx.fill()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Session nodes
|
|
Repeater {
|
|
model: graphRoot.sessions
|
|
|
|
Rectangle {
|
|
id: nodeRect
|
|
width: 120
|
|
height: 50
|
|
radius: 8
|
|
color: palette.base
|
|
border.color: graphRoot.stateColor(modelData.state)
|
|
border.width: modelData.id === dbus.activeSessionId ? 3 : 2
|
|
|
|
x: {
|
|
let pos = graphRoot.nodePositions[modelData.id]
|
|
return pos ? pos.x - width / 2 : 0
|
|
}
|
|
y: {
|
|
let pos = graphRoot.nodePositions[modelData.id]
|
|
return pos ? pos.y - height / 2 : 0
|
|
}
|
|
|
|
// Pulse animation for active sessions
|
|
SequentialAnimation on opacity {
|
|
running: modelData.state !== "idle"
|
|
loops: Animation.Infinite
|
|
NumberAnimation { to: 0.7; duration: 600; easing.type: Easing.InOutSine }
|
|
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine }
|
|
}
|
|
opacity: modelData.state === "idle" ? 1.0 : opacity
|
|
|
|
Column {
|
|
anchors.centerIn: parent
|
|
spacing: 2
|
|
|
|
Label {
|
|
text: modelData.id
|
|
font.bold: true
|
|
font.pixelSize: 11
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
elide: Text.ElideRight
|
|
width: nodeRect.width - 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
|
|
Label {
|
|
text: modelData.state || "idle"
|
|
font.pixelSize: 9
|
|
opacity: 0.7
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
color: graphRoot.stateColor(modelData.state)
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: dbus.activeSessionId = modelData.id
|
|
cursorShape: Qt.PointingHandCursor
|
|
}
|
|
}
|
|
}
|
|
|
|
} // end graphContent Item
|
|
|
|
// Empty state
|
|
Label {
|
|
anchors.centerIn: parent
|
|
text: "No active sessions"
|
|
opacity: 0.5
|
|
visible: sessions.length === 0
|
|
}
|
|
}
|