kde/gui: render markdown tables in chat view
Add table rendering support to ChatBlockModel: - isTableLine(): detect pipe-delimited table rows - renderTable(): convert table lines to HTML <table> - renderProse(): split prose into text/table segments - incrementalAppend: handle table state transitions during streaming Tables render with header row detection (before |---|---| separator) and proper incremental updates as content streams in.
This commit is contained in:
parent
065e399444
commit
5f68871c65
|
|
@ -319,6 +319,86 @@ QString ChatBlockModel::escapeForHtml(const QString &text) const
|
|||
return result;
|
||||
}
|
||||
|
||||
bool ChatBlockModel::isTableLine(const QString &line)
|
||||
{
|
||||
const QString trimmed = line.trimmed();
|
||||
return trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.count('|') >= 2;
|
||||
}
|
||||
|
||||
QString ChatBlockModel::renderTable(const QStringList &tableLines) const
|
||||
{
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
|
||||
QString html = QStringLiteral("<table border=\"1\" cellspacing=\"0\" cellpadding=\"3\" style=\"color:")
|
||||
+ defaultColor + QStringLiteral(";\">");
|
||||
|
||||
bool pastSeparator = false;
|
||||
|
||||
for (const QString &line : tableLines) {
|
||||
const QString trimmed = line.trimmed();
|
||||
// Skip separator rows (|---|---|)
|
||||
static QRegularExpression sepRe(QStringLiteral(R"(^\|[\s:|-]+\|$)"));
|
||||
if (sepRe.match(trimmed).hasMatch()) {
|
||||
pastSeparator = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip leading/trailing |, split by |
|
||||
QString inner = trimmed;
|
||||
if (inner.startsWith('|')) inner = inner.mid(1);
|
||||
if (inner.endsWith('|')) inner.chop(1);
|
||||
const QStringList cells = inner.split('|');
|
||||
|
||||
const QString tag = pastSeparator ? QStringLiteral("td") : QStringLiteral("th");
|
||||
|
||||
html += QStringLiteral("<tr>");
|
||||
for (const QString &cell : cells) {
|
||||
html += QStringLiteral("<") + tag + QStringLiteral(">")
|
||||
+ cell.trimmed().toHtmlEscaped()
|
||||
+ QStringLiteral("</") + tag + QStringLiteral(">");
|
||||
}
|
||||
html += QStringLiteral("</tr>");
|
||||
}
|
||||
|
||||
html += QStringLiteral("</table>");
|
||||
return html;
|
||||
}
|
||||
|
||||
QString ChatBlockModel::renderProse(const QStringList &lines) const
|
||||
{
|
||||
QString html;
|
||||
QStringList textRun;
|
||||
QStringList tableRun;
|
||||
|
||||
auto flushText = [&]() {
|
||||
if (!textRun.isEmpty()) {
|
||||
html += escapeForHtml(textRun.join('\n'));
|
||||
textRun.clear();
|
||||
}
|
||||
};
|
||||
|
||||
auto flushTable = [&]() {
|
||||
if (!tableRun.isEmpty()) {
|
||||
html += renderTable(tableRun);
|
||||
tableRun.clear();
|
||||
}
|
||||
};
|
||||
|
||||
for (const QString &line : lines) {
|
||||
if (isTableLine(line)) {
|
||||
flushText();
|
||||
tableRun.append(line);
|
||||
} else {
|
||||
flushTable();
|
||||
textRun.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
flushText();
|
||||
flushTable();
|
||||
return html;
|
||||
}
|
||||
|
||||
void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
||||
{
|
||||
if (blockIndex < 0 || blockIndex >= m_blocks.size())
|
||||
|
|
@ -337,7 +417,7 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex)
|
|||
|
||||
auto flushProse = [&]() {
|
||||
if (!proseLines.isEmpty()) {
|
||||
QString proseHtml = escapeForHtml(proseLines.join('\n'));
|
||||
QString proseHtml = renderProse(proseLines);
|
||||
html += proseHtml;
|
||||
|
||||
BlockSegment seg;
|
||||
|
|
@ -460,6 +540,13 @@ void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line)
|
|||
// Check for fence open
|
||||
auto match = s_fenceOpenRe.match(line);
|
||||
if (match.hasMatch()) {
|
||||
// Flush any accumulated table into a finalized segment
|
||||
if (rs.inTable && !rs.tableLines.isEmpty()) {
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = renderTable(rs.tableLines);
|
||||
rs.tableLines.clear();
|
||||
rs.inTable = false;
|
||||
}
|
||||
// Flush any accumulated prose into a finalized text segment
|
||||
if (!rs.proseLines.isEmpty()) {
|
||||
// The last segment should already be Text with these prose lines;
|
||||
|
|
@ -479,6 +566,37 @@ void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line)
|
|||
return;
|
||||
}
|
||||
|
||||
if (isTableLine(line)) {
|
||||
// Transitioning from prose to table — flush prose, start table segment
|
||||
if (!rs.inTable) {
|
||||
if (!rs.proseLines.isEmpty())
|
||||
rs.proseLines.clear();
|
||||
rs.inTable = true;
|
||||
rs.tableLines.clear();
|
||||
rs.tableLines.append(line);
|
||||
|
||||
// Start a new segment for the table
|
||||
BlockSegment seg;
|
||||
seg.type = BlockSegment::Text;
|
||||
seg.html = renderTable(rs.tableLines);
|
||||
block.segments.append(seg);
|
||||
} else {
|
||||
// Continue accumulating table lines
|
||||
rs.tableLines.append(line);
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = renderTable(rs.tableLines);
|
||||
}
|
||||
rebuildRenderedHtmlFromSegments(block);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-table prose line
|
||||
if (rs.inTable) {
|
||||
// Table just ended — finalize the table segment, start new prose
|
||||
rs.tableLines.clear();
|
||||
rs.inTable = false;
|
||||
}
|
||||
|
||||
// Normal prose line — extend or create last text segment
|
||||
rs.proseLines.append(line);
|
||||
|
||||
|
|
@ -635,25 +753,44 @@ void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &par
|
|||
+ 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>");
|
||||
// Show partial line appended to current prose/table
|
||||
if (rs.inTable) {
|
||||
// Currently building a table — show partial line as part of the table
|
||||
if (isTableLine(partialLine)) {
|
||||
QStringList preview = rs.tableLines;
|
||||
preview.append(partialLine);
|
||||
if (!block.segments.isEmpty())
|
||||
block.segments.last().html = renderTable(preview);
|
||||
} else {
|
||||
segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n'));
|
||||
// Partial line breaks the table — just show it as escaped text after
|
||||
const QString defaultColor = getDefaultTextColor();
|
||||
const QString escapedPartial = partialLine.toHtmlEscaped();
|
||||
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);
|
||||
}
|
||||
} 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);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ struct BlockRenderState {
|
|||
QString fenceLang; // Language of the open fence
|
||||
QStringList fenceLines; // Accumulated lines in the open fence
|
||||
QStringList proseLines; // Accumulated prose lines (not yet flushed)
|
||||
QStringList tableLines; // Accumulated table lines (being built)
|
||||
bool inTable = false; // Currently accumulating table lines
|
||||
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
|
||||
|
|
@ -113,6 +115,9 @@ private:
|
|||
QString highlightCode(const QString &code, const QString &language) const;
|
||||
QString highlightCodeWrapped(const QString &innerHtml) const;
|
||||
QString escapeForHtml(const QString &text) const;
|
||||
QString renderProse(const QStringList &lines) const;
|
||||
QString renderTable(const QStringList &tableLines) const;
|
||||
static bool isTableLine(const QString &line);
|
||||
QString getDefaultTextColor() const;
|
||||
|
||||
// Diagram rendering
|
||||
|
|
|
|||
Loading…
Reference in New Issue