From 07232b94df51ba70cd09f9ccce3ff85bbbab346e Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 7 Aug 2026 14:59:20 +0200 Subject: [PATCH] Add PlantUML and Mermaid diagram rendering - Detect plantuml/mermaid code fences and render as diagrams - PlantUML: POST to localhost:8080/svg for SVG output - Mermaid: GET to localhost:8081/img/ with base64 for JPEG output (SVG not usable - Qt doesn't support foreignObject) - Cache rendered diagrams by content hash - Toggle between source code and rendered image view - Copy button for source text or image data - Fall back to source display on render errors - Add ClipboardHelper for image clipboard support - Clear diagram cache on session reset --- CMakeLists.txt | 6 +- gui/chatblockmodel.cpp | 295 ++++++++++++++++++++++++++++++++++++++-- gui/chatblockmodel.h | 46 ++++++- gui/clipboardhelper.cpp | 1 + gui/clipboardhelper.h | 59 ++++++++ gui/main.cpp | 3 + gui/main.qml | 256 ++++++++++++++++++++++++++-------- 7 files changed, 599 insertions(+), 67 deletions(-) create mode 100644 gui/clipboardhelper.cpp create mode 100644 gui/clipboardhelper.h diff --git a/CMakeLists.txt b/CMakeLists.txt index d26254f..a7dc83c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,7 @@ if(OLLIE_KF5) gui/chatblockmodel.cpp gui/thememanager.cpp gui/streamfsm.cpp + gui/clipboardhelper.cpp ${GUI_KF5_QRC} ) target_include_directories(ollie-gui PRIVATE @@ -92,6 +93,7 @@ if(OLLIE_KF5) Qt5::Widgets Qt5::Quick Qt5::QuickControls2 + Qt5::Network KF5::ConfigCore KF5::SyntaxHighlighting $ENV{HOME}/.local/lib/libollie9p.so @@ -102,7 +104,7 @@ if(OLLIE_KF5) else() # --- Qt6 / KF6 --- - find_package(Qt6 REQUIRED COMPONENTS Core Widgets Quick QuickControls2) + find_package(Qt6 REQUIRED COMPONENTS Core Widgets Quick QuickControls2 Network) find_package(KF6 REQUIRED COMPONENTS Runner CoreAddons) find_package(KF6Config REQUIRED) @@ -149,6 +151,7 @@ else() gui/chatblockmodel.cpp gui/thememanager.cpp gui/streamfsm.cpp + gui/clipboardhelper.cpp ) target_include_directories(ollie-gui PRIVATE ${CMAKE_SOURCE_DIR}/gui @@ -159,6 +162,7 @@ else() Qt6::Widgets Qt6::Quick Qt6::QuickControls2 + Qt6::Network KF6::ConfigCore KF6::SyntaxHighlighting $ENV{HOME}/.local/lib/libollie9p.so diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index 75da56a..bb1c0c8 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -4,6 +4,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include @@ -107,7 +113,10 @@ const QRegularExpression ChatBlockModel::s_fenceCloseRe( ChatBlockModel::ChatBlockModel(QObject *parent) : QAbstractListModel(parent) , m_repo(new KSyntaxHighlighting::Repository()) + , m_netManager(new QNetworkAccessManager(this)) { + connect(m_netManager, &QNetworkAccessManager::finished, + this, &ChatBlockModel::onDiagramReply); } ChatBlockModel::~ChatBlockModel() @@ -148,10 +157,32 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const return b.content; case RenderedHtmlRole: return b.renderedHtml; + case SegmentsRole: + return segmentsToVariant(b.segments); } return {}; } +QVariantList ChatBlockModel::segmentsToVariant(const QList &segments) const +{ + QVariantList result; + for (const BlockSegment &seg : segments) { + QVariantMap m; + m[QStringLiteral("type")] = (seg.type == BlockSegment::Text) + ? QStringLiteral("text") : QStringLiteral("diagram"); + m[QStringLiteral("html")] = seg.html; + if (seg.type == BlockSegment::Diagram) { + m[QStringLiteral("lang")] = seg.diagramLang; + m[QStringLiteral("source")] = seg.diagramSource; + m[QStringLiteral("svg")] = seg.diagramSvg; + m[QStringLiteral("error")] = seg.diagramError; + m[QStringLiteral("loading")] = seg.diagramLoading; + } + result.append(m); + } + return result; +} + QHash ChatBlockModel::roleNames() const { return { @@ -159,6 +190,7 @@ QHash ChatBlockModel::roleNames() const {BlockNameRole, "blockName"}, {ContentRole, "blockContent"}, {RenderedHtmlRole, "blockRenderedHtml"}, + {SegmentsRole, "blockSegments"}, }; } @@ -204,6 +236,9 @@ void ChatBlockModel::reset() m_inCodeFence = false; m_fenceLanguage.clear(); m_fenceContent.clear(); + m_pendingDiagrams.clear(); + m_inFlightRequests.clear(); + m_diagramCache.clear(); // Clear cache on reset to pick up fixes endResetModel(); emit countChanged(); } @@ -223,6 +258,12 @@ void ChatBlockModel::trimFront(int keepCount) emit countChanged(); } +void ChatBlockModel::clearDiagramCache() +{ + m_diagramCache.clear(); + qDebug() << "Diagram cache cleared"; +} + QString ChatBlockModel::getDefaultTextColor() const { const auto textColor = QApplication::palette().color(QPalette::Text); @@ -286,6 +327,7 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex) ChatBlock &block = m_blocks[blockIndex]; QString html; + QList segments; // Parse the block content for code fences const QStringList lines = block.content.split('\n'); @@ -296,17 +338,102 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex) auto flushProse = [&]() { if (!proseLines.isEmpty()) { - html += escapeForHtml(proseLines.join('\n')); + QString proseHtml = escapeForHtml(proseLines.join('\n')); + html += proseHtml; + + BlockSegment seg; + seg.type = BlockSegment::Text; + seg.html = proseHtml; + segments.append(seg); + proseLines.clear(); } }; - auto flushFence = [&]() { - if (!fenceLines.isEmpty()) { - html += highlightCode(fenceLines.join('\n'), fenceLang); - fenceLines.clear(); + auto flushFence = [&](bool complete) { + if (fenceLines.isEmpty()) { fenceLang.clear(); + return; } + + const QString code = fenceLines.join('\n'); + + if (isDiagramLanguage(fenceLang)) { + BlockSegment seg; + seg.type = BlockSegment::Diagram; + seg.diagramLang = fenceLang; + seg.diagramSource = code; + seg.html = highlightCode(code, fenceLang); // Source view HTML + + if (!complete) { + // Streaming — show placeholder, don't request yet + html += diagramPlaceholder(fenceLang); + seg.diagramLoading = true; + } else { + // Complete fence — check cache or request diagram + const QString cacheKey = fenceLang + QStringLiteral(":") + + QString::fromLatin1(QCryptographicHash::hash( + code.toUtf8(), QCryptographicHash::Md5).toHex()); + + if (m_diagramCache.contains(cacheKey)) { + const QByteArray cached = m_diagramCache.value(cacheKey); + if (cached.startsWith("ERROR:")) { + // Error: fall back to source code rendering (not a diagram segment) + QString codeHtml = highlightCode(code, fenceLang); + html += codeHtml; + + BlockSegment textSeg; + textSeg.type = BlockSegment::Text; + textSeg.html = codeHtml; + segments.append(textSeg); + + fenceLines.clear(); + fenceLang.clear(); + return; // Don't append diagram segment + } else { + // Check if JPEG, PNG, or SVG + if (cached.startsWith("JPEG:")) { + QByteArray jpegData = cached.mid(5); // Skip "JPEG:" prefix + html += diagramJpegHtml(jpegData); + seg.diagramSvg = QStringLiteral("data:image/jpeg;base64,") + + QString::fromLatin1(jpegData.toBase64()); + } else if (cached.startsWith("PNG:")) { + QByteArray pngData = cached.mid(4); // Skip "PNG:" prefix + html += diagramPngHtml(pngData); + seg.diagramSvg = QStringLiteral("data:image/png;base64,") + + QString::fromLatin1(pngData.toBase64()); + } else if (cached.startsWith("SVG:")) { + QByteArray svgData = cached.mid(4); // Skip "SVG:" prefix + html += diagramSvgHtml(svgData); + seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") + + QString::fromLatin1(svgData.toBase64()); + } else { + // Legacy: assume SVG if no prefix + html += diagramSvgHtml(cached); + seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") + + QString::fromLatin1(cached.toBase64()); + } + } + } else { + html += diagramPlaceholder(fenceLang); + seg.diagramLoading = true; + // Request diagram asynchronously + requestDiagram(fenceLang, code, blockIndex); + } + } + segments.append(seg); + } else { + QString codeHtml = highlightCode(code, fenceLang); + html += codeHtml; + + BlockSegment seg; + seg.type = BlockSegment::Text; + seg.html = codeHtml; + segments.append(seg); + } + + fenceLines.clear(); + fenceLang.clear(); }; for (const QString &line : lines) { @@ -323,7 +450,7 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex) } else { // Inside fence — check for close if (s_fenceCloseRe.match(line).hasMatch()) { - flushFence(); + flushFence(true); // complete inFence = false; continue; } @@ -333,13 +460,14 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex) // Handle unclosed fence (streaming) or remaining prose if (inFence) { - // Still inside a fence — render what we have - html += highlightCode(fenceLines.join('\n'), fenceLang); + // Still inside a fence — render what we have (incomplete) + flushFence(false); } else { flushProse(); } block.renderedHtml = html; + block.segments = segments; } void ChatBlockModel::appendContent(const QString &line) @@ -387,3 +515,154 @@ void ChatBlockModel::processLine(const QString &line) return; } } + +// --- Diagram rendering --- + +bool ChatBlockModel::isDiagramLanguage(const QString &lang) const +{ + const QString l = lang.toLower(); + return l == QStringLiteral("plantuml") || + l == QStringLiteral("mermaid") || + l == QStringLiteral("puml"); +} + +QString ChatBlockModel::diagramPlaceholder(const QString &lang) const +{ + const QString defaultColor = getDefaultTextColor(); + return QStringLiteral("
Loading ") + + lang.toHtmlEscaped() + QStringLiteral(" diagram...
"); +} + +QString ChatBlockModel::diagramErrorHtml(const QString &error) const +{ + return QStringLiteral("
") + + QStringLiteral("Diagram error: ") + error.toHtmlEscaped() + + QStringLiteral("
"); +} + +QString ChatBlockModel::diagramSvgHtml(const QByteArray &svg) const +{ + // Embed SVG inline as data URI + const QString base64 = QString::fromLatin1(svg.toBase64()); + return QStringLiteral(""); +} + +QString ChatBlockModel::diagramPngHtml(const QByteArray &png) const +{ + // Embed PNG inline as data URI + const QString base64 = QString::fromLatin1(png.toBase64()); + return QStringLiteral(""); +} + +QString ChatBlockModel::diagramJpegHtml(const QByteArray &jpeg) const +{ + // Embed JPEG inline as data URI + const QString base64 = QString::fromLatin1(jpeg.toBase64()); + return QStringLiteral(""); +} + +void ChatBlockModel::requestDiagram(const QString &lang, const QString &code, int blockIndex) +{ + const QString cacheKey = lang + QStringLiteral(":") + + QString::fromLatin1(QCryptographicHash::hash( + code.toUtf8(), QCryptographicHash::Md5).toHex()); + + // Already have it cached? + if (m_diagramCache.contains(cacheKey)) + return; + + // Track which blocks need this diagram + if (!m_pendingDiagrams.contains(cacheKey)) { + m_pendingDiagrams[cacheKey] = QList(); + } + if (!m_pendingDiagrams[cacheKey].contains(blockIndex)) { + m_pendingDiagrams[cacheKey].append(blockIndex); + } + + // Already requesting? + if (m_inFlightRequests.contains(cacheKey)) + return; + + m_inFlightRequests.insert(cacheKey); + + // Build request + QNetworkRequest request; + request.setAttribute(QNetworkRequest::User, cacheKey); + request.setTransferTimeout(10000); + + const QString langLower = lang.toLower(); + + if (langLower == QStringLiteral("plantuml") || langLower == QStringLiteral("puml")) { + // PlantUML server accepts POST with raw source + request.setUrl(QUrl(QStringLiteral("http://localhost:8080/svg"))); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain")); + m_netManager->post(request, code.toUtf8()); + } else if (langLower == QStringLiteral("mermaid")) { + // Mermaid-ink expects base64 in URL path, /img/ for PNG output + // (SVG uses foreignObject which Qt's SVG renderer doesn't support) + const QByteArray encoded = code.toUtf8().toBase64(); + request.setUrl(QUrl(QStringLiteral("http://localhost:8081/img/") + QString::fromLatin1(encoded))); + m_netManager->get(request); + } else { + m_inFlightRequests.remove(cacheKey); + return; + } +} + +void ChatBlockModel::onDiagramReply(QNetworkReply *reply) +{ + reply->deleteLater(); + + const QString cacheKey = reply->request().attribute(QNetworkRequest::User).toString(); + m_inFlightRequests.remove(cacheKey); + + QByteArray imageData; + bool success = false; + QString errorMsg; + + if (reply->error() == QNetworkReply::NoError) { + imageData = reply->readAll(); + + // Check for JPEG/PNG (mermaid) or SVG (plantuml) + if (imageData.startsWith("\xff\xd8\xff")) { + // JPEG image + success = true; + imageData.prepend("JPEG:"); + } else if (imageData.startsWith("\x89PNG")) { + // PNG image + success = true; + imageData.prepend("PNG:"); + } else if (imageData.contains("errorString(); + } + + // Update cache + if (success) { + m_diagramCache[cacheKey] = imageData; + } else { + // Cache error as empty data with special marker + m_diagramCache[cacheKey] = QByteArray("ERROR:") + errorMsg.toUtf8(); + } + + // Rebuild all blocks waiting for this diagram + const QList blocks = m_pendingDiagrams.take(cacheKey); + for (int blockIndex : blocks) { + if (blockIndex < m_blocks.size()) { + rebuildBlockHtml(blockIndex); + QModelIndex idx = index(blockIndex); + emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole}); + } + } +} diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h index b66ca19..cfb97ec 100644 --- a/gui/chatblockmodel.h +++ b/gui/chatblockmodel.h @@ -6,11 +6,29 @@ #include #include #include +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; namespace KSyntaxHighlighting { class Repository; } +// A segment within a block — either text or a diagram +struct BlockSegment { + enum Type { Text, Diagram }; + Type type = Text; + QString html; // For Text: rendered HTML; for Diagram: highlighted source HTML + QString diagramLang; // For Diagram: language (plantuml, mermaid) + QString diagramSource; // For Diagram: raw source code + QString diagramSvg; // For Diagram: base64 SVG data URI, or empty if loading/error + QString diagramError; // For Diagram: error message if failed + bool diagramLoading = false; // For Diagram: true if still loading +}; + struct ChatBlock { enum Type { User, @@ -28,7 +46,8 @@ struct ChatBlock { Type type = Default; QString name; QString content; - QString renderedHtml; // Rendered HTML with syntax highlighting + QString renderedHtml; // Legacy: full HTML (kept for compatibility) + QList segments; // Structured segments for interactive rendering }; class ChatBlockModel : public QAbstractListModel @@ -48,6 +67,7 @@ public: BlockNameRole, ContentRole, RenderedHtmlRole, + SegmentsRole, // QVariantList of segment maps }; explicit ChatBlockModel(QObject *parent = nullptr); @@ -60,10 +80,14 @@ public: Q_INVOKABLE void appendData(const QString &text); Q_INVOKABLE void reset(); Q_INVOKABLE void trimFront(int keepCount); + Q_INVOKABLE void clearDiagramCache(); signals: void countChanged(); +private slots: + void onDiagramReply(QNetworkReply *reply); + private: void processLine(const QString &line); void appendContent(const QString &line); @@ -72,9 +96,22 @@ private: QString highlightCode(const QString &code, const QString &language) const; QString escapeForHtml(const QString &text) const; QString getDefaultTextColor() const; + + // Diagram rendering + bool isDiagramLanguage(const QString &lang) const; + void requestDiagram(const QString &lang, const QString &code, int blockIndex); + QString diagramPlaceholder(const QString &lang) const; + QString diagramErrorHtml(const QString &error) const; + QString diagramSvgHtml(const QByteArray &svg) const; + QString diagramPngHtml(const QByteArray &png) const; + QString diagramJpegHtml(const QByteArray &jpeg) const; + + // Convert segments to QVariantList for QML + QVariantList segmentsToVariant(const QList &segments) const; State m_state = State::BlockStart; KSyntaxHighlighting::Repository *m_repo = nullptr; + QNetworkAccessManager *m_netManager = nullptr; QList m_blocks; QString m_lineBuffer; @@ -83,6 +120,13 @@ private: bool m_inCodeFence = false; QString m_fenceLanguage; QString m_fenceContent; + + // Diagram cache: hash of (lang + code) -> SVG data + QHash m_diagramCache; + // Pending requests: hash -> block indices waiting for this diagram + QHash> m_pendingDiagrams; + // Track which requests are in flight + QSet m_inFlightRequests; static const QRegularExpression s_headerRe; static const QRegularExpression s_endRe; diff --git a/gui/clipboardhelper.cpp b/gui/clipboardhelper.cpp new file mode 100644 index 0000000..d67a107 --- /dev/null +++ b/gui/clipboardhelper.cpp @@ -0,0 +1 @@ +#include "clipboardhelper.h" diff --git a/gui/clipboardhelper.h b/gui/clipboardhelper.h new file mode 100644 index 0000000..f6d7839 --- /dev/null +++ b/gui/clipboardhelper.h @@ -0,0 +1,59 @@ +#ifndef CLIPBOARDHELPER_H +#define CLIPBOARDHELPER_H + +#include +#include +#include +#include +#include + +class ClipboardHelper : public QObject +{ + Q_OBJECT + +public: + explicit ClipboardHelper(QObject *parent = nullptr) : QObject(parent) {} + + // Copy image from data URI (data:image/svg+xml;base64,...) to clipboard + Q_INVOKABLE void copyImage(const QString &dataUri) { + if (!dataUri.startsWith(QStringLiteral("data:image/"))) + return; + + // Parse data URI: data:image/TYPE;base64,DATA + int commaIdx = dataUri.indexOf(','); + if (commaIdx < 0) return; + + QString header = dataUri.left(commaIdx); + QString base64Data = dataUri.mid(commaIdx + 1); + QByteArray imageData = QByteArray::fromBase64(base64Data.toLatin1()); + + QClipboard *clipboard = QGuiApplication::clipboard(); + QMimeData *mimeData = new QMimeData(); + + if (header.contains(QStringLiteral("svg"))) { + // For SVG, set as both image/svg+xml and try to render as raster + mimeData->setData(QStringLiteral("image/svg+xml"), imageData); + + // Also try to load as QImage for apps that don't support SVG + QImage img; + if (img.loadFromData(imageData, "SVG")) { + mimeData->setImageData(img); + } + } else { + // PNG, JPEG, etc. + QImage img; + if (img.loadFromData(imageData)) { + mimeData->setImageData(img); + } + } + + clipboard->setMimeData(mimeData); + } + + // Copy plain text to clipboard + Q_INVOKABLE void copyText(const QString &text) { + QGuiApplication::clipboard()->setText(text); + } +}; + +#endif // CLIPBOARDHELPER_H diff --git a/gui/main.cpp b/gui/main.cpp index d416fef..d5f857f 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -8,6 +8,7 @@ #include "ollie9pclient.h" #include "chatblockmodel.h" #include "thememanager.h" +#include "clipboardhelper.h" int main(int argc, char *argv[]) { @@ -27,6 +28,7 @@ int main(int argc, char *argv[]) auto *ollie = new Ollie9pClient(&app); auto *chatModel = new ChatBlockModel(&app); auto *theme = new ThemeManager(&app); + auto *clipboard = new ClipboardHelper(&app); // Route chat stream to the block model. // Reset + reload is handled in QML's onActiveSessionIdChanged so the @@ -37,6 +39,7 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty("ollie", ollie); engine.rootContext()->setContextProperty("chatModel", chatModel); engine.rootContext()->setContextProperty("theme", theme); + engine.rootContext()->setContextProperty("clipboard", clipboard); const QUrl url(QStringLiteral("qrc:/main.qml")); diff --git a/gui/main.qml b/gui/main.qml index 021104a..ec14feb 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -1373,71 +1373,213 @@ property bool blockExpanded: { } } - // Block content is Markdown rendered by ChatBlockModel. - TextEdit { - id: blockContent + // Block content rendered as segments (text and diagrams) + Column { Layout.fillWidth: true visible: blockItem.blockExpanded - text: blockRenderedHtml || "" - textFormat: Text.RichText - readOnly: true - selectByMouse: true - persistentSelection: true - wrapMode: TextEdit.Wrap - font.family: theme.fontFamily || Qt.application.font.family - font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 - // Note: don't set 'color' here — it can override inline styles from - // syntax highlighting in Qt5. The HTML content should set colors. + spacing: 4 - // Selection colors - selectionColor: theme.highlightColor - selectedTextColor: theme.highlightedTextColor - - // Restore ListView scrolling when focus is lost - onActiveFocusChanged: { - if (!activeFocus) - chatView.interactive = true - } - - // Context menu for text operations - Menu { - id: textContextMenu - MenuItem { - text: "Copy" - enabled: blockContent.selectedText.length > 0 - onTriggered: blockContent.copy() - } - MenuItem { - text: "Select All" - onTriggered: blockContent.selectAll() - } - } - - // Mouse handling for selection (left button) - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton - propagateComposedEvents: true - cursorShape: Qt.IBeamCursor + Repeater { + model: blockSegments || [] - onPressed: function(mouse) { - chatView.interactive = false - mouse.accepted = false // Let TextEdit handle selection - } - onReleased: function(mouse) { - chatView.interactive = true - mouse.accepted = false + delegate: Loader { + width: parent.width + sourceComponent: modelData.type === "diagram" ? diagramComponent : textComponent + + property var segmentData: modelData } } + } + + // Text segment component + Component { + id: textComponent - // Context menu (right button) - separate to avoid deselection - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.RightButton - cursorShape: Qt.IBeamCursor + TextEdit { + width: parent ? parent.width : 100 + text: segmentData.html || "" + textFormat: Text.RichText + readOnly: true + selectByMouse: true + persistentSelection: true + wrapMode: TextEdit.Wrap + font.family: theme.fontFamily || Qt.application.font.family + font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 + selectionColor: theme.highlightColor + selectedTextColor: theme.highlightedTextColor - onClicked: function(mouse) { - textContextMenu.popup() + onActiveFocusChanged: { + if (!activeFocus) + chatView.interactive = true + } + + Menu { + id: textSegmentMenu + MenuItem { + text: "Copy" + enabled: parent.parent.selectedText.length > 0 + onTriggered: parent.parent.copy() + } + MenuItem { + text: "Select All" + onTriggered: parent.parent.selectAll() + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + propagateComposedEvents: true + cursorShape: Qt.IBeamCursor + onPressed: function(mouse) { + chatView.interactive = false + mouse.accepted = false + } + onReleased: function(mouse) { + chatView.interactive = true + mouse.accepted = false + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + cursorShape: Qt.IBeamCursor + onClicked: textSegmentMenu.popup() + } + } + } + + // Diagram segment component with source/image toggle + Component { + id: diagramComponent + + Rectangle { + width: parent ? parent.width : 100 + height: diagramContent.height + diagramHeader.height + 8 + color: "transparent" + border.color: theme.midColor + border.width: 1 + radius: 4 + + property bool showSource: false + + // Header with language label and toggle button + RowLayout { + id: diagramHeader + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 4 + height: 24 + + Label { + text: segmentData.lang || "diagram" + font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 11 + color: theme.textColor + opacity: 0.7 + } + + Item { Layout.fillWidth: true } + + Button { + text: parent.parent.showSource ? "📊" : "" + font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10 + implicitWidth: 32 + implicitHeight: 20 + ToolTip.visible: hovered + ToolTip.text: parent.parent.showSource ? "Show diagram" : "Show source" + onClicked: parent.parent.showSource = !parent.parent.showSource + } + + Button { + text: "📋" + font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10 + implicitWidth: 32 + implicitHeight: 20 + ToolTip.visible: hovered + ToolTip.text: parent.parent.showSource ? "Copy source" : "Copy image" + onClicked: { + if (parent.parent.showSource) { + // Copy source code as plain text + clipboard.copyText(segmentData.source) + } else if (segmentData.svg) { + // Copy image + clipboard.copyImage(segmentData.svg) + } + } + } + } + + // Content area + Item { + id: diagramContent + anchors.top: diagramHeader.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 4 + height: showSource ? diagramSourceEdit.height : diagramImage.height + + // Source view + TextEdit { + id: diagramSourceEdit + width: parent.width + visible: showSource + text: segmentData.html || "" + textFormat: Text.RichText + readOnly: true + selectByMouse: true + persistentSelection: true + wrapMode: TextEdit.Wrap + font.family: theme.fontFamily || Qt.application.font.family + font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 + selectionColor: theme.highlightColor + selectedTextColor: theme.highlightedTextColor + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + propagateComposedEvents: true + cursorShape: Qt.IBeamCursor + onPressed: function(mouse) { + chatView.interactive = false + mouse.accepted = false + } + onReleased: function(mouse) { + chatView.interactive = true + mouse.accepted = false + } + } + } + + // Image view + Column { + id: diagramImage + width: parent.width + visible: !showSource + + Rectangle { + visible: segmentData.svg && !segmentData.loading + color: "white" + width: diagramImg.width + height: diagramImg.height + + Image { + id: diagramImg + source: segmentData.svg || "" + fillMode: Image.PreserveAspectFit + width: Math.min(implicitWidth, diagramImage.width) + } + } + + Label { + visible: segmentData.loading + text: "Loading " + (segmentData.lang || "diagram") + "..." + font.italic: true + color: theme.textColor + opacity: 0.7 + } + } } } }