From 29c74b28376fe4c7cdbb2e6972b5903a7d51d0cb Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 29 Jul 2026 15:23:29 +0200 Subject: [PATCH] feat(gui): render PlantUML, Mermaid, and draw.io diagrams as images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect mermaid/plantuml/drawio/mxgraph code fences as 'diagram' sub-blocks - Render via local servers (PlantUML :8080, Mermaid :8081, draw.io :8082) - Fall back to CLI tools (plantuml, mmdc, drawio) if servers unavailable - Cache rendered SVGs in ~/.cache/*/diagrams/ keyed by content hash - Show rendered image with white background (dark theme friendly) - Collapsible source toggle (▶/▼) below rendered diagram - Middle-click to invalidate cache and re-render - Left-click opens fullscreen pannable/zoomable viewer - Fullscreen: scroll-wheel zoom, drag to pan, Esc/right-click/dbl-click to close - Both kf5 and kf6 builds --- CMakeLists.txt | 6 +- gui/ChatPane.qml | 220 +++++++++++++++++++++++++++++ gui/chatblockmodel.cpp | 314 +++++++++++++++++++++++++++++++++++++++++ gui/chatblockmodel.h | 7 +- gui/main-kf5.qml | 212 ++++++++++++++++++++++++++++ 5 files changed, 755 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 397c420..0d9a6ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,7 +39,7 @@ endif() if(OLLIE_KF5) # --- Qt5 / KF5 --- - find_package(Qt5 REQUIRED COMPONENTS Core DBus Widgets Quick Qml QuickControls2) + find_package(Qt5 REQUIRED COMPONENTS Core DBus Widgets Quick Qml QuickControls2 Network) # Override KDE_INSTALL_PLUGINDIR so kcoreaddons_add_plugin installs under # CMAKE_INSTALL_PREFIX (e.g. ~/.local/lib/qt5/plugins), not the system dir. @@ -137,6 +137,7 @@ if(OLLIE_KF5) Qt5::Quick Qt5::Qml Qt5::QuickControls2 + Qt5::Network KF5::ConfigCore KF5::SyntaxHighlighting ) @@ -150,7 +151,7 @@ if(OLLIE_KF5) else() # --- Qt6 / KF6 --- - find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick QuickControls2 Multimedia) + find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick QuickControls2 Multimedia Network) find_package(KF6StatusNotifierItem REQUIRED) find_package(KF6 REQUIRED COMPONENTS Runner Notifications CoreAddons Config) find_package(Plasma REQUIRED) @@ -240,6 +241,7 @@ else() Qt6::Quick Qt6::QuickControls2 Qt6::Multimedia + Qt6::Network KF6::ConfigCore KF6::SyntaxHighlighting ) diff --git a/gui/ChatPane.qml b/gui/ChatPane.qml index 1550302..27c0d2f 100644 --- a/gui/ChatPane.qml +++ b/gui/ChatPane.qml @@ -633,6 +633,7 @@ Item { Layout.fillWidth: true property var subBlock: modelData sourceComponent: subBlock.type === "code" ? codeSubDelegate + : subBlock.type === "diagram" ? diagramSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate : subBlock.type === "video" ? videoSubDelegate @@ -740,6 +741,7 @@ Item { Layout.fillWidth: true property var subBlock: modelData sourceComponent: subBlock.type === "code" ? codeSubDelegate + : subBlock.type === "diagram" ? diagramSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate : subBlock.type === "video" ? videoSubDelegate @@ -840,6 +842,7 @@ Item { Layout.fillWidth: true property var subBlock: modelData sourceComponent: subBlock.type === "code" ? codeSubDelegate + : subBlock.type === "diagram" ? diagramSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate : subBlock.type === "video" ? videoSubDelegate @@ -902,6 +905,7 @@ Item { Layout.fillWidth: true property var subBlock: modelData sourceComponent: subBlock.type === "code" ? codeSubDelegate + : subBlock.type === "diagram" ? diagramSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate : subBlock.type === "video" ? videoSubDelegate @@ -1277,4 +1281,220 @@ Item { } } } + + Component { + id: diagramSubDelegate + Rectangle { + implicitHeight: diagramCol.implicitHeight + color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6) + radius: 4 + border.width: 1 + border.color: palette.mid + + ColumnLayout { + id: diagramCol + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + + // Rendered diagram image (white background for dark theme readability) + Rectangle { + Layout.fillWidth: true + Layout.margins: 8 + Layout.maximumHeight: 600 + implicitHeight: diagramImg.implicitHeight > 0 ? Math.min(diagramImg.implicitHeight, 600) : 100 + color: "#ffffff" + radius: 4 + + Image { + id: diagramImg + anchors.fill: parent + anchors.margins: 4 + fillMode: Image.PreserveAspectFit + source: paneModel.diagramUrl(subBlock.content || "", subBlock.language || "") + asynchronous: true + cache: false // we manage caching ourselves + + // Loading indicator + BusyIndicator { + anchors.centerIn: parent + running: diagramImg.status === Image.Loading + visible: running + } + + // Error fallback + Label { + anchors.centerIn: parent + text: "\u26A0 Failed to load diagram" + visible: diagramImg.status === Image.Error + color: "#666" + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + cursorShape: Qt.PointingHandCursor + onClicked: function(mouse) { + if (mouse.button === Qt.MiddleButton) { + // Middle-click: invalidate cache and re-render + paneModel.invalidateDiagram(subBlock.content || "", subBlock.language || "") + diagramImg.source = "" + diagramImg.source = paneModel.diagramUrl(subBlock.content || "", subBlock.language || "") + } else { + // Left-click: open fullscreen view + diagramFullscreen.diagramSource = diagramImg.source + diagramFullscreen.visible = true + } + } + } + } + } + + // Collapsible source toggle + RowLayout { + Layout.fillWidth: true + Layout.margins: 4 + spacing: 4 + + Label { + id: diagramToggle + property bool expanded: false + text: (expanded ? "\u25BC" : "\u25B6") + " " + (subBlock.language || "source") + font.pixelSize: Qt.application.font.pixelSize * 0.8 + opacity: 0.6 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: diagramToggle.expanded = !diagramToggle.expanded + } + } + + Item { Layout.fillWidth: true } + + ToolButton { + icon.name: "edit-copy" + implicitWidth: 28 + implicitHeight: 28 + onClicked: paneModel.copyToClipboard(subBlock.content) + ToolTip.text: "Copy source" + ToolTip.visible: hovered + } + } + + // Collapsed source code + TextEdit { + Layout.fillWidth: true + Layout.margins: 8 + visible: diagramToggle.expanded + text: subBlock.content || "" + wrapMode: TextEdit.Wrap + textFormat: TextEdit.PlainText + font.family: "monospace" + font.pixelSize: Qt.application.font.pixelSize * 0.85 + color: palette.text + selectionColor: palette.highlight + selectedTextColor: palette.highlightedText + readOnly: true + selectByMouse: true + } + } + } + } + + // Fullscreen diagram viewer (pannable + zoomable) + Popup { + id: diagramFullscreen + property string diagramSource: "" + parent: Overlay.overlay + anchors.centerIn: parent + width: parent ? parent.width * 0.95 : 800 + height: parent ? parent.height * 0.95 : 600 + modal: true + dim: true + padding: 0 + + background: Rectangle { + color: "#ffffff" + radius: 8 + border.color: palette.mid + border.width: 1 + } + + // Close on Escape + Shortcut { + sequence: "Escape" + enabled: diagramFullscreen.visible + onActivated: diagramFullscreen.close() + } + + // Close button + ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 8 + z: 10 + icon.name: "window-close" + onClicked: diagramFullscreen.close() + } + + // Zoom reset button + ToolButton { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: 8 + z: 10 + text: Math.round(diagramZoomImg.zoomFactor * 100) + "%" + font.pixelSize: Qt.application.font.pixelSize * 0.8 + onClicked: { + diagramZoomImg.zoomFactor = 1.0 + diagramFlick.contentX = 0 + diagramFlick.contentY = 0 + } + ToolTip.text: "Reset zoom" + ToolTip.visible: hovered + } + + Flickable { + id: diagramFlick + anchors.fill: parent + anchors.margins: 8 + clip: true + contentWidth: diagramZoomImg.width + contentHeight: diagramZoomImg.height + boundsBehavior: Flickable.StopAtBounds + + Image { + id: diagramZoomImg + source: diagramFullscreen.diagramSource + fillMode: Image.PreserveAspectFit + cache: false + + property real zoomFactor: 1.0 + // sourceSize gives us intrinsic dimensions; scale from Flickable size + width: Math.max(diagramFlick.width, diagramFlick.width * zoomFactor) + height: Math.max(diagramFlick.height, diagramFlick.height * zoomFactor) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) + diagramFullscreen.close() + } + onDoubleClicked: diagramFullscreen.close() + onWheel: function(wheel) { + let factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87 + let newZoom = Math.max(0.25, Math.min(10.0, diagramZoomImg.zoomFactor * factor)) + diagramZoomImg.zoomFactor = newZoom + } + } + } + + onAboutToShow: { + diagramZoomImg.zoomFactor = 1.0 + diagramFlick.contentX = 0 + diagramFlick.contentY = 0 + } + } } diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index b74abaa..6a875ef 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -9,10 +9,18 @@ #include #include #include +#include #include #include +#include +#include +#include +#include +#include +#include #include #include +#include #include #include #include @@ -379,6 +387,9 @@ QVariantList ChatBlockModel::parseSubBlocks(const QString &content) const if (lang == "diff" || (lang.isEmpty() && (code.startsWith("--- ") || code.startsWith("diff --git")))) { type = "diff"; + } else if (lang.toLower() == "mermaid" || lang.toLower() == "plantuml" + || lang.toLower() == "drawio" || lang.toLower() == "mxgraph") { + type = "diagram"; } QVariantMap m; @@ -742,3 +753,306 @@ bool ChatBlockModel::executeInTerminal(const QString &code, const QString &langu } return false; } + +// PlantUML text encoding: custom base64 alphabet +static QByteArray plantumlEncode64(const QByteArray &data) +{ + static const char table[] = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"; + + QByteArray result; + result.reserve((data.size() + 2) / 3 * 4); + + for (int i = 0; i < data.size(); i += 3) { + int b0 = static_cast(data[i]); + int b1 = (i + 1 < data.size()) ? static_cast(data[i + 1]) : 0; + int b2 = (i + 2 < data.size()) ? static_cast(data[i + 2]) : 0; + + result.append(table[b0 >> 2]); + result.append(table[((b0 & 0x3) << 4) | (b1 >> 4)]); + result.append(table[((b1 & 0xF) << 2) | (b2 >> 6)]); + result.append(table[b2 & 0x3F]); + } + + return result; +} + +// Encode PlantUML source for server URL: deflate + custom base64 +static QByteArray plantumlEncodeSource(const QString &source) +{ + QByteArray utf8 = source.toUtf8(); + QByteArray compressed = qCompress(utf8); + // qCompress: 4-byte Qt length prefix + 2-byte zlib header + deflate + 4-byte adler32 + if (compressed.size() > 6) { + compressed = compressed.mid(6); // strip Qt prefix + zlib header + if (compressed.size() > 4) + compressed.chop(4); // strip adler32 + } + return plantumlEncode64(compressed); +} + +// Fetch SVG from a local PlantUML server, returns true on success +static bool fetchFromPlantUmlServer(const QString &source, const QString &svgPath) +{ + QByteArray encoded = plantumlEncodeSource(source); + QUrl url(QStringLiteral("http://localhost:8080/svg/") + QString::fromLatin1(encoded)); + + QNetworkAccessManager nam; + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + + QScopedPointer reply(nam.get(req)); + + // Synchronous wait with timeout + QEventLoop loop; + QObject::connect(reply.data(), &QNetworkReply::finished, &loop, &QEventLoop::quit); + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(5000); // 5s timeout + loop.exec(); + + if (!reply->isFinished() || reply->error() != QNetworkReply::NoError) + return false; + + QByteArray svg = reply->readAll(); + if (svg.isEmpty() || !svg.contains(" reply(nam.get(req)); + + QEventLoop loop; + QObject::connect(reply.data(), &QNetworkReply::finished, &loop, &QEventLoop::quit); + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(5000); + loop.exec(); + + if (!reply->isFinished() || reply->error() != QNetworkReply::NoError) + return false; + + QByteArray svg = reply->readAll(); + if (svg.isEmpty() || !svg.contains(" reply(nam.post(req, body.toJson(QJsonDocument::Compact))); + + QEventLoop loop; + QObject::connect(reply.data(), &QNetworkReply::finished, &loop, &QEventLoop::quit); + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(10000); // 10s — draw.io can be slow + loop.exec(); + + bool serverOk = false; + if (reply->isFinished() && reply->error() == QNetworkReply::NoError) { + QByteArray svg = reply->readAll(); + if (!svg.isEmpty() && svg.contains(" 0 ? Math.min(diagramImg.implicitHeight, 600) : 100 + color: "#ffffff" + radius: 4 + + Image { + id: diagramImg + anchors.fill: parent + anchors.margins: 4 + fillMode: Image.PreserveAspectFit + source: chatModel.diagramUrl(subBlock.content || "", subBlock.language || "") + asynchronous: true + cache: false + + BusyIndicator { + anchors.centerIn: parent + running: diagramImg.status === Image.Loading + visible: running + } + + Label { + anchors.centerIn: parent + text: "\u26A0 Failed to load diagram" + visible: diagramImg.status === Image.Error + color: "#666" + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + cursorShape: Qt.PointingHandCursor + onClicked: function(mouse) { + if (mouse.button === Qt.MiddleButton) { + chatModel.invalidateDiagram(subBlock.content || "", subBlock.language || "") + diagramImg.source = "" + diagramImg.source = chatModel.diagramUrl(subBlock.content || "", subBlock.language || "") + } else { + diagramFullscreen.diagramSource = diagramImg.source + diagramFullscreen.visible = true + } + } + } + } + } + + // Collapsible source toggle + RowLayout { + Layout.fillWidth: true + Layout.margins: 4 + spacing: 4 + + Label { + id: diagramToggle + property bool expanded: false + text: (expanded ? "\u25BC" : "\u25B6") + " " + (subBlock.language || "source") + font.pixelSize: Qt.application.font.pixelSize * 0.8 + opacity: 0.6 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: diagramToggle.expanded = !diagramToggle.expanded + } + } + + Item { Layout.fillWidth: true } + + ToolButton { + icon.name: "edit-copy" + implicitWidth: 28 + implicitHeight: 28 + onClicked: chatModel.copyToClipboard(subBlock.content) + ToolTip.text: "Copy source" + ToolTip.visible: hovered + } + } + + // Collapsed source code + TextEdit { + Layout.fillWidth: true + Layout.margins: 8 + visible: diagramToggle.expanded + text: subBlock.content || "" + wrapMode: TextEdit.Wrap + textFormat: TextEdit.PlainText + font.family: "monospace" + font.pixelSize: Qt.application.font.pixelSize * 0.85 + color: palette.text + selectionColor: palette.highlight + selectedTextColor: palette.highlightedText + readOnly: true + selectByMouse: true + } + } + } + } + + // Fullscreen diagram viewer (pannable + zoomable) + Popup { + id: diagramFullscreen + property string diagramSource: "" + parent: Overlay.overlay + anchors.centerIn: parent + width: parent ? parent.width * 0.95 : 800 + height: parent ? parent.height * 0.95 : 600 + modal: true + dim: true + padding: 0 + + background: Rectangle { + color: "#ffffff" + radius: 8 + border.color: palette.mid + border.width: 1 + } + + Shortcut { + sequence: "Escape" + enabled: diagramFullscreen.visible + onActivated: diagramFullscreen.close() + } + + ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 8 + z: 10 + icon.name: "window-close" + onClicked: diagramFullscreen.close() + } + + ToolButton { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: 8 + z: 10 + text: Math.round(diagramZoomImg.zoomFactor * 100) + "%" + font.pixelSize: Qt.application.font.pixelSize * 0.8 + onClicked: { + diagramZoomImg.zoomFactor = 1.0 + diagramFlick.contentX = 0 + diagramFlick.contentY = 0 + } + ToolTip.text: "Reset zoom" + ToolTip.visible: hovered + } + + Flickable { + id: diagramFlick + anchors.fill: parent + anchors.margins: 8 + clip: true + contentWidth: diagramZoomImg.width + contentHeight: diagramZoomImg.height + boundsBehavior: Flickable.StopAtBounds + + Image { + id: diagramZoomImg + source: diagramFullscreen.diagramSource + fillMode: Image.PreserveAspectFit + cache: false + + property real zoomFactor: 1.0 + width: Math.max(diagramFlick.width, diagramFlick.width * zoomFactor) + height: Math.max(diagramFlick.height, diagramFlick.height * zoomFactor) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) + diagramFullscreen.close() + } + onDoubleClicked: diagramFullscreen.close() + onWheel: function(wheel) { + var factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87 + var newZoom = Math.max(0.25, Math.min(10.0, diagramZoomImg.zoomFactor * factor)) + diagramZoomImg.zoomFactor = newZoom + } + } + } + + onAboutToShow: { + diagramZoomImg.zoomFactor = 1.0 + diagramFlick.contentX = 0 + diagramFlick.contentY = 0 + } + } + // --- Process Manager Window --- Window { id: processManagerWindow