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("")
- + 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("