gui: incremental rendering for chat blocks (O(n) → O(1) per line)

Previously, every streamed line triggered a full rebuild of the
block's HTML: reparsing all content, re-running syntax highlighting
on every code fence from the start. For a block with n lines this
was O(n) per line = O(n²) total.

Now appendContent() uses incrementalAppend() which:
- Prose: escapes only the new line and appends to the segment HTML
- Code fences: saves KSyntaxHighlighting::State between lines and
  only highlights the new line (O(1) per line)
- Fence close: finalizes segment, requests diagrams as before
- Partial line display: uses cached state, no full re-highlight

Full rebuildBlockHtml() is retained only for diagram reply callbacks
where the entire segment needs reconstruction.

Also adds BlockRenderState to ChatBlock for tracking incremental
parse position, highlight state, and accumulated HTML.
This commit is contained in:
Levi Neely 2026-08-09 00:12:22 +02:00
parent 022980bc40
commit 93eba4442d
2 changed files with 290 additions and 20 deletions

View File

@ -208,22 +208,22 @@ void ChatBlockModel::appendData(const QString &text)
// For streaming: update current block with partial line buffer content // For streaming: update current block with partial line buffer content
// so the UI shows text as it arrives, not just after newlines. // so the UI shows text as it arrives, not just after newlines.
if (!m_lineBuffer.isEmpty() && !m_blocks.isEmpty() && m_state == State::BlockEnd) { if (!m_lineBuffer.isEmpty() && !m_blocks.isEmpty() && m_state == State::BlockEnd) {
ChatBlock &last = m_blocks.last(); int blockIndex = m_blocks.size() - 1;
// Store the committed content length so we can update partial content ChatBlock &last = m_blocks[blockIndex];
QString fullContent = last.content;
if (!fullContent.isEmpty() && !m_lineBuffer.isEmpty()) // Save current segment state, render with partial line, then restore
fullContent += '\n'; QList<BlockSegment> savedSegments = last.segments;
fullContent += m_lineBuffer; QString savedHtml = last.renderedHtml;
// Temporarily set content with partial line for rendering renderLastSegmentPartial(blockIndex, m_lineBuffer);
QString savedContent = last.content;
last.content = fullContent; // Emit the partial-updated view
rebuildBlockHtml(m_blocks.size() - 1); QModelIndex idx = index(blockIndex);
last.content = savedContent; // Restore — partial line not committed yet
// But emit with the rendered HTML that includes partial content
QModelIndex idx = index(m_blocks.size() - 1);
emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole}); emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole});
// Restore — partial line not committed yet
last.segments = savedSegments;
last.renderedHtml = savedHtml;
} }
} }
@ -311,6 +311,11 @@ QString ChatBlockModel::highlightCode(const QString &code, const QString &langua
return result; return result;
} }
QString ChatBlockModel::highlightCodeWrapped(const QString &innerHtml) const
{
return QStringLiteral("<pre style=\"white-space:pre-wrap;\">") + innerHtml + QStringLiteral("</pre>");
}
QString ChatBlockModel::escapeForHtml(const QString &text) const QString ChatBlockModel::escapeForHtml(const QString &text) const
{ {
const QString defaultColor = getDefaultTextColor(); const QString defaultColor = getDefaultTextColor();
@ -468,6 +473,8 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
block.renderedHtml = html; block.renderedHtml = html;
block.segments = segments; block.segments = segments;
// Reset incremental state to match the full rebuild
block.renderState = BlockRenderState();
} }
void ChatBlockModel::appendContent(const QString &line) void ChatBlockModel::appendContent(const QString &line)
@ -475,17 +482,259 @@ void ChatBlockModel::appendContent(const QString &line)
if (m_blocks.isEmpty()) if (m_blocks.isEmpty())
return; return;
ChatBlock &last = m_blocks.last(); int blockIndex = m_blocks.size() - 1;
ChatBlock &last = m_blocks[blockIndex];
if (!last.content.isEmpty()) if (!last.content.isEmpty())
last.content += '\n'; last.content += '\n';
last.content += line; last.content += line;
rebuildBlockHtml(m_blocks.size() - 1); incrementalAppend(blockIndex, line);
QModelIndex idx = index(m_blocks.size() - 1); QModelIndex idx = index(blockIndex);
emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole}); emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole});
} }
void ChatBlockModel::rebuildRenderedHtmlFromSegments(ChatBlock &block)
{
QString html;
for (const BlockSegment &seg : block.segments)
html += seg.html;
block.renderedHtml = html;
}
void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line)
{
ChatBlock &block = m_blocks[blockIndex];
BlockRenderState &rs = block.renderState;
rs.committedLineCount++;
if (!rs.inFence) {
// Check for fence open
auto match = s_fenceOpenRe.match(line);
if (match.hasMatch()) {
// Flush any accumulated prose into a finalized text segment
if (!rs.proseLines.isEmpty()) {
// The last segment should already be Text with these prose lines;
// it was being built up incrementally. Mark it done.
rs.proseLines.clear();
}
rs.inFence = true;
rs.fenceLang = match.captured(1);
rs.fenceLines.clear();
// Add a new Text segment for the code fence (will be rebuilt each line)
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = highlightCode(QString(), rs.fenceLang);
block.segments.append(seg);
block.renderedHtml += seg.html;
return;
}
// Normal prose line — extend or create last text segment
rs.proseLines.append(line);
// Incremental: escape only the new line and append to existing segment
const QString defaultColor = getDefaultTextColor();
const QString escapedLine = line.toHtmlEscaped();
if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text
&& !rs.inFence) {
// Append to existing prose segment — strip closing </div>, add newline + content
QString &segHtml = block.segments.last().html;
// Remove trailing </div> to append
if (segHtml.endsWith(QStringLiteral("</div>"))) {
segHtml.chop(6);
segHtml += '\n' + escapedLine + QStringLiteral("</div>");
} else {
// Shouldn't happen, but fall back to full rebuild
segHtml = escapeForHtml(rs.proseLines.join('\n'));
}
} else {
// Start a new text segment
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedLine + QStringLiteral("</div>");
block.segments.append(seg);
}
// Rebuild renderedHtml from segments (concatenation is cheap)
rebuildRenderedHtmlFromSegments(block);
} else {
// Inside a fence — check for close
if (s_fenceCloseRe.match(line).hasMatch()) {
// Close the fence — finalize the segment
rs.inFence = false;
const QString code = rs.fenceLines.join('\n');
if (isDiagramLanguage(rs.fenceLang)) {
// Replace last segment with a diagram segment
BlockSegment seg;
seg.type = BlockSegment::Diagram;
seg.diagramLang = rs.fenceLang;
seg.diagramSource = code;
seg.html = highlightCode(code, rs.fenceLang);
const QString cacheKey = rs.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: keep as text segment with highlighted source
seg.type = BlockSegment::Text;
} else if (cached.startsWith("JPEG:")) {
seg.diagramSvg = QStringLiteral("data:image/jpeg;base64,") +
QString::fromLatin1(cached.mid(5).toBase64());
} else if (cached.startsWith("PNG:")) {
seg.diagramSvg = QStringLiteral("data:image/png;base64,") +
QString::fromLatin1(cached.mid(4).toBase64());
} else if (cached.startsWith("SVG:")) {
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
QString::fromLatin1(cached.mid(4).toBase64());
} else {
seg.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") +
QString::fromLatin1(cached.toBase64());
}
} else {
seg.diagramLoading = true;
requestDiagram(rs.fenceLang, code, blockIndex);
}
// Replace the in-progress fence segment
if (!block.segments.isEmpty())
block.segments.last() = seg;
else
block.segments.append(seg);
} else {
// Normal code: finalize highlighted segment
QString codeHtml = highlightCode(code, rs.fenceLang);
if (!block.segments.isEmpty())
block.segments.last().html = codeHtml;
}
rs.fenceLang.clear();
rs.fenceLines.clear();
rebuildRenderedHtmlFromSegments(block);
} else {
// Append line to fence — incrementally highlight only the new line
rs.fenceLines.append(line);
// Get highlighter definition + theme
auto definition = m_repo->definitionForName(rs.fenceLang);
if (!definition.isValid())
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
if (definition.isValid()) {
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
const auto hlTheme = dark
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
HtmlHighlighter highlighter;
highlighter.setDefinition(definition);
highlighter.setTheme(hlTheme);
highlighter.defaultColor = getDefaultTextColor();
// Add line break before new line (if not the first)
if (!rs.hlHtml.isEmpty())
rs.hlHtml += QStringLiteral("<br/>");
// Process only the new line, continuing from saved state
rs.hlState = highlighter.processLine(line, rs.hlState);
highlighter.endLine();
rs.hlHtml += highlighter.html;
if (!block.segments.isEmpty())
block.segments.last().html = highlightCodeWrapped(rs.hlHtml);
} else {
// No syntax def — just escape
if (!rs.hlHtml.isEmpty())
rs.hlHtml += '\n';
rs.hlHtml += line.toHtmlEscaped();
const QString defaultColor = getDefaultTextColor();
if (!block.segments.isEmpty())
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ rs.hlHtml + QStringLiteral("</pre>");
}
rebuildRenderedHtmlFromSegments(block);
}
}
}
void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &partialLine)
{
if (blockIndex < 0 || blockIndex >= m_blocks.size())
return;
ChatBlock &block = m_blocks[blockIndex];
BlockRenderState &rs = block.renderState;
if (rs.inFence) {
// Show partial line appended to current fence — use cached state
auto definition = m_repo->definitionForName(rs.fenceLang);
if (!definition.isValid())
definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang);
if (definition.isValid()) {
const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
const auto hlTheme = dark
? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
: m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
HtmlHighlighter highlighter;
highlighter.setDefinition(definition);
highlighter.setTheme(hlTheme);
highlighter.defaultColor = getDefaultTextColor();
// Process partial line from saved state (don't save back)
QString partialHtml = rs.hlHtml;
if (!partialHtml.isEmpty())
partialHtml += QStringLiteral("<br/>");
highlighter.processLine(partialLine, rs.hlState);
highlighter.endLine();
partialHtml += highlighter.html;
if (!block.segments.isEmpty())
block.segments.last().html = highlightCodeWrapped(partialHtml);
} else {
// No definition — simple escape
QString inner = rs.hlHtml;
if (!inner.isEmpty())
inner += '\n';
inner += partialLine.toHtmlEscaped();
const QString defaultColor = getDefaultTextColor();
if (!block.segments.isEmpty())
block.segments.last().html = QStringLiteral("<pre style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ inner + QStringLiteral("</pre>");
}
} else {
// Show partial line appended to current prose — just escape one line
const QString defaultColor = getDefaultTextColor();
const QString escapedPartial = partialLine.toHtmlEscaped();
if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text) {
// Temporarily append to the segment (caller will restore)
QString &segHtml = block.segments.last().html;
if (segHtml.endsWith(QStringLiteral("</div>"))) {
segHtml.chop(6);
segHtml += '\n' + escapedPartial + QStringLiteral("</div>");
} else {
segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n'));
}
} else {
BlockSegment seg;
seg.type = BlockSegment::Text;
seg.html = QStringLiteral("<div style=\"white-space:pre-wrap;color:") + defaultColor + QStringLiteral("\">")
+ escapedPartial + QStringLiteral("</div>");
block.segments.append(seg);
}
}
rebuildRenderedHtmlFromSegments(block);
}
void ChatBlockModel::processLine(const QString &line) void ChatBlockModel::processLine(const QString &line)
{ {
switch (m_state) { switch (m_state) {
@ -495,8 +744,10 @@ void ChatBlockModel::processLine(const QString &line)
if (match.hasMatch()) { if (match.hasMatch()) {
const QString blockName = match.captured(1); const QString blockName = match.captured(1);
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size()); beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
m_blocks.append({blockTypeFromString(blockName.section(':', 0, 0)), ChatBlock newBlock;
blockName.section(':', 1), QString(), QString()}); newBlock.type = blockTypeFromString(blockName.section(':', 0, 0));
newBlock.name = blockName.section(':', 1);
m_blocks.append(newBlock);
endInsertRows(); endInsertRows();
emit countChanged(); emit countChanged();
m_state = State::BlockEnd; m_state = State::BlockEnd;

View File

@ -3,6 +3,7 @@
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QList> #include <QList>
#include <KSyntaxHighlighting/State>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QRegularExpression> #include <QRegularExpression>
@ -29,6 +30,19 @@ struct BlockSegment {
bool diagramLoading = false; // For Diagram: true if still loading bool diagramLoading = false; // For Diagram: true if still loading
}; };
// Incremental rendering state for a block — tracks where we left off so
// appendContent() can extend the last segment without reparsing from scratch.
struct BlockRenderState {
bool inFence = false; // Currently inside a code fence
QString fenceLang; // Language of the open fence
QStringList fenceLines; // Accumulated lines in the open fence
QStringList proseLines; // Accumulated prose lines (not yet flushed)
int committedLineCount = 0; // Number of content lines already processed incrementally
// Incremental syntax highlighting state for open code fences
KSyntaxHighlighting::State hlState; // Highlighter state after last processed line
QString hlHtml; // Accumulated highlighted HTML so far (without pre wrapper)
};
struct ChatBlock { struct ChatBlock {
enum Type { enum Type {
User, User,
@ -48,6 +62,7 @@ struct ChatBlock {
QString content; QString content;
QString renderedHtml; // Legacy: full HTML (kept for compatibility) QString renderedHtml; // Legacy: full HTML (kept for compatibility)
QList<BlockSegment> segments; // Structured segments for interactive rendering QList<BlockSegment> segments; // Structured segments for interactive rendering
BlockRenderState renderState; // Incremental rendering state
}; };
class ChatBlockModel : public QAbstractListModel class ChatBlockModel : public QAbstractListModel
@ -92,8 +107,12 @@ private:
void processLine(const QString &line); void processLine(const QString &line);
void appendContent(const QString &line); void appendContent(const QString &line);
void rebuildBlockHtml(int blockIndex); void rebuildBlockHtml(int blockIndex);
void incrementalAppend(int blockIndex, const QString &line);
void renderLastSegmentPartial(int blockIndex, const QString &partialLine);
static void rebuildRenderedHtmlFromSegments(ChatBlock &block);
QString highlightCode(const QString &code, const QString &language) const; QString highlightCode(const QString &code, const QString &language) const;
QString highlightCodeWrapped(const QString &innerHtml) const;
QString escapeForHtml(const QString &text) const; QString escapeForHtml(const QString &text) const;
QString getDefaultTextColor() const; QString getDefaultTextColor() const;