feat: add markdown support for user notes (#6490)

Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
Reviewed-by: Nerixyz <nerixdev@outlook.de>
This commit is contained in:
Jacob Alexander Thompson
2025-12-28 02:39:27 -08:00
committed by GitHub
parent a6d7918f07
commit e084ae6ef1
22 changed files with 640 additions and 12 deletions
+2 -1
View File
@@ -178,6 +178,7 @@ void Label::updateSize()
auto yPadding =
this->currentPadding_.top() + this->currentPadding_.bottom();
auto height = metrics.height() + yPadding;
if (this->shouldElide_)
{
@@ -214,7 +215,7 @@ bool Label::updateElidedText(const QFontMetricsF &fontMetrics, qreal width)
QRectF Label::textRect() const
{
return this->rect().toRectF().marginsRemoved(this->currentPadding_);
return QRectF(this->rect()).marginsRemoved(this->currentPadding_);
}
} // namespace chatterino
+1 -2
View File
@@ -43,8 +43,7 @@ protected:
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
private:
void updateSize();
virtual void updateSize();
QRectF textRect() const;
/// Returns the current font style's font metric based on the current scale.
+188
View File
@@ -0,0 +1,188 @@
#include "widgets/MarkdownLabel.hpp"
#include "Application.hpp"
#include "singletons/Theme.hpp"
#include <QAbstractTextDocumentLayout>
#include <QDesktopServices>
#include <QMouseEvent>
#include <QPainter>
#include <QTextDocument>
#include <QUrl>
namespace chatterino {
MarkdownLabel::MarkdownLabel(BaseWidget *parent, QString text, FontStyle style)
: Label(parent, std::move(text), style)
, markdownDocument(new QTextDocument(this))
{
if (!this->text_.isEmpty())
{
this->markdownDocument->setMarkdown(this->text_);
}
this->setMouseTracking(true);
}
void MarkdownLabel::setText(const QString &text)
{
assert(this->markdownDocument != nullptr);
if (this->text_ != text)
{
this->text_ = text;
this->markdownDocument->setMarkdown(text);
this->updateSize();
this->update();
}
}
void MarkdownLabel::paintEvent(QPaintEvent * /*event*/)
{
assert(this->markdownDocument != nullptr);
QPainter painter(this);
auto font =
getApp()->getFonts()->getFont(this->getFontStyle(), this->scale());
painter.setFont(font);
// draw text
QRectF textRect = this->textRect();
if (!this->text_.isEmpty())
{
QColor textColor =
this->theme ? this->theme->messages.textColors.regular : Qt::black;
this->markdownDocument->setTextWidth(textRect.width());
this->markdownDocument->setDefaultFont(font);
this->markdownDocument->setMarkdown(this->text_);
QPalette docPalette = this->palette();
docPalette.setColor(QPalette::Text, textColor);
docPalette.setColor(QPalette::WindowText, textColor);
painter.setPen(textColor);
painter.save();
painter.translate(textRect.topLeft());
// create a rendering context using our text color and document palette
QAbstractTextDocumentLayout::PaintContext paintContext;
paintContext.palette = docPalette;
paintContext.clip = QRectF(0, 0, textRect.width(), textRect.height());
this->markdownDocument->documentLayout()->draw(&painter, paintContext);
painter.restore();
}
else
{
// Fall back to the base Label rendering if no markdown document
Label::paintEvent(nullptr);
return;
}
}
void MarkdownLabel::mousePressEvent(QMouseEvent *event)
{
assert(this->markdownDocument != nullptr);
if (event->button() == Qt::LeftButton)
{
QRectF textRect = this->textRect();
QPointF pos = event->pos() - textRect.topLeft();
QString anchor =
this->markdownDocument->documentLayout()->anchorAt(pos);
if (!anchor.isEmpty())
{
QUrl url(anchor);
// Validate the URL and add scheme if missing
if (!url.isValid())
{
return;
}
// If the URL doesn't have a scheme, assume it's http
if (url.scheme().isEmpty())
{
url.setScheme("http");
}
// Only open URLs with safe schemes
QString scheme = url.scheme().toLower();
if (scheme == "http" || scheme == "https" || scheme == "ftp" ||
scheme == "file" || scheme == "mailto")
{
QDesktopServices::openUrl(url);
}
return;
}
}
Label::mousePressEvent(event);
}
void MarkdownLabel::mouseMoveEvent(QMouseEvent *event)
{
assert(this->markdownDocument != nullptr);
QRectF textRect = this->textRect();
QPointF pos = event->pos() - textRect.topLeft();
QString anchor = this->markdownDocument->documentLayout()->anchorAt(pos);
if (!anchor.isEmpty())
{
this->setCursor(Qt::PointingHandCursor);
}
else
{
this->setCursor(Qt::ArrowCursor);
}
Label::mouseMoveEvent(event);
}
void MarkdownLabel::updateSize()
{
assert(this->markdownDocument != nullptr);
this->currentPadding_ = this->basePadding_.toMarginsF() * this->scale();
if (!this->text_.isEmpty())
{
this->markdownDocument->setDefaultFont(
getApp()->getFonts()->getFont(this->getFontStyle(), this->scale()));
this->markdownDocument->setMarkdown(this->text_);
// Use word wrap width if enabled, otherwise use a reasonable default
qreal testWidth = this->wordWrap_
? 400.0 * this->scale()
: this->markdownDocument->idealWidth();
this->markdownDocument->setTextWidth(testWidth);
auto yPadding =
this->currentPadding_.top() + this->currentPadding_.bottom();
auto height = this->markdownDocument->size().height() + yPadding;
auto width = qMin(this->markdownDocument->idealWidth(), testWidth) +
this->currentPadding_.left() +
this->currentPadding_.right();
this->sizeHint_ = QSizeF(width, height).toSize();
this->minimumSizeHint_ = this->sizeHint_;
this->updateGeometry();
}
else
{
// Fall back to base Label size calculation
Label::updateSize();
}
}
} // namespace chatterino
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "widgets/Label.hpp"
class QTextDocument;
namespace chatterino {
/// @brief A Label that supports rendering markdown text
///
/// MarkdownLabel inherits from Label and adds markdown rendering capabilities.
/// It automatically handles markdown document creation and rendering.
class MarkdownLabel : public Label
{
public:
explicit MarkdownLabel(BaseWidget *parent, QString text,
FontStyle style = FontStyle::UiMedium);
void setText(const QString &text);
protected:
void paintEvent(QPaintEvent * /*event*/) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
private:
void updateSize() override;
QTextDocument *markdownDocument;
};
} // namespace chatterino
+382 -2
View File
@@ -2,8 +2,14 @@
#include "singletons/Theme.hpp"
#include "util/LayoutCreator.hpp"
#include "widgets/buttons/SvgButton.hpp"
#include "widgets/MarkdownLabel.hpp"
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QRegularExpression>
#include <QSplitter>
#include <QTextBlock>
#include <QTextEdit>
namespace chatterino {
@@ -17,12 +23,281 @@ EditUserNotesDialog::EditUserNotesDialog(QWidget *parent)
},
parent)
{
this->setScaleIndependentSize(500, 350);
this->setScaleIndependentSize(700, 450);
auto layout = LayoutCreator<QWidget>(this->getLayoutContainer())
.setLayoutType<QVBoxLayout>();
auto edit = layout.emplace<QTextEdit>().assign(&this->textEdit_);
auto headerLayout = layout.emplace<QHBoxLayout>();
auto *headingButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/heading-darkMode.svg",
.light = ":/buttons/heading-lightMode.svg",
})
.getElement();
QObject::connect(headingButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
const auto line = cursor.block().text();
const auto pos = EditUserNotesDialog::currentWordPosition(cursor);
const auto selectedText = cursor.selectedText();
if (!selectedText.isEmpty() &&
EditUserNotesDialog::isHeading(line, pos))
{
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, 1);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, 4);
cursor.removeSelectedText();
// restore selection
cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else if (cursor.hasSelection())
{
cursor.insertText("### " + selectedText);
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else
{
cursor.insertText("### ");
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
headingButton->setToolTip("Insert a heading");
auto *boldButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/bold-darkMode.svg",
.light = ":/buttons/bold-lightMode.svg",
})
.getElement();
QObject::connect(boldButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
const auto line = cursor.block().text();
const auto pos = cursor.columnNumber();
auto selectedText = cursor.selectedText();
if (!selectedText.isEmpty() && EditUserNotesDialog::isBold(line, pos))
{
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor,
1); // un-select
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, 2);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length() + 4));
cursor.insertText(selectedText);
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else if (cursor.hasSelection())
{
auto appended = 0;
QChar newLine(QChar::ParagraphSeparator);
if (selectedText.back() == newLine)
{
selectedText.chop(1);
cursor.insertText("**" + selectedText + "**" + newLine);
appended = 3;
}
else
{
cursor.insertText("**" + selectedText + "**");
appended = 2;
}
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor,
appended);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else
{
cursor.insertText("****");
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, 2);
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
boldButton->setToolTip("Make selected text bold");
auto *italicButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/italic-darkMode.svg",
.light = ":/buttons/italic-lightMode.svg",
})
.getElement();
QObject::connect(italicButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
const auto line = cursor.block().text();
const auto pos = cursor.columnNumber();
auto selectedText = cursor.selectedText();
if (!selectedText.isEmpty() && EditUserNotesDialog::isItalic(line, pos))
{
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor,
1); // un-select
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, 1);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length() + 2));
cursor.insertText(selectedText);
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else if (cursor.hasSelection())
{
auto appended = 0;
QChar newLine(QChar::ParagraphSeparator);
if (selectedText.back() == newLine)
{
selectedText.chop(1);
cursor.insertText("*" + selectedText + "*" + newLine);
appended = 2;
}
else
{
cursor.insertText("*" + selectedText + "*");
appended = 1;
}
// restore selection
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor,
appended);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor,
static_cast<int>(selectedText.length()));
}
else
{
cursor.insertText("**");
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, 1);
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
italicButton->setToolTip("Make selected text italic");
auto *quoteButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/quote-darkMode.svg",
.light = ":/buttons/quote-lightMode.svg",
})
.getElement();
QObject::connect(quoteButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
if (cursor.hasSelection())
{
auto selectedText = cursor.selectedText();
cursor.insertText("> " + selectedText);
}
else
{
cursor.insertText("> ");
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
quoteButton->setToolTip("Insert a blockquote");
auto *linkButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/link-darkMode.svg",
.light = ":/buttons/link-lightMode.svg",
})
.getElement();
QObject::connect(linkButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
if (cursor.hasSelection())
{
auto appended = 0;
auto selectedText = cursor.selectedText();
QChar newLine(QChar::ParagraphSeparator);
if (selectedText.back() == newLine)
{
selectedText.chop(1);
cursor.insertText("[" + selectedText + "](url)" + newLine);
appended = 2;
}
else
{
cursor.insertText("[" + selectedText + "](url)");
appended = 1;
}
// select "url" for easy replacement
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor,
appended);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, 3);
}
else
{
cursor.insertText("[](url)");
cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, 1);
cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, 3);
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
linkButton->setToolTip("Insert a hyperlink");
auto *listButton = headerLayout
.emplace<SvgButton>(SvgButton::Src{
.dark = ":/buttons/bullet-list-darkMode.svg",
.light = ":/buttons/bullet-list-lightMode.svg",
})
.getElement();
QObject::connect(listButton, &Button::leftClicked, this, [&] {
auto cursor = this->textEdit_->textCursor();
if (cursor.hasSelection())
{
cursor.select(QTextCursor::LineUnderCursor);
const auto selectedText = cursor.selectedText();
cursor.insertText("- " + selectedText);
cursor.movePosition(QTextCursor::StartOfLine,
QTextCursor::KeepAnchor, 0);
}
else
{
const auto pos = cursor.columnNumber();
cursor.movePosition(QTextCursor::StartOfLine,
QTextCursor::MoveAnchor, 0);
cursor.insertText("- ");
cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor,
pos);
}
this->textEdit_->setTextCursor(cursor);
this->textEdit_->setFocus();
});
listButton->setToolTip("Insert a bullet list item");
headerLayout->addSpacing(175); // group markdown toolbar buttons
auto previewCheckBox =
headerLayout.emplace<QCheckBox>("Show Markdown Preview")
.assign(&this->previewCheckBox_);
auto splitter =
layout.emplace<QSplitter>(Qt::Horizontal).assign(&this->splitter_);
auto edit = splitter.emplace<QTextEdit>().assign(&this->textEdit_);
auto preview = splitter.emplace<MarkdownLabel>(this, QString())
.assign(&this->previewLabel_);
preview->setWordWrap(true);
preview->setPadding(QMargins(10, 10, 10, 10));
this->splitter_->setSizes({350, 350});
this->previewLabel_->setVisible(false);
layout
.emplace<QDialogButtonBox>(QDialogButtonBox::Ok |
@@ -36,6 +311,31 @@ EditUserNotesDialog::EditUserNotesDialog(QWidget *parent)
this->close();
});
// Connect preview toggle
QObject::connect(this->previewCheckBox_, &QCheckBox::toggled, this,
[this](bool checked) {
this->previewLabel_->setVisible(checked);
if (checked)
{
this->updatePreview();
this->splitter_->setSizes({350, 350});
this->textEdit_->setFocus();
}
else
{
this->splitter_->setSizes({700, 0});
this->textEdit_->setFocus();
}
});
// Connect text changes to preview update
QObject::connect(this->textEdit_, &QTextEdit::textChanged, this, [this] {
if (this->previewCheckBox_->isChecked())
{
this->updatePreview();
}
});
this->themeChangedEvent();
}
@@ -63,6 +363,8 @@ void EditUserNotesDialog::themeChangedEvent()
return;
}
assert(this->previewLabel_ != nullptr);
auto palette = this->palette();
palette.setColor(QPalette::Window,
@@ -76,6 +378,84 @@ void EditUserNotesDialog::themeChangedEvent()
{
this->textEdit_->setPalette(palette);
}
this->previewLabel_->setPalette(palette);
}
void EditUserNotesDialog::updatePreview()
{
assert(this->textEdit_ != nullptr);
assert(this->previewLabel_ != nullptr);
QString text = this->textEdit_->toPlainText();
if (text.isEmpty())
{
this->previewLabel_->setText(
"*Preview will appear here when you type markdown text...*");
}
else
{
this->previewLabel_->setText(text);
}
}
int EditUserNotesDialog::currentWordPosition(const QTextCursor &cursor)
{
QTextCursor temp = cursor;
temp.movePosition(QTextCursor::StartOfWord, QTextCursor::MoveAnchor);
return temp.columnNumber();
}
bool EditUserNotesDialog::isBold(const QString &line, const int pos)
{
static QRegularExpression pattern(R"((\*{2,})(.*?)(\1))");
QRegularExpressionMatchIterator iter = pattern.globalMatch(line);
while (iter.hasNext())
{
QRegularExpressionMatch match = iter.next();
const auto startIndex = match.capturedStart(2);
const auto endIndex = match.capturedEnd(2);
if (pos == startIndex || pos == endIndex)
{
return true;
}
}
return false;
}
bool EditUserNotesDialog::isItalic(const QString &line, const int pos)
{
static QRegularExpression pattern(R"(\*(.*?)\*)");
QRegularExpressionMatchIterator iter = pattern.globalMatch(line);
while (iter.hasNext())
{
QRegularExpressionMatch match = iter.next();
const auto startIndex = match.capturedStart(1);
const auto endIndex = match.capturedEnd(1);
if (pos == startIndex || pos == endIndex)
{
return true;
}
}
return false;
}
bool EditUserNotesDialog::isHeading(const QString &line, const int pos)
{
static QRegularExpression pattern(R"(###\s)");
QRegularExpressionMatch match = pattern.match(line, pos - 4);
return match.hasMatch() && pos == match.capturedEnd();
}
} // namespace chatterino
@@ -3,10 +3,15 @@
#include "pajlada/signals/signal.hpp"
#include "widgets/BasePopup.hpp"
class QCheckBox;
class QSplitter;
class QTextCursor;
class QTextEdit;
namespace chatterino {
class MarkdownLabel;
class EditUserNotesDialog : public BasePopup
{
Q_OBJECT
@@ -24,7 +29,16 @@ protected:
void themeChangedEvent() override;
private:
void updatePreview();
static int currentWordPosition(const QTextCursor &cursor);
static bool isBold(const QString &line, const int pos);
static bool isItalic(const QString &line, const int pos);
static bool isHeading(const QString &line, const int pos);
QTextEdit *textEdit_{};
QCheckBox *previewCheckBox_{};
QSplitter *splitter_{};
MarkdownLabel *previewLabel_{};
};
} // namespace chatterino
+4 -6
View File
@@ -36,6 +36,7 @@
#include "widgets/helper/LiveIndicator.hpp"
#include "widgets/helper/ScalingSpacerItem.hpp"
#include "widgets/Label.hpp"
#include "widgets/MarkdownLabel.hpp"
#include "widgets/Notebook.hpp"
#include "widgets/Scrollbar.hpp"
#include "widgets/splits/Split.hpp"
@@ -496,7 +497,8 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, Split *split)
});
}
auto notesPreview = layout.emplace<Label>().assign(&ui_.notesPreview);
auto notesPreview = layout.emplace<MarkdownLabel>(this, QString())
.assign(&this->ui_.notesPreview);
notesPreview->setVisible(false);
notesPreview->setShouldElide(true);
@@ -1188,11 +1190,7 @@ void UserInfoPopup::updateNotes()
return;
}
static QRegularExpression spaceRegex{"\\s+"};
auto previewText = "Notes: " + userData->notes.replace(spaceRegex, " ");
this->ui_.notesPreview->setText(previewText);
this->ui_.notesPreview->setText(userData->notes);
this->ui_.notesPreview->setVisible(true);
}
+2 -1
View File
@@ -13,6 +13,7 @@ namespace chatterino {
class Channel;
using ChannelPtr = std::shared_ptr<Channel>;
class Label;
class MarkdownLabel;
class EditUserNotesDialog;
class ChannelView;
class Split;
@@ -90,7 +91,7 @@ private:
QCheckBox *block = nullptr;
QCheckBox *ignoreHighlights = nullptr;
Label *notesPreview = nullptr;
MarkdownLabel *notesPreview = nullptr;
LabelButton *notesAdd = nullptr;
Label *noMessagesLabel = nullptr;