From 28de16dbf10c4914030aa03e4b08eae3cc0e60a5 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Mon, 3 Aug 2026 16:16:43 +0200 Subject: [PATCH] fix(gui): rewrite chat block rendering for proper syntax highlighting Reworked ChatBlockModel to fix syntax highlighting issues: - Replace regex-based renderMarkdown() with line-by-line fence parsing - Code fences detected by ```lang (open) and ``` alone (close) - Prose wrapped in
with explicit palette text color - Code blocks syntax highlighted with KSyntaxHighlighting HtmlHighlighter improvements: - Add defaultColor for unhighlighted text spans - Track offset to emit gaps between highlighted tokens - Add endLine() to flush trailing unhighlighted content Remove TextEdit color property that was overriding inline styles in Qt5. --- gui/chatblockmodel.cpp | 204 +++++++++++++++++++++++++++++------------ gui/chatblockmodel.h | 17 +++- 2 files changed, 157 insertions(+), 64 deletions(-) diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index a753bc5..bac13f4 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -19,33 +19,58 @@ class HtmlHighlighter : public KSyntaxHighlighting::AbstractHighlighter { public: QString html; + QString defaultColor; // Fallback color for unhighlighted text using AbstractHighlighter::AbstractHighlighter; KSyntaxHighlighting::State processLine(const QString &line, const KSyntaxHighlighting::State &state) { m_line = line; + m_offset = 0; return highlightLine(line, state); } + void endLine() { + // Emit any remaining unhighlighted text at end of line + if (m_offset < m_line.length()) { + html += escapeHtml(m_line.mid(m_offset)); + } + } + protected: void applyFormat(int offset, int length, const KSyntaxHighlighting::Format &format) override { - const QString text = m_line.mid(offset, length).toHtmlEscaped() - .replace(" ", " ") - .replace("\t", "    "); + // Emit any unhighlighted text before this span + if (offset > m_offset) { + const QString gap = m_line.mid(m_offset, offset - m_offset); + html += escapeHtml(gap); + } + m_offset = offset + length; + + const QString text = escapeHtml(m_line.mid(offset, length)); if (format.hasTextColor(theme())) { html += QStringLiteral("") + text + QStringLiteral(""); + } else if (!defaultColor.isEmpty()) { + html += QStringLiteral("") + text + QStringLiteral(""); } else { html += text; } } private: + QString escapeHtml(const QString &text) const { + return text.toHtmlEscaped() + .replace(QStringLiteral(" "), QStringLiteral(" ")) + .replace(QStringLiteral("\t"), QStringLiteral("    ")); + } + QString m_line; + int m_offset = 0; }; // --- Static helpers --- @@ -71,6 +96,14 @@ const QRegularExpression ChatBlockModel::s_headerRe( const QRegularExpression ChatBlockModel::s_endRe( R"(^\[\[\[end\]\]\]$)"); +// Matches opening fence: ```lang or just ``` +const QRegularExpression ChatBlockModel::s_fenceOpenRe( + R"(^```([A-Za-z0-9_+.-]*).*$)"); + +// Matches closing fence: ``` on its own line +const QRegularExpression ChatBlockModel::s_fenceCloseRe( + R"(^```$)"); + ChatBlockModel::ChatBlockModel(QObject *parent) : QAbstractListModel(parent) , m_repo(new KSyntaxHighlighting::Repository()) @@ -95,19 +128,19 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const const ChatBlock &b = m_blocks[index.row()]; switch (role) { - case BlockTypeRole: - switch (b.type) { - case ChatBlock::User: return QStringLiteral("user"); - case ChatBlock::Assistant: return QStringLiteral("assistant"); - case ChatBlock::Reasoning: return QStringLiteral("reasoning"); - case ChatBlock::Call: return QStringLiteral("call"); - case ChatBlock::Tool: return QStringLiteral("tool"); -case ChatBlock::Error: return QStringLiteral("error"); - case ChatBlock::Info: return QStringLiteral("info"); - case ChatBlock::Retry: return QStringLiteral("retry"); - case ChatBlock::Interrupt: return QStringLiteral("interrupt"); - case ChatBlock::Default: return QStringLiteral("default"); - } + case BlockTypeRole: + switch (b.type) { + case ChatBlock::User: return QStringLiteral("user"); + case ChatBlock::Assistant: return QStringLiteral("assistant"); + case ChatBlock::Reasoning: return QStringLiteral("reasoning"); + case ChatBlock::Call: return QStringLiteral("call"); + case ChatBlock::Tool: return QStringLiteral("tool"); + case ChatBlock::Error: return QStringLiteral("error"); + case ChatBlock::Info: return QStringLiteral("info"); + case ChatBlock::Retry: return QStringLiteral("retry"); + case ChatBlock::Interrupt: return QStringLiteral("interrupt"); + case ChatBlock::Default: return QStringLiteral("default"); + } return {}; case BlockNameRole: return b.name; @@ -147,6 +180,9 @@ void ChatBlockModel::reset() m_blocks.clear(); m_lineBuffer.clear(); m_state = State::BlockStart; + m_inCodeFence = false; + m_fenceLanguage.clear(); + m_fenceContent.clear(); endResetModel(); emit countChanged(); } @@ -166,77 +202,123 @@ void ChatBlockModel::trimFront(int keepCount) emit countChanged(); } +QString ChatBlockModel::getDefaultTextColor() const +{ + const auto textColor = QApplication::palette().color(QPalette::Text); + return textColor.name(); +} + 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); + const QString defaultColor = getDefaultTextColor(); + + // No valid definition — just escape and return + if (!definition.isValid()) { + QString result = QStringLiteral("
");
+        result += code.toHtmlEscaped();
+        result += QStringLiteral("
"); + return result; + } + HtmlHighlighter highlighter; highlighter.setDefinition(definition); highlighter.setTheme(theme); + highlighter.defaultColor = defaultColor; KSyntaxHighlighting::State state; const QStringList lines = code.split('\n'); for (int i = 0; i < lines.size(); ++i) { state = highlighter.processLine(lines[i], state); + highlighter.endLine(); if (i + 1 < lines.size()) highlighter.html += QStringLiteral("
"); } - return QStringLiteral("
")
-        + highlighter.html + QStringLiteral("
"); + + QString result = QStringLiteral("
");
+    result += highlighter.html;
+    result += QStringLiteral("
"); + return result; } -QString ChatBlockModel::renderMarkdown(const QString &markdown) const +QString ChatBlockModel::escapeForHtml(const QString &text) const { - static const QRegularExpression fenceRe( - R"(```([A-Za-z0-9_+.-]*)[^\n]*\n([\s\S]*?)\n```(\n|$))"); + const QString defaultColor = getDefaultTextColor(); + QString result = QStringLiteral("
"); + result += text.toHtmlEscaped(); + result += QStringLiteral("
"); + return result; +} - QString result; - int cursor = 0; - auto it = fenceRe.globalMatch(markdown); - while (it.hasNext()) { - const auto match = it.next(); - // Prose before this fence: HTML-escape and wrap in pre-wrap div to - // preserve newlines and whitespace. QTextDocument::setMarkdown would - // collapse single newlines (standard Markdown behavior). - const QString prose = markdown.mid(cursor, match.capturedStart() - cursor); - if (!prose.isEmpty()) - result += QStringLiteral("
") - + prose.toHtmlEscaped() + QStringLiteral("
"); - result += highlightCode(match.captured(2), match.captured(1)); - cursor = match.capturedEnd(); - } +void ChatBlockModel::rebuildBlockHtml(int blockIndex) +{ + if (blockIndex < 0 || blockIndex >= m_blocks.size()) + return; - // 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); - if (!tail.isEmpty()) { - const auto open = openFenceRe.match(tail); - if (open.hasMatch()) { - const QString prose = tail.left(open.capturedStart()); - if (!prose.isEmpty()) - result += QStringLiteral("
") - + prose.toHtmlEscaped() + QStringLiteral("
"); - result += highlightCode(open.captured(2), open.captured(1)); + ChatBlock &block = m_blocks[blockIndex]; + QString html; + + // Parse the block content for code fences + const QStringList lines = block.content.split('\n'); + bool inFence = false; + QString fenceLang; + QStringList fenceLines; + QStringList proseLines; + + auto flushProse = [&]() { + if (!proseLines.isEmpty()) { + html += escapeForHtml(proseLines.join('\n')); + proseLines.clear(); + } + }; + + auto flushFence = [&]() { + if (!fenceLines.isEmpty()) { + html += highlightCode(fenceLines.join('\n'), fenceLang); + fenceLines.clear(); + fenceLang.clear(); + } + }; + + for (const QString &line : lines) { + if (!inFence) { + // Check for fence open + auto match = s_fenceOpenRe.match(line); + if (match.hasMatch()) { + flushProse(); + inFence = true; + fenceLang = match.captured(1); + continue; + } + proseLines.append(line); } else { - result += QStringLiteral("
") - + tail.toHtmlEscaped() + QStringLiteral("
"); + // Inside fence — check for close + if (s_fenceCloseRe.match(line).hasMatch()) { + flushFence(); + inFence = false; + continue; + } + fenceLines.append(line); } } - return result; + + // Handle unclosed fence (streaming) or remaining prose + if (inFence) { + // Still inside a fence — render what we have + html += highlightCode(fenceLines.join('\n'), fenceLang); + } else { + flushProse(); + } + + block.renderedHtml = html; } void ChatBlockModel::appendContent(const QString &line) @@ -248,7 +330,8 @@ void ChatBlockModel::appendContent(const QString &line) if (!last.content.isEmpty()) last.content += '\n'; last.content += line; - last.renderedHtml = renderMarkdown(last.content); + + rebuildBlockHtml(m_blocks.size() - 1); QModelIndex idx = index(m_blocks.size() - 1); emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole}); @@ -258,7 +341,7 @@ void ChatBlockModel::processLine(const QString &line) { switch (m_state) { case State::BlockStart: { - // [[[blockname]]] is the pre-transition header trigger. + // [[[blockname]]] starts a new block QRegularExpressionMatch match = s_headerRe.match(line); if (match.hasMatch()) { const QString blockName = match.captured(1); @@ -269,11 +352,12 @@ void ChatBlockModel::processLine(const QString &line) emit countChanged(); m_state = State::BlockEnd; } + // Lines outside blocks are ignored return; } case State::BlockEnd: - // [[[end]]] is the post-transition trigger; all other lines are Markdown. + // [[[end]]] closes the current block if (s_endRe.match(line).hasMatch()) { m_state = State::BlockStart; return; diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h index a8fa2d9..b66ca19 100644 --- a/gui/chatblockmodel.h +++ b/gui/chatblockmodel.h @@ -28,7 +28,7 @@ struct ChatBlock { Type type = Default; QString name; QString content; - QString renderedHtml; // Markdown-rendered block content + QString renderedHtml; // Rendered HTML with syntax highlighting }; class ChatBlockModel : public QAbstractListModel @@ -40,7 +40,7 @@ public: // Two-state parser for [[[blockname]]] ... [[[end]]] blocks. enum class State { BlockStart, // Waiting for a block header - BlockEnd // Collecting Markdown content until [[[end]]] + BlockEnd // Collecting content until [[[end]]] }; enum Roles { @@ -67,9 +67,11 @@ signals: private: void processLine(const QString &line); void appendContent(const QString &line); + void rebuildBlockHtml(int blockIndex); - QString renderMarkdown(const QString &markdown) const; QString highlightCode(const QString &code, const QString &language) const; + QString escapeForHtml(const QString &text) const; + QString getDefaultTextColor() const; State m_state = State::BlockStart; KSyntaxHighlighting::Repository *m_repo = nullptr; @@ -77,8 +79,15 @@ private: QList m_blocks; QString m_lineBuffer; + // Code fence state (for streaming) + bool m_inCodeFence = false; + QString m_fenceLanguage; + QString m_fenceContent; + static const QRegularExpression s_headerRe; static const QRegularExpression s_endRe; + static const QRegularExpression s_fenceOpenRe; + static const QRegularExpression s_fenceCloseRe; }; -#endif // CHATBLOCKMODEL_H \ No newline at end of file +#endif // CHATBLOCKMODEL_H