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+
4578MarkdownLabel::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
76129void 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
113155void 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>" + " "
260367 + createLink (" copy" , out->codeBlocks .size () - 1 , Tr::tr (" Copy" )) + " "
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" )) + " "
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