Skip to content

Commit 1599b25

Browse files
committed
Replace QLabel with QTextBrowser in MarkdownLabel
QLabel would have the whole markdown rendered as html, which becomes troublesome when the markdown grows to multiple pages. With QTextBrowser after every heading the markdown is considered "done" and only the rest of the markdown is considered dirty. The conversion between html to QTextBrowser internals is more expensive than in a QLabel, but sections splitting will make up for it.
1 parent 145a511 commit 1599b25

File tree

3 files changed

+209
-75
lines changed

3 files changed

+209
-75
lines changed

llamachatmessage.cpp

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ void ChatMessage::buildUI()
6161
m_bubble = new QLabel(this);
6262

6363
m_markdownLabel = new MarkdownLabel(this);
64-
m_markdownLabel->setWordWrap(true);
6564
connect(m_markdownLabel, &MarkdownLabel::copyToClipboard, this, &ChatMessage::onCopyToClipboard);
6665
connect(m_markdownLabel, &MarkdownLabel::saveToFile, this, &ChatMessage::onSaveToDisk);
6766

@@ -293,11 +292,13 @@ void ChatMessage::applyStyleSheet()
293292
setAttribute(Qt::WA_StyledBackground, true);
294293

295294
setStyleSheet(replaceThemeColorNamesWithRGBNames(R"(
296-
QLabel#BubbleUser {
295+
QTextBrowser#BubbleUser {
297296
background: Token_Background_Muted;
298297
border-radius: 8px;
299298
}
300-
QLabel#BubbleAssistant {
299+
QTextBrowser#BubbleAssistant {
300+
background: Token_Background_Default;
301+
border-radius: 8px;
301302
}
302303
303304
QToolButton {

llamamarkdownwidget.cpp

Lines changed: 189 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include <QFile>
44
#include <QList>
55
#include <QResizeEvent>
6+
#include <QTextBlock>
67
#include <QTextDocumentFragment>
78
#include <QToolTip>
89

@@ -42,24 +43,76 @@ static KSyntaxHighlighting::Definition definitionForName(const QString &name)
4243
return highlightRepository()->definitionForName(name);
4344
}
4445

46+
class HoverFilter : public QObject
47+
{
48+
Q_OBJECT
49+
public:
50+
explicit HoverFilter(QObject *parent = nullptr)
51+
: QObject(parent)
52+
{}
53+
bool eventFilter(QObject *obj, QEvent *event)
54+
{
55+
if (event->type() == QEvent::MouseMove) {
56+
QMouseEvent *me = static_cast<QMouseEvent *>(event);
57+
QTextEdit *te = qobject_cast<QTextEdit *>(obj->parent());
58+
if (!te)
59+
return QObject::eventFilter(obj, event);
60+
61+
QTextCursor cur = te->cursorForPosition(me->pos());
62+
if (!cur.isNull()) {
63+
QTextCharFormat fmt = cur.charFormat();
64+
if (fmt.isAnchor()) {
65+
QString url = fmt.anchorHref();
66+
emit linkHovered(url);
67+
return true; // we handled it
68+
}
69+
}
70+
}
71+
return QObject::eventFilter(obj, event);
72+
}
73+
74+
signals:
75+
void linkHovered(const QString &link);
76+
};
77+
4578
MarkdownLabel::MarkdownLabel(QWidget *parent)
46-
: QLabel(parent)
79+
: QTextBrowser(parent)
4780
{
4881
setTextInteractionFlags(Qt::TextBrowserInteraction);
4982

50-
connect(this, &QLabel::linkHovered, this, [](const QString &link) {
51-
auto idx = link.indexOf(":");
52-
QString command = link.left(idx);
83+
setReadOnly(true);
84+
setOpenLinks(false);
85+
setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
86+
87+
auto policy = QSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
88+
policy.setHeightForWidth(true);
89+
setSizePolicy(policy);
90+
91+
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
92+
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
93+
94+
connect(document(), &QTextDocument::contentsChanged, this, &MarkdownLabel::updateGeometry);
95+
96+
viewport()->setMouseTracking(true);
97+
98+
HoverFilter *hoverFilter = new HoverFilter(this);
99+
viewport()->installEventFilter(hoverFilter);
100+
101+
connect(hoverFilter, &HoverFilter::linkHovered, this, [](const QString &link) {
102+
const int idx = link.indexOf(':');
103+
const QString command = link.left(idx);
53104

54105
if (command == "copy")
55106
QToolTip::showText(QCursor::pos(), Tr::tr("Copy the code below to Clipboard"));
56107
else if (command == "save")
57108
QToolTip::showText(QCursor::pos(), Tr::tr("Save the code below into a file on disk"));
58109
});
59-
connect(this, &QLabel::linkActivated, this, [this](const QString &link) {
60-
auto idx = link.indexOf(":");
61-
QString command = link.left(idx);
62-
int codeBlockIndex = link.mid(idx + 1).toInt();
110+
111+
connect(this, &QTextBrowser::anchorClicked, this, [this](const QUrl &url) {
112+
const QString link = url.toString();
113+
const int idx = link.indexOf(':');
114+
const QString command = link.left(idx);
115+
const int codeBlockIndex = link.mid(idx + 1).toInt();
63116

64117
if (command == "copy") {
65118
if (codeBlockIndex >= 0 && codeBlockIndex < m_data.codeBlocks.size())
@@ -75,47 +128,33 @@ MarkdownLabel::MarkdownLabel(QWidget *parent)
75128

76129
void MarkdownLabel::setMarkdown(const QString &markdown)
77130
{
78-
const QStringList lines = markdown.split("\n");
79-
const QString longestLine = *std::ranges::max_element(lines, std::less{}, &QString::length);
80-
QFontMetrics fm(font());
81-
const int longestLineWidth = fm.horizontalAdvance(longestLine) + 21;
82-
83-
if (minimumWidth() == 0 || lines.size() < 5 && minimumWidth() < longestLineWidth)
84-
setMinimumWidth(fm.horizontalAdvance(longestLine) + 21);
85-
86-
auto html = markdownToHtml(markdown);
87-
if (html) {
88-
setStyleSheet();
89-
90-
// Inject the optional CSS before the </head> tag
91-
if (!m_css.isEmpty()) {
92-
const QByteArray headEnd = "</head>";
93-
int pos = html.value().indexOf(headEnd);
94-
if (pos != -1)
95-
html.value().insert(pos, QByteArray("\n<style>" + m_css + "</style>"));
96-
else
97-
html.value().prepend(QByteArray("<style>" + m_css + "</style>"));
98-
}
131+
// Make sure the widget’s minimum width is large enough for the
132+
// longest line in the markdown.
133+
adjustMinimumWidth(markdown);
99134

100-
setText(QString::fromUtf8(html.value()));
101-
setTextFormat(Qt::RichText);
135+
setStyleSheet();
102136

103-
// emit rendered(html);
104-
// QFile htmlFile("rendered.html");
105-
// if (htmlFile.open(QFile::ReadWrite)) {
106-
// htmlFile.write(html.value().toUtf8());
107-
// }
108-
} else {
109-
qWarning() << "Markdown conversion failed:" << html.error();
137+
// Render the markdown to a new `Data` instance.
138+
auto result = markdownToHtml(markdown);
139+
if (!result) { // error handling – keep widget in a sane state
140+
qWarning() << "Markdown rendering failed:" << result.error();
141+
return;
110142
}
143+
Data newData = std::move(result.value());
144+
145+
// Update the document: delete obsolete sections and insert the new ones
146+
updateDocumentHtmlSections(newData);
147+
148+
// Store the new data and finish.
149+
m_data = std::move(newData);
150+
151+
document()->setTextWidth(viewport()->width());
152+
updateGeometry();
111153
}
112154

113155
void MarkdownLabel::setStyleSheet()
114156
{
115-
if (!m_css.isEmpty())
116-
return;
117-
118-
m_css = replaceThemeColorNamesWithRGBNames(R"##(
157+
QString css = replaceThemeColorNamesWithRGBNames(R"##(
119158
hr {
120159
margin: 10px 0;
121160
background-color: Token_Foreground_Muted;
@@ -201,25 +240,93 @@ void MarkdownLabel::setStyleSheet()
201240
font-size: medium;
202241
}
203242
204-
)##")
205-
.toUtf8();
243+
)##");
244+
245+
document()->setDefaultStyleSheet(css);
246+
}
247+
248+
void MarkdownLabel::resizeEvent(QResizeEvent *event)
249+
{
250+
QTextEdit::resizeEvent(event); // keep normal behaviour
251+
document()->setTextWidth(viewport()->width()); // re‑wrap at new width
252+
updateGeometry(); // notify layout
206253
}
207254

208-
void MarkdownLabel::paintEvent(QPaintEvent *ev)
255+
int MarkdownLabel::heightForWidth(int w) const
209256
{
210-
return QLabel::paintEvent(ev);
257+
// Ask the document what height it needs for w px
258+
// (this does not change the widget’s real geometry)
259+
QTextDocument *doc = const_cast<QTextDocument *>(document());
260+
doc->setTextWidth(w);
261+
return qRound(doc->size().height());
211262
}
212263

213-
Utils::expected<QByteArray, QString> MarkdownLabel::markdownToHtml(const QString &markdown)
264+
void MarkdownLabel::adjustMinimumWidth(const QString &markdown)
265+
{
266+
const QStringList lines = markdown.split('\n');
267+
const QString longestLine = *std::ranges::max_element(lines, std::less{}, &QString::length);
268+
QFontMetrics fm(font());
269+
const int longestLineWidth = fm.horizontalAdvance(longestLine) + 10;
270+
271+
if (minimumWidth() == 0 || (lines.size() < 5 && minimumWidth() < longestLineWidth))
272+
setMinimumWidth(longestLineWidth);
273+
}
274+
275+
int MarkdownLabel::commonPrefixLength(const QList<QByteArray> &a, const QList<QByteArray> &b) const
276+
{
277+
const int n = std::min(a.size(), b.size());
278+
int i = 0;
279+
while (i < n && a[i] == b[i])
280+
++i;
281+
return i;
282+
}
283+
284+
void MarkdownLabel::removeHtmlSection(int index)
285+
{
286+
QTextCursor cur(document());
287+
const auto &range = m_insertedHtmlSection[index];
288+
cur.setPosition(range.first);
289+
cur.setPosition(range.second, QTextCursor::KeepAnchor);
290+
cur.removeSelectedText();
291+
}
292+
293+
void MarkdownLabel::insertHtmlSection(const QByteArray &html, int index)
294+
{
295+
QTextCursor cur(document());
296+
cur.movePosition(QTextCursor::End);
297+
int start = cur.position();
298+
299+
insertHtml(QString::fromUtf8(html));
300+
301+
int end = cur.position();
302+
m_insertedHtmlSection[index] = {start, end};
303+
}
304+
305+
void MarkdownLabel::updateDocumentHtmlSections(const Data &newData)
306+
{
307+
const auto &oldSections = m_data.outputHtmlSections;
308+
const auto &newSections = newData.outputHtmlSections;
309+
const int common = commonPrefixLength(oldSections, newSections);
310+
311+
// Delete sections that are no longer needed (from the end).
312+
for (int i = oldSections.size() - 1; i >= common; --i)
313+
removeHtmlSection(i);
314+
315+
// Insert the new sections that appear after the common prefix.
316+
for (int i = common; i < newSections.size(); ++i)
317+
insertHtmlSection(newSections[i], i);
318+
}
319+
320+
Utils::expected<MarkdownLabel::Data, QString> MarkdownLabel::markdownToHtml(const QString &markdown)
214321
{
215322
if (markdown.isEmpty())
216323
return {};
217324

218325
// md4c expects UTF‑8 data
219326
QByteArray md = markdown.toUtf8();
220327

221-
m_data = {};
222-
m_data.output_html.reserve(md.size() * 4); // heuristic
328+
Data out;
329+
out.outputHtml.reserve(md.size() * 4); // heuristic
223330

224331
// md4c output callback
225332
auto append_cb = [](const MD_CHAR *data, MD_SIZE length, void *user_data) -> void {
@@ -253,24 +360,24 @@ Utils::expected<QByteArray, QString> MarkdownLabel::markdownToHtml(const QString
253360
};
254361

255362
auto insertSourceFileCopySave = [&]() {
256-
out->output_html.append("<tr>");
257-
out->output_html.append(
363+
out->outputHtml.append("<tr>");
364+
out->outputHtml.append(
258365
"<th class=\"copy-save-links\"><span style=\"font-size:small\">"
259366
+ out->codeBlocks.last().fileName.value().toUtf8() + "</span>" + "&nbsp;&nbsp;"
260367
+ createLink("copy", out->codeBlocks.size() - 1, Tr::tr("Copy")) + "&nbsp;&nbsp;"
261368
+ createLink("save", out->codeBlocks.size() - 1, Tr::tr("Save")) + "</th>");
262-
out->output_html.append("</tr>\n");
263-
out->output_html.append("<tr><td>\n");
369+
out->outputHtml.append("</tr>\n");
370+
out->outputHtml.append("<tr><td>\n");
264371
};
265372

266373
auto insertCopySave = [&]() {
267-
out->output_html.append("<tr>");
268-
out->output_html.append(
374+
out->outputHtml.append("<tr>");
375+
out->outputHtml.append(
269376
"<th class=\"copy-save-links\">"
270377
+ createLink("copy", out->codeBlocks.size() - 1, Tr::tr("Copy")) + "&nbsp;&nbsp;"
271378
+ createLink("save", out->codeBlocks.size() - 1, Tr::tr("Save")) + "</th>");
272-
out->output_html.append("</tr>\n");
273-
out->output_html.append("<tr><td>\n");
379+
out->outputHtml.append("</tr>\n");
380+
out->outputHtml.append("<tr><td>\n");
274381
};
275382

276383
auto processOneLine = [&]() {
@@ -279,7 +386,7 @@ Utils::expected<QByteArray, QString> MarkdownLabel::markdownToHtml(const QString
279386

280387
if (out->awaitingNewLine) {
281388
out->codeBlocks.last().hightlightedCode.append("<br>");
282-
out->output_html.append("<br>");
389+
out->outputHtml.append("<br>");
283390
out->awaitingNewLine = false;
284391
}
285392

@@ -291,9 +398,20 @@ Utils::expected<QByteArray, QString> MarkdownLabel::markdownToHtml(const QString
291398
}
292399

293400
out->codeBlocks.last().hightlightedCode.append(highlightedLine);
294-
out->output_html.append(highlightedLine.toUtf8());
401+
out->outputHtml.append(highlightedLine.toUtf8());
295402
};
296403

404+
// Break the output into logical sections, this way we could cache some of the output
405+
// in the QTextBrowser's document
406+
if (line == "<h1>" || line == "<h2>" || line == "<h3>" || line == "<h4>" || line == "<h5>"
407+
|| line == "<h6>" || line == "<br>\n") {
408+
if (!out->outputHtml.isEmpty()) {
409+
out->outputHtml.append("<br>");
410+
out->outputHtmlSections << out->outputHtml;
411+
out->outputHtml.clear();
412+
}
413+
}
414+
297415
if (line == "<pre><code" && out->state == Data::NormalHtml) {
298416
out->state = Data::PreCode;
299417
CodeBlock c;
@@ -319,10 +437,10 @@ Utils::expected<QByteArray, QString> MarkdownLabel::markdownToHtml(const QString
319437
} else if (line == "</code></pre>\n") {
320438
out->state = Data::NormalHtml;
321439
out->awaitingNewLine = false;
322-
out->output_html.append("</td></tr></table>\n");
440+
out->outputHtml.append("</td></tr></table>\n");
323441
} else if (out->state == Data::PreCodeEndTag) {
324442
out->state = Data::Code;
325-
out->output_html.append("<table class=\"codeblock\">\n");
443+
out->outputHtml.append("<table class=\"codeblock\">\n");
326444

327445
static const QRegularExpression
328446
cxxAndBashFileNameRegex(R"(^\s*(?:\/\/|#)\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9]+).*$)",
@@ -366,20 +484,27 @@ Utils::expected<QByteArray, QString> MarkdownLabel::markdownToHtml(const QString
366484
return;
367485
}
368486

369-
out->output_html.append(data, length);
487+
out->outputHtml.append(data, length);
370488
};
371489

372490
// Render Markdown to HTML
373491
int rc = md_html(reinterpret_cast<const MD_CHAR *>(md.constData()),
374492
static_cast<MD_SIZE>(md.size()),
375493
append_cb,
376-
reinterpret_cast<void *>(&m_data),
494+
reinterpret_cast<void *>(&out),
377495
MD_DIALECT_GITHUB,
378496
0);
379497

380498
if (rc != 0)
381499
return Utils::make_unexpected(QString("md4c failed to render"));
382500

383-
return m_data.output_html;
501+
if (!out.outputHtml.isEmpty()) {
502+
out.outputHtmlSections << out.outputHtml;
503+
out.outputHtml.clear();
504+
}
505+
506+
return out;
384507
}
385508
} // namespace LlamaCpp
509+
510+
#include "llamamarkdownwidget.moc"

0 commit comments

Comments
 (0)