This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/kate/chat_renderer.cpp

715 lines
27 KiB
C++

#include "chat_renderer.h"
#include <KSyntaxHighlighting/Repository>
#include <KSyntaxHighlighting/Definition>
#include <KSyntaxHighlighting/Theme>
#include <KSyntaxHighlighting/AbstractHighlighter>
#include <KSyntaxHighlighting/State>
#include <KSyntaxHighlighting/Format>
#include <QApplication>
#include <QPalette>
#include <QTextCursor>
#include <QScrollBar>
#include <QRegularExpression>
// --- HTML Highlighter ---
class HtmlHighlighter : public KSyntaxHighlighting::AbstractHighlighter
{
public:
QString html;
using AbstractHighlighter::AbstractHighlighter;
KSyntaxHighlighting::State processLine(const QString &line, const KSyntaxHighlighting::State &state)
{
m_currentLine = line;
return highlightLine(line, state);
}
protected:
void applyFormat(int offset, int length, const KSyntaxHighlighting::Format &format) override
{
QString text = m_currentLine.mid(offset, length).toHtmlEscaped();
if (format.hasTextColor(theme())) {
QColor fg = format.textColor(theme());
html += QStringLiteral("<span style=\"color:") + fg.name() + QStringLiteral("\">") + text + QStringLiteral("</span>");
} else {
html += text;
}
}
private:
QString m_currentLine;
};
// --- ChatRenderer ---
ChatRenderer::ChatRenderer()
: m_repo(new KSyntaxHighlighting::Repository())
{
}
ChatRenderer::~ChatRenderer()
{
delete m_repo;
}
void ChatRenderer::detectTheme() const
{
m_dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
}
QString ChatRenderer::escapeHtml(const QString &text) const
{
return text.toHtmlEscaped();
}
// --- Block Parsing ---
QList<ChatBlock> ChatRenderer::parseBlocks(const QString &text) const
{
QList<ChatBlock> blocks;
if (text.isEmpty()) return blocks;
static const QRegularExpression headerRe(
R"(^\[\[\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\]\]\](.*)$)",
QRegularExpression::MultilineOption);
static const QRegularExpression endRe(
R"(^\[\[\[end\]\]\]\s*$)",
QRegularExpression::MultilineOption);
auto it = headerRe.globalMatch(text);
QList<QPair<int, QRegularExpressionMatch>> headers;
while (it.hasNext()) {
auto match = it.next();
headers.append({match.capturedStart(), match});
}
if (headers.isEmpty()) {
ChatBlock b;
b.type = ChatBlock::Assistant;
b.content = text;
blocks.append(b);
return blocks;
}
for (int i = 0; i < headers.size(); ++i) {
const auto &[pos, match] = headers[i];
QString typeStr = match.captured(1);
QString name = match.captured(2);
QString inlineContent = match.captured(3);
int contentStart = match.capturedEnd();
// Find the [[[end]]] marker after this header (before next header)
int searchEnd = (i + 1 < headers.size()) ? headers[i + 1].first : text.size();
QString segment = text.mid(contentStart, searchEnd - contentStart);
auto endMatch = endRe.match(segment);
int contentEnd;
if (endMatch.hasMatch()) {
contentEnd = contentStart + endMatch.capturedStart();
} else {
// No [[[end]]] found — treat rest until next header as content (streaming)
contentEnd = searchEnd;
}
QString content;
if (typeStr == "info" && !inlineContent.trimmed().isEmpty()) {
content = inlineContent.trimmed();
} else {
content = text.mid(contentStart, contentEnd - contentStart);
if (content.startsWith('\n'))
content = content.mid(1);
while (content.endsWith("\n\n"))
content.chop(1);
}
ChatBlock b;
if (typeStr == "user") b.type = ChatBlock::User;
else if (typeStr == "assistant") b.type = ChatBlock::Assistant;
else if (typeStr == "reasoning") b.type = ChatBlock::Reasoning;
else if (typeStr == "call") b.type = ChatBlock::Call;
else if (typeStr == "tool") b.type = ChatBlock::Tool;
else if (typeStr == "error") b.type = ChatBlock::Error;
else if (typeStr == "info") b.type = ChatBlock::Info;
b.name = name;
b.content = content;
blocks.append(b);
}
return blocks;
}
QList<SubBlock> ChatRenderer::parseSubBlocks(const QString &content) const
{
QList<SubBlock> result;
if (content.isEmpty()) return result;
static const QRegularExpression interruptRe(
R"(<system-user-interruption>\n?(.*?)\n?</system-user-interruption>)",
QRegularExpression::DotMatchesEverythingOption);
static const QRegularExpression fenceRe(
R"(^```(\w*)[ \t]*([^\n]*)\n((?:(?!^```).)*?)\n^```\s*$)",
QRegularExpression::MultilineOption | QRegularExpression::DotMatchesEverythingOption);
// Find all interrupt tags
int lastEnd = 0;
auto intIt = interruptRe.globalMatch(content);
QList<QPair<int, QRegularExpressionMatch>> interrupts;
while (intIt.hasNext()) {
auto match = intIt.next();
interrupts.append({match.capturedStart(), match});
}
// Helper: parse a segment for code fences and prose
auto parseSegment = [&](const QString &segment) {
if (segment.trimmed().isEmpty()) return;
int segLastEnd = 0;
auto it = fenceRe.globalMatch(segment);
while (it.hasNext()) {
auto match = it.next();
if (match.capturedStart() > segLastEnd) {
QString prose = segment.mid(segLastEnd, match.capturedStart() - segLastEnd).trimmed();
if (!prose.isEmpty()) {
SubBlock sb;
sb.type = SubBlock::Prose;
sb.content = prose;
result.append(sb);
}
}
QString lang = match.captured(1);
QString meta = match.captured(2).trimmed();
QString code = match.captured(3);
SubBlock sb;
if (lang == "diff" || (lang.isEmpty() &&
(code.startsWith("--- ") || code.startsWith("diff --git")))) {
sb.type = SubBlock::Diff;
} else {
sb.type = SubBlock::Code;
}
sb.language = lang;
sb.fenceMeta = meta;
sb.content = code;
result.append(sb);
segLastEnd = match.capturedEnd();
}
if (segLastEnd < segment.size()) {
QString prose = segment.mid(segLastEnd).trimmed();
if (!prose.isEmpty()) {
SubBlock sb;
sb.type = SubBlock::Prose;
sb.content = prose;
result.append(sb);
}
}
};
if (interrupts.isEmpty()) {
parseSegment(content);
} else {
for (int i = 0; i < interrupts.size(); ++i) {
const auto &[pos, match] = interrupts[i];
if (pos > lastEnd) {
parseSegment(content.mid(lastEnd, pos - lastEnd));
}
SubBlock sb;
sb.type = SubBlock::Interrupt;
sb.content = match.captured(1).trimmed();
result.append(sb);
lastEnd = match.capturedEnd();
}
if (lastEnd < content.size()) {
parseSegment(content.mid(lastEnd));
}
}
if (result.isEmpty()) {
SubBlock sb;
sb.type = SubBlock::Prose;
sb.content = content;
result.append(sb);
}
return result;
}
// --- HTML Rendering ---
QString ChatRenderer::highlightCode(const QString &code, const QString &language) const
{
auto def = m_repo->definitionForName(language);
if (!def.isValid())
def = m_repo->definitionForFileName("file." + language);
if (!def.isValid())
return QStringLiteral("<pre style=\"margin:4px 0;white-space:pre-wrap;\">") + escapeHtml(code) + QStringLiteral("</pre>");
auto theme = m_dark
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
HtmlHighlighter builder;
builder.setDefinition(def);
builder.setTheme(theme);
KSyntaxHighlighting::State state;
const QStringList lines = code.split('\n');
for (int i = 0; i < lines.size(); ++i) {
state = builder.processLine(lines[i], state);
if (i < lines.size() - 1)
builder.html += '\n';
}
return QStringLiteral("<pre style=\"margin:4px 0;white-space:pre-wrap;\">") + builder.html + QStringLiteral("</pre>");
}
QString ChatRenderer::renderDiff(const QString &diff) const
{
QString html = QStringLiteral("<pre style=\"margin:4px 0;white-space:pre-wrap;\">");
const QStringList lines = diff.split('\n');
for (const QString &line : lines) {
if (line.startsWith('+')) {
html += QStringLiteral("<span style=\"color:#4caf50;\">") + escapeHtml(line) + QStringLiteral("</span>\n");
} else if (line.startsWith('-')) {
html += QStringLiteral("<span style=\"color:#f44336;\">") + escapeHtml(line) + QStringLiteral("</span>\n");
} else if (line.startsWith("@@")) {
html += QStringLiteral("<span style=\"color:#2196f3;\">") + escapeHtml(line) + QStringLiteral("</span>\n");
} else {
html += escapeHtml(line) + '\n';
}
}
html += QStringLiteral("</pre>");
return html;
}
QString ChatRenderer::renderProse(const QString &prose) const
{
// Simple markdown-ish rendering: bold, italic, inline code, links
QString html = escapeHtml(prose);
// Inline code: `code`
static const QRegularExpression inlineCode("`([^`]+)`");
html.replace(inlineCode, QStringLiteral("<code style=\"background:") +
(m_dark ? QStringLiteral("#333") : QStringLiteral("#f0f0f0")) +
QStringLiteral(";padding:1px 4px;border-radius:3px;\">\\1</code>"));
// Bold: **text** or __text__
static const QRegularExpression bold(R"(\*\*(.+?)\*\*)");
html.replace(bold, QStringLiteral("<b>\\1</b>"));
static const QRegularExpression bold2(R"(__(.+?)__)");
html.replace(bold2, QStringLiteral("<b>\\1</b>"));
// Italic: *text* or _text_
static const QRegularExpression italic(R"((?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*))");
html.replace(italic, QStringLiteral("<i>\\1</i>"));
// Bullet points: lines starting with - or *
static const QRegularExpression bullet(R"(^(\s*)[\-\*]\s)", QRegularExpression::MultilineOption);
html.replace(bullet, QStringLiteral("\\1• "));
// Newlines to <br>
html.replace('\n', QStringLiteral("<br>"));
return html;
}
QString ChatRenderer::subBlockToHtml(const SubBlock &sub, bool preserveWhitespace) const
{
QString codeBg = m_dark ? QStringLiteral("#1e1e2e") : QStringLiteral("#f5f5f5");
QString codeBorder = m_dark ? QStringLiteral("#444") : QStringLiteral("#ddd");
switch (sub.type) {
case SubBlock::Code: {
QString langLabel;
if (!sub.language.isEmpty()) {
langLabel = QStringLiteral(
"<div style=\"font-size:small;color:gray;padding:2px 8px;\">") +
escapeHtml(sub.language) + QStringLiteral("</div>");
}
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:6px 0;\">"
"<tr><td style=\"background:") + codeBg +
QStringLiteral(";border:1px solid ") + codeBorder +
QStringLiteral(";padding:8px;\">") +
langLabel + highlightCode(sub.content, sub.language) +
QStringLiteral("</td></tr></table>");
}
case SubBlock::Diff: {
// Store diff and render a clickable link to open in Kate's diff viewer
if (m_diffStorage) {
int idx = m_diffStorage->size();
m_diffStorage->append(sub.content);
// Count lines for display
int adds = 0, dels = 0;
const QStringList lines = sub.content.split('\n');
for (const QString &line : lines) {
if (line.startsWith('+') && !line.startsWith("+++")) adds++;
else if (line.startsWith('-') && !line.startsWith("---")) dels++;
}
QString stats;
if (adds > 0) stats += QStringLiteral("<span style=\"color:#4caf50;\">+%1</span> ").arg(adds);
if (dels > 0) stats += QStringLiteral("<span style=\"color:#f44336;\">-%1</span>").arg(dels);
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:6px 0;\">"
"<tr><td style=\"background:") + codeBg +
QStringLiteral(";border:1px solid ") + codeBorder +
QStringLiteral(";padding:8px 12px;\">"
"<span style=\"font-size:small;color:gray;\">▶ diff</span> ") +
stats +
QStringLiteral("<br><a href=\"ollie-diff:///") + QString::number(idx) +
QStringLiteral("\" style=\"color:#2196f3;text-decoration:underline;\">"
"↗ Open in Diff Viewer</a>"
"</td></tr></table>");
}
// Fallback: render inline
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:6px 0;\">"
"<tr><td style=\"background:") + codeBg +
QStringLiteral(";border:1px solid ") + codeBorder +
QStringLiteral(";padding:8px;\">"
"<div style=\"font-size:small;color:gray;padding:2px 8px;\">diff</div>") +
renderDiff(sub.content) +
QStringLiteral("</td></tr></table>");
}
case SubBlock::Prose:
if (preserveWhitespace) {
return QStringLiteral("<pre style=\"margin:4px 0;white-space:pre-wrap;font-family:monospace;font-size:small;\">") +
escapeHtml(sub.content) + QStringLiteral("</pre>");
}
return QStringLiteral("<div style=\"margin:4px 0;line-height:1.4;\">") +
renderProse(sub.content) + QStringLiteral("</div>");
case SubBlock::Interrupt: {
QString interruptBg = m_dark ? QStringLiteral("#3d2e00") : QStringLiteral("#fff8e1");
QString interruptBorder = QStringLiteral("#ffc107");
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:8px 0;\">"
"<tr><td style=\"background:") + interruptBg +
QStringLiteral(";padding:8px 12px;border-left:3px solid ") + interruptBorder +
QStringLiteral(";\">"
"<div style=\"font-size:small;color:") + interruptBorder +
QStringLiteral(";font-weight:bold;\">⚠ user interrupt</div>"
"<div style=\"margin:4px 0;line-height:1.4;\">") +
renderProse(sub.content) +
QStringLiteral("</div></td></tr></table>");
}
}
return {};
}
QString ChatRenderer::blockToHtml(const ChatBlock &block) const
{
detectTheme();
QString userBg = m_dark ? QStringLiteral("#1a3a5c") : QStringLiteral("#e3f2fd");
QString assistantBg = m_dark ? QStringLiteral("#2a2a2a") : QStringLiteral("#fafafa");
QString errorBg = m_dark ? QStringLiteral("#3d1515") : QStringLiteral("#ffebee");
QString metaBg = m_dark ? QStringLiteral("#1a1a2e") : QStringLiteral("#f5f5f5");
QString metaColor = m_dark ? QStringLiteral("#888") : QStringLiteral("#666");
QString borderColor = m_dark ? QStringLiteral("#444") : QStringLiteral("#e0e0e0");
switch (block.type) {
case ChatBlock::User: {
QList<SubBlock> subs = parseSubBlocks(block.content);
QString inner;
for (const auto &sub : subs)
inner += subBlockToHtml(sub);
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:8px 0;\">"
"<tr><td width=\"15%\"></td>"
"<td style=\"background:") + userBg +
QStringLiteral(";padding:10px 14px;border:1px solid ") + borderColor +
QStringLiteral(";\">") + inner +
QStringLiteral("</td></tr>"
"<tr><td></td><td align=\"right\"><span style=\"font-size:small;color:gray;\">You</span></td></tr>"
"</table>");
}
case ChatBlock::Assistant: {
QList<SubBlock> subs = parseSubBlocks(block.content);
QString inner;
QString lastProse; // track preceding prose for file path extraction
QString lastFilePath; // carry forward across code blocks
for (const auto &sub : subs) {
if (sub.type == SubBlock::Prose) {
lastProse = sub.content;
}
inner += subBlockToHtml(sub);
// Add "Apply" button for code blocks in assistant messages
if (sub.type == SubBlock::Code && m_applyStorage && !sub.content.trimmed().isEmpty()) {
// Parse fence metadata for /path:startLine-endLine
static const QRegularExpression fenceMetaRe(
R"((/[^\s:]+)(?::(\d+)(?:-(\d+))?)?)");
QString filePath;
int startLine = -1, endLine = -1;
auto metaMatch = fenceMetaRe.match(sub.fenceMeta);
if (metaMatch.hasMatch()) {
filePath = metaMatch.captured(1);
lastFilePath = filePath;
if (!metaMatch.captured(2).isEmpty())
startLine = metaMatch.captured(2).toInt();
if (!metaMatch.captured(3).isEmpty())
endLine = metaMatch.captured(3).toInt();
else if (startLine > 0)
endLine = startLine; // single line
} else {
// Fall back to extracting path from preceding prose
static const QRegularExpression filePathRe(
R"((?:`|\b)(/[^\s:`]+\.\w+)(?:`|\b))");
auto proseMatch = filePathRe.match(lastProse);
if (proseMatch.hasMatch()) {
filePath = proseMatch.captured(1);
lastFilePath = filePath;
} else {
filePath = lastFilePath;
}
}
int idx = m_applyStorage->size();
ApplyBlock ab;
ab.filePath = filePath;
ab.code = sub.content;
ab.startLine = startLine;
ab.endLine = endLine;
m_applyStorage->append(ab);
QString rangeStr;
if (startLine > 0 && endLine > 0) {
rangeStr = QStringLiteral(":%1-%2").arg(startLine).arg(endLine);
}
QString applyLabel = filePath.isEmpty()
? QStringLiteral("✎ Apply")
: QStringLiteral("✎ Apply to ") + escapeHtml(filePath.section('/', -1)) + rangeStr;
inner += QStringLiteral(
"<div style=\"margin:-4px 0 6px 0;\">"
"<a href=\"ollie-apply:///") + QString::number(idx) +
QStringLiteral("\" style=\"color:#4caf50;text-decoration:none;"
"font-size:small;padding:2px 8px;"
"border:1px solid #4caf50;border-radius:3px;\">") +
applyLabel + QStringLiteral("</a></div>");
}
}
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:8px 0;\">"
"<tr><td style=\"background:") + assistantBg +
QStringLiteral(";padding:10px 14px;border:1px solid ") + borderColor +
QStringLiteral(";\">") + inner +
QStringLiteral("</td><td width=\"5%\"></td></tr>"
"<tr><td><span style=\"font-size:small;color:gray;\">Ollie</span></td><td></td></tr>"
"</table>");
}
case ChatBlock::Reasoning: {
// Show abbreviated with dimmed styling
QString abbreviated = block.content.left(200);
if (block.content.size() > 200)
abbreviated += QStringLiteral("...");
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:4px 0;\">"
"<tr><td style=\"background:") + metaBg +
QStringLiteral(";padding:6px 10px;border-left:3px solid ") + metaColor +
QStringLiteral(";\">"
"<div style=\"font-size:small;color:") + metaColor +
QStringLiteral(";\">▸ reasoning</div>"
"<pre style=\"margin:4px 0;white-space:pre-wrap;color:") + metaColor +
QStringLiteral(";font-size:small;\">") + escapeHtml(abbreviated) +
QStringLiteral("</pre></td></tr></table>");
}
case ChatBlock::Call: {
QString label = QStringLiteral("⚡ call");
if (!block.name.isEmpty())
label += QStringLiteral(": ") + escapeHtml(block.name);
// Render full call args with sub-block parsing (preserve whitespace)
QList<SubBlock> subs = parseSubBlocks(block.content);
QString inner;
for (const auto &sub : subs)
inner += subBlockToHtml(sub, true);
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:4px 0;\">"
"<tr><td style=\"background:") + metaBg +
QStringLiteral(";padding:6px 10px;border-left:3px solid #ff9800;\">"
"<div style=\"font-size:small;color:#ff9800;\">") + label +
QStringLiteral("</div>") + inner +
QStringLiteral("</td></tr></table>");
}
case ChatBlock::Tool: {
QString label = QStringLiteral("◆ tool");
if (!block.name.isEmpty())
label += QStringLiteral(": ") + escapeHtml(block.name);
// Render full tool output with sub-block parsing (preserve whitespace)
QList<SubBlock> subs = parseSubBlocks(block.content);
QString inner;
for (const auto &sub : subs)
inner += subBlockToHtml(sub, true);
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:4px 0;\">"
"<tr><td style=\"background:") + metaBg +
QStringLiteral(";padding:6px 10px;border-left:3px solid ") + metaColor +
QStringLiteral(";\">"
"<div style=\"font-size:small;color:") + metaColor +
QStringLiteral(";\">") + label +
QStringLiteral("</div>") + inner +
QStringLiteral("</td></tr></table>");
}
case ChatBlock::Error: {
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:6px 0;\">"
"<tr><td style=\"background:") + errorBg +
QStringLiteral(";padding:8px 12px;border-left:3px solid #e53935;\">"
"<pre style=\"margin:0;white-space:pre-wrap;color:#e53935;\">") +
escapeHtml(block.content) +
QStringLiteral("</pre></td></tr></table>");
}
case ChatBlock::Info: {
return QStringLiteral(
"<div style=\"margin:4px 0;font-style:italic;color:") + metaColor +
QStringLiteral(";font-size:small;\">") +
escapeHtml(block.content) + QStringLiteral("</div>");
}
}
return {};
}
// --- Full Render ---
void ChatRenderer::render(QTextEdit *view, const QString &rawText)
{
detectTheme();
// Clear storage before re-render (blocks get re-indexed)
if (m_diffStorage)
m_diffStorage->clear();
if (m_applyStorage)
m_applyStorage->clear();
m_allBlocks = parseBlocks(rawText);
// Only render the tail window
m_renderStart = qMax(0, m_allBlocks.size() - m_windowSize);
QString html = QStringLiteral(
"<html><body style=\"font-family:sans-serif;font-size:medium;\">");
// Indicate truncation if there are older blocks
if (m_renderStart > 0) {
html += QStringLiteral(
"<div id=\"ollie-load-more\" style=\"text-align:center;padding:8px;"
"color:gray;font-size:small;font-style:italic;\">↑ "
"Scroll up to load earlier messages (%1 more) ↑</div>")
.arg(m_renderStart);
}
for (int i = m_renderStart; i < m_allBlocks.size(); ++i)
html += blockToHtml(m_allBlocks[i]);
html += QStringLiteral("</body></html>");
auto *sb = view->verticalScrollBar();
bool atBottom = (sb->maximum() <= 0) || (sb->value() >= sb->maximum() - 4);
int savedPos = sb->value();
// Block signals to prevent intermediate scroll events during setHtml
sb->blockSignals(true);
view->setHtml(html);
if (atBottom) {
// Move cursor to end and ensure visible — this forces layout and
// scrolls reliably, unlike sb->setValue(sb->maximum()) which can
// read a stale maximum before relayout completes.
QTextCursor cursor = view->textCursor();
cursor.movePosition(QTextCursor::End);
view->setTextCursor(cursor);
view->ensureCursorVisible();
} else {
// Restore exact scroll position — clamp to new maximum
sb->setValue(qMin(savedPos, sb->maximum()));
}
sb->blockSignals(false);
m_lastRawText = rawText;
m_lastBlockCount = m_allBlocks.size();
}
bool ChatRenderer::renderIncremental(QTextEdit *view, const QString &rawText)
{
// If text is just appended, try incremental
if (!rawText.startsWith(m_lastRawText) || m_lastRawText.isEmpty()) {
render(view, rawText);
return false;
}
// Full re-render (the HTML approach doesn't support true incremental well
// since setHtml replaces everything). For performance with typical chat
// sizes this is fine — QTextEdit handles it efficiently.
render(view, rawText);
return true;
}
int ChatRenderer::prependOlder(QTextEdit *view, int count)
{
if (m_renderStart <= 0)
return 0;
detectTheme();
int loadFrom = qMax(0, m_renderStart - count);
int loaded = m_renderStart - loadFrom;
if (loaded <= 0)
return 0;
m_renderStart = loadFrom;
// Re-render all currently-visible blocks (now including the older ones)
auto *sb = view->verticalScrollBar();
int oldMax = sb->maximum();
int oldVal = sb->value();
sb->blockSignals(true);
QString html = QStringLiteral(
"<html><body style=\"font-family:sans-serif;font-size:medium;\">");
if (m_renderStart > 0) {
html += QStringLiteral(
"<div id=\"ollie-load-more\" style=\"text-align:center;padding:8px;"
"color:gray;font-size:small;font-style:italic;\">↑ "
"Scroll up to load earlier messages (%1 more) ↑</div>")
.arg(m_renderStart);
}
for (int i = m_renderStart; i < m_allBlocks.size(); ++i)
html += blockToHtml(m_allBlocks[i]);
html += QStringLiteral("</body></html>");
view->setHtml(html);
// Restore scroll position offset by the added content height
int newMax = sb->maximum();
int addedHeight = newMax - oldMax;
sb->setValue(oldVal + addedHeight);
sb->blockSignals(false);
return loaded;
}