From b50a2e01b259bbed6a21b34d1ccca59b2c45e33d Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:48:00 +0200 Subject: [PATCH] fix source block rendering --- gui/chatblockmodel.cpp | 116 +++++++++++++++++++++++++++++++++++++++-- gui/chatblockmodel.h | 8 +++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index 957a0df..d14f910 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -2,7 +2,51 @@ #include #include +#include +#include +#include +#include +#include +#include +#include +#include + + +// --- Syntax highlighting --- + +class HtmlHighlighter : public KSyntaxHighlighting::AbstractHighlighter +{ +public: + QString html; + using AbstractHighlighter::AbstractHighlighter; + + KSyntaxHighlighting::State processLine(const QString &line, + const KSyntaxHighlighting::State &state) + { + m_line = line; + return highlightLine(line, state); + } + +protected: + void applyFormat(int offset, int length, + const KSyntaxHighlighting::Format &format) override + { + const QString text = m_line.mid(offset, length).toHtmlEscaped() + .replace(" ", " ") + .replace("\t", "    "); + if (format.hasTextColor(theme())) { + html += QStringLiteral("") + text + QStringLiteral(""); + } else { + html += text; + } + } + +private: + QString m_line; +}; // --- Static helpers --- @@ -29,9 +73,15 @@ const QRegularExpression ChatBlockModel::s_endRe( ChatBlockModel::ChatBlockModel(QObject *parent) : QAbstractListModel(parent) + , m_repo(new KSyntaxHighlighting::Repository()) { } +ChatBlockModel::~ChatBlockModel() +{ + delete m_repo; +} + int ChatBlockModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) return 0; @@ -112,11 +162,71 @@ void ChatBlockModel::trimFront(int keepCount) emit countChanged(); } +QString ChatBlockModel::highlightCode(const QString &code, const QString &language) const +{ + auto definition = m_repo->definitionForName(language); + if (!definition.isValid()) + definition = m_repo->definitionForFileName(QStringLiteral("file.") + language); + if (!definition.isValid()) + return QStringLiteral("
")
+            + code.toHtmlEscaped() + QStringLiteral("
"); + + const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5; + const auto theme = dark + ? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme) + : m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme); + + HtmlHighlighter highlighter; + highlighter.setDefinition(definition); + highlighter.setTheme(theme); + + KSyntaxHighlighting::State state; + const QStringList lines = code.split('\n'); + for (int i = 0; i < lines.size(); ++i) { + state = highlighter.processLine(lines[i], state); + if (i + 1 < lines.size()) + highlighter.html += QStringLiteral("
"); + } + return QStringLiteral("
")
+        + highlighter.html + QStringLiteral("
"); +} + QString ChatBlockModel::renderMarkdown(const QString &markdown) const { - QTextDocument document; - document.setMarkdown(markdown); - return document.toHtml(); + static const QRegularExpression fenceRe( + R"(```([A-Za-z0-9_+.-]*)[^\n]*\n([\s\S]*?)```)"); + + QString result; + int cursor = 0; + auto it = fenceRe.globalMatch(markdown); + while (it.hasNext()) { + const auto match = it.next(); + QTextDocument prose; + prose.setMarkdown(markdown.mid(cursor, match.capturedStart() - cursor)); + result += prose.toHtml(); + result += highlightCode(match.captured(2), match.captured(1)); + cursor = match.capturedEnd(); + } + + // During streaming, the closing fence may not have arrived yet. Do not + // send the incomplete code block through Markdown, which collapses its + // newlines into prose spacing. + static const QRegularExpression openFenceRe( + R"(^```([A-Za-z0-9_+.-]*)[^\n]*\n([\s\S]*)$)", + QRegularExpression::MultilineOption); + const auto tail = markdown.mid(cursor); + const auto open = openFenceRe.match(tail); + if (open.hasMatch()) { + QTextDocument prose; + prose.setMarkdown(tail.left(open.capturedStart())); + result += prose.toHtml(); + result += highlightCode(open.captured(2), open.captured(1)); + } else { + QTextDocument prose; + prose.setMarkdown(tail); + result += prose.toHtml(); + } + return result; } void ChatBlockModel::appendContent(const QString &line) diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h index 1c4dbfe..a8fa2d9 100644 --- a/gui/chatblockmodel.h +++ b/gui/chatblockmodel.h @@ -4,8 +4,13 @@ #include #include #include +#include #include +namespace KSyntaxHighlighting { +class Repository; +} + struct ChatBlock { enum Type { User, @@ -46,6 +51,7 @@ public: }; explicit ChatBlockModel(QObject *parent = nullptr); + ~ChatBlockModel() override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; @@ -63,8 +69,10 @@ private: void appendContent(const QString &line); QString renderMarkdown(const QString &markdown) const; + QString highlightCode(const QString &code, const QString &language) const; State m_state = State::BlockStart; + KSyntaxHighlighting::Repository *m_repo = nullptr; QList m_blocks; QString m_lineBuffer;