feat(gui): render PlantUML, Mermaid, and draw.io diagrams as images

- 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
This commit is contained in:
Levi Neely 2026-07-29 15:23:29 +02:00
parent b591640b65
commit 29c74b2837
5 changed files with 755 additions and 4 deletions

View File

@ -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
)

View File

@ -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
}
}
}

View File

@ -9,10 +9,18 @@
#include <KSyntaxHighlighting/Format>
#include <QGuiApplication>
#include <QClipboard>
#include <QCryptographicHash>
#include <QDesktopServices>
#include <QDir>
#include <QEventLoop>
#include <QFileInfo>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QTimer>
#include <QUrl>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QPalette>
#include <QTemporaryFile>
@ -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<unsigned char>(data[i]);
int b1 = (i + 1 < data.size()) ? static_cast<unsigned char>(data[i + 1]) : 0;
int b2 = (i + 2 < data.size()) ? static_cast<unsigned char>(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<QNetworkReply> 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("<svg"))
return false;
QFile out(svgPath);
if (!out.open(QIODevice::WriteOnly))
return false;
out.write(svg);
return true;
}
// Fetch SVG from a local Mermaid server (mermaid.ink API), returns true on success
static bool fetchFromMermaidServer(const QString &source, const QString &svgPath)
{
QByteArray encoded = source.toUtf8().toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
QUrl url(QStringLiteral("http://localhost:8081/svg/") + QString::fromLatin1(encoded));
QNetworkAccessManager nam;
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
QScopedPointer<QNetworkReply> 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("<svg"))
return false;
QFile out(svgPath);
if (!out.open(QIODevice::WriteOnly))
return false;
out.write(svg);
return true;
}
QString ChatBlockModel::diagramUrl(const QString &source, const QString &language) const
{
if (source.isEmpty()) return QString();
QString lang = language.toLower().trimmed();
// Normalize mxgraph → drawio
if (lang == QStringLiteral("mxgraph"))
lang = QStringLiteral("drawio");
if (lang != QStringLiteral("plantuml") && lang != QStringLiteral("mermaid")
&& lang != QStringLiteral("drawio"))
return QString();
// Deterministic cache path based on content hash
QByteArray hash = QCryptographicHash::hash(
(lang + "\x1f" + source).toUtf8(), QCryptographicHash::Sha256).toHex().left(16);
QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
+ QStringLiteral("/diagrams");
QDir().mkpath(cacheDir);
QString svgPath = cacheDir + "/" + QString::fromLatin1(hash) + ".svg";
// Return cached result if available
if (QFileInfo::exists(svgPath))
return QUrl::fromLocalFile(svgPath).toString();
if (lang == QStringLiteral("plantuml")) {
// Try local PlantUML server first (localhost:8080)
if (fetchFromPlantUmlServer(source, svgPath))
return QUrl::fromLocalFile(svgPath).toString();
// Fall back to CLI
QTemporaryFile input;
input.setFileTemplate(QStringLiteral("/tmp/ollie-diagram-XXXXXX.puml"));
if (!input.open())
return QString();
input.write(source.toUtf8());
input.close();
QProcess proc;
proc.setProcessChannelMode(QProcess::MergedChannels);
QString plantuml = QStandardPaths::findExecutable(QStringLiteral("plantuml"));
if (!plantuml.isEmpty()) {
proc.start(plantuml, {
QStringLiteral("-tsvg"),
QStringLiteral("-o"), cacheDir,
QStringLiteral("-filename"), QString::fromLatin1(hash),
input.fileName()
});
} else {
QString jar;
QStringList jarPaths = {
QStringLiteral("/usr/share/plantuml/plantuml.jar"),
QStringLiteral("/usr/share/java/plantuml.jar"),
QStringLiteral("/opt/plantuml/plantuml.jar"),
QDir::homePath() + QStringLiteral("/.local/share/plantuml/plantuml.jar")
};
for (const QString &p : jarPaths) {
if (QFileInfo::exists(p)) { jar = p; break; }
}
if (jar.isEmpty())
return QString();
proc.start(QStringLiteral("java"), {
QStringLiteral("-jar"), jar,
QStringLiteral("-tsvg"),
QStringLiteral("-o"), cacheDir,
QStringLiteral("-filename"), QString::fromLatin1(hash),
input.fileName()
});
}
if (!proc.waitForFinished(15000))
return QString();
// plantuml names output after input file, rename if needed
if (!QFileInfo::exists(svgPath)) {
QString baseName = QFileInfo(input.fileName()).completeBaseName();
QString actualOutput = cacheDir + "/" + baseName + ".svg";
if (QFileInfo::exists(actualOutput))
QFile::rename(actualOutput, svgPath);
}
} else if (lang == QStringLiteral("mermaid")) {
// Mermaid — try local server first (localhost:8081), fall back to mmdc CLI
if (fetchFromMermaidServer(source, svgPath))
return QUrl::fromLocalFile(svgPath).toString();
QTemporaryFile input;
input.setFileTemplate(QStringLiteral("/tmp/ollie-diagram-XXXXXX.mmd"));
if (!input.open())
return QString();
input.write(source.toUtf8());
input.close();
QString mmdc = QStandardPaths::findExecutable(QStringLiteral("mmdc"));
if (mmdc.isEmpty())
mmdc = QStandardPaths::findExecutable(QStringLiteral("mermaid"));
if (mmdc.isEmpty())
return QString();
QProcess proc;
proc.setProcessChannelMode(QProcess::MergedChannels);
proc.start(mmdc, {
QStringLiteral("-i"), input.fileName(),
QStringLiteral("-o"), svgPath,
QStringLiteral("-b"), QStringLiteral("transparent")
});
if (!proc.waitForFinished(15000))
return QString();
} else if (lang == QStringLiteral("drawio")) {
// Draw.io — try local export server (localhost:8082), fall back to drawio CLI
QNetworkAccessManager nam;
QUrl url(QStringLiteral("http://localhost:8082/"));
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
QJsonDocument body(QJsonObject{
{QStringLiteral("xml"), source},
{QStringLiteral("format"), QStringLiteral("svg")}
});
QScopedPointer<QNetworkReply> 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("<svg")) {
QFile out(svgPath);
if (out.open(QIODevice::WriteOnly)) {
out.write(svg);
serverOk = true;
}
}
}
if (!serverOk) {
// Fall back to drawio CLI
QString drawio = QStandardPaths::findExecutable(QStringLiteral("drawio"));
if (drawio.isEmpty())
drawio = QStandardPaths::findExecutable(QStringLiteral("draw.io"));
if (drawio.isEmpty())
return QString();
QTemporaryFile input;
input.setFileTemplate(QStringLiteral("/tmp/ollie-diagram-XXXXXX.drawio"));
if (!input.open())
return QString();
input.write(source.toUtf8());
input.close();
QProcess proc;
proc.setProcessChannelMode(QProcess::MergedChannels);
proc.start(drawio, {
QStringLiteral("--export"),
QStringLiteral("--format"), QStringLiteral("svg"),
QStringLiteral("--output"), svgPath,
input.fileName()
});
if (!proc.waitForFinished(30000)) // draw.io CLI can be slow (Electron)
return QString();
}
}
if (!QFileInfo::exists(svgPath))
return QString();
return QUrl::fromLocalFile(svgPath).toString();
}
bool ChatBlockModel::invalidateDiagram(const QString &source, const QString &language) const
{
if (source.isEmpty()) return false;
QString lang = language.toLower().trimmed();
if (lang == QStringLiteral("mxgraph"))
lang = QStringLiteral("drawio");
QByteArray hash = QCryptographicHash::hash(
(lang + "\x1f" + source).toUtf8(), QCryptographicHash::Sha256).toHex().left(16);
QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
+ QStringLiteral("/diagrams");
QString svgPath = cacheDir + "/" + QString::fromLatin1(hash) + ".svg";
return QFile::remove(svgPath);
}

View File

@ -31,11 +31,12 @@ struct ContentBlock {
enum Type {
Prose, // regular markdown text
Code, // fenced code block
Diff // unified diff (detected by content pattern)
Diff, // unified diff (detected by content pattern)
Diagram // mermaid/plantuml rendered as image
};
Type type;
QString language; // for Code blocks
QString language; // for Code and Diagram blocks
QString content;
};
@ -79,6 +80,8 @@ public:
const QString &workingDirectory = QString()) const;
Q_INVOKABLE bool loadEarlier(); // returns true if more blocks were loaded
Q_INVOKABLE void setReactions(const QVariantMap &reactions);
Q_INVOKABLE QString diagramUrl(const QString &source, const QString &language) const;
Q_INVOKABLE bool invalidateDiagram(const QString &source, const QString &language) const;
signals:
void rawTextChanged();

View File

@ -1482,6 +1482,7 @@ ApplicationWindow {
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
@ -1589,6 +1590,7 @@ ApplicationWindow {
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
@ -1689,6 +1691,7 @@ ApplicationWindow {
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
@ -1751,6 +1754,7 @@ ApplicationWindow {
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
@ -2095,6 +2099,214 @@ ApplicationWindow {
}
}
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: 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