[network/ruqola/link-preview-compact-card] src: Redesign link previews as compact cards
Till Adam <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 204cc2889d852051a1722933bb39924ff01400c9 by Till Adam.
Committed on 26/07/2026 at 16:54.
Pushed by tilladam into branch 'link-preview-compact-card'.
Redesign link previews as compact cards
Link previews rendered as full-width banners: the og:image scaled up to
the column width, an italic "Link Preview" label, an eye toggle, and a
description that repeated the title and the site name. The result
dominated the viewport while conveying little.
Render them as compact cards instead:
- A small favicon-sized thumbnail (larger only for links whose content is
itself an image or video), laid out horizontally to the left of the
text and never upscaled past its own size, replacing the full-width
image drawn with IgnoreAspectRatio.
- Title and description to the right of the thumbnail; the redundant
site-name line is dropped from generateHtmlDescription().
- A subtle rounded, palette-derived background marks the card as embedded
content, taking over from the italic "Link Preview" label (removed);
the eye toggle becomes a quieter chevron, and a small gap separates the
card from the message text.
- Title-only previews (no image, no description) are suppressed entirely,
via the new MessageUrl::hasRichPreview(): they only restated the link
that is already in the message body.
The card geometry, hit-testing and text selection are reworked for the
horizontal layout, keeping the sizeHint/draw width invariant intact.
M +28 -2 src/core/autotests/messageurltest.cpp
M +1 -0 src/core/autotests/messageurltest.h
M +14 -3 src/core/messages/messageurl.cpp
M +5 -0 src/core/messages/messageurl.h
M +175 -117 src/widgets/room/delegate/messagedelegatehelperurlpreview.cpp
M +17 -11 src/widgets/room/delegate/messagedelegatehelperurlpreview.h
M +1 -1 src/widgets/room/delegate/messagelistdelegate.cpp
M +1 -1 src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.cpp
https://invent.kde.org/network/ruqola/-/commit/204cc2889d852051a1722933bb39924ff01400c9
diff --git a/src/core/autotests/messageurltest.cpp b/src/core/autotests/messageurltest.cpp
index fdc5e0d1eb..95a2fb3a13 100644
--- a/src/core/autotests/messageurltest.cpp
+++ b/src/core/autotests/messageurltest.cpp
@@ -157,6 +157,32 @@ void MessageUrlTest::shouldTestPreviewUrl()
QVERIFY(url.hasPreviewUrl());
}
+void MessageUrlTest::shouldTestRichPreview()
+{
+ // A bare title is not enough for a preview card: it just restates the link.
+ MessageUrl url;
+ url.setPageTitle(u"Index of /ci-builds/network/ruqola"_s);
+ url.setUrl(u"https://origin.cdn.kde.org/ci-builds/network/ruqola/"_s);
+ QVERIFY(url.hasPreviewUrl());
+ QVERIFY(!url.hasRichPreview());
+
+ // A description makes it worth showing.
+ url.setDescription(u"bla"_s);
+ QVERIFY(url.hasRichPreview());
+ url.setDescription(QString());
+ QVERIFY(!url.hasRichPreview());
+
+ // An image makes it worth showing.
+ url.setImageUrl(u"https://example.com/image.png"_s);
+ QVERIFY(url.hasRichPreview());
+ url.setImageUrl(QString());
+ QVERIFY(!url.hasRichPreview());
+
+ // Media content (a directly-linked image/video) is worth showing.
+ url.setContentType(MessageUrl::ContentType::Image);
+ QVERIFY(url.hasRichPreview());
+}
+
void MessageUrlTest::shouldGenerateHtmlDescription()
{
QFETCH(MessageUrl, messageUrl);
@@ -180,7 +206,7 @@ void MessageUrlTest::shouldGenerateHtmlDescription_data()
url.setSiteName(u"SiteName"_s);
url.setSiteUrl(u"SiteUrl"_s);
- QTest::newRow("generateHtmlDescription-test1") << url << u"[Title](Title_url)\nDescription\n[SiteName](SiteUrl)"_s;
+ QTest::newRow("generateHtmlDescription-test1") << url << u"[Title](Title_url)\nDescription"_s;
}
{
@@ -220,7 +246,7 @@ void MessageUrlTest::shouldGenerateHtmlDescription_data()
<< QStringLiteral(
"[ Shan Hadden Fanpage on Instagram: \"The Iconic video that started it all. . . #shanhadden #queenshanhadden #egirl "
"#minecraft\"](https://www.instagram.com/p/C0vwctGuxnI/)\n19K likes, 66 comments - queenshanfan on December 12, 2023: \"The Iconic video "
- "that started it all. . . #shanhadden #queenshanhadden #egirl #minecraft\"\n[Instagram](https://www.instagram.com/reel/C0vwctGuxnI/)");
+ "that started it all. . . #shanhadden #queenshanhadden #egirl #minecraft\"");
}
{
MessageUrl url;
diff --git a/src/core/autotests/messageurltest.h b/src/core/autotests/messageurltest.h
index 1ffd447792..ba70540135 100644
--- a/src/core/autotests/messageurltest.h
+++ b/src/core/autotests/messageurltest.h
@@ -18,6 +18,7 @@ private Q_SLOTS:
void shouldHaveDefaultValue();
void shouldSerializeData();
void shouldTestPreviewUrl();
+ void shouldTestRichPreview();
void shouldGenerateHtmlDescription();
void shouldGenerateHtmlDescription_data();
diff --git a/src/core/messages/messageurl.cpp b/src/core/messages/messageurl.cpp
index 2255e580ef..bdbfbfaa61 100644
--- a/src/core/messages/messageurl.cpp
+++ b/src/core/messages/messageurl.cpp
@@ -79,9 +79,9 @@ void MessageUrl::generateHtmlDescription()
if (!mDescription.isEmpty()) {
mHtmlDescription += u"\n%1"_s.arg(MessageUrl::cleanText(mDescription));
}
- if (!mSiteName.isEmpty()) {
- mHtmlDescription += u"\n[%1](%2)"_s.arg(mSiteName, mSiteUrl);
- }
+ // Note: the site name is intentionally not repeated here. It duplicates
+ // information already carried by the page title (and the link itself), and
+ // made the preview card needlessly tall. See MessageDelegateHelperUrlPreview.
}
bool MessageUrl::hasHtmlDescription() const
@@ -108,6 +108,17 @@ bool MessageUrl::hasPreviewUrl() const
return false;
}
+bool MessageUrl::hasRichPreview() const
+{
+ if (!mDescription.isEmpty()) {
+ return true;
+ }
+ if (!mImageUrl.isEmpty()) {
+ return true;
+ }
+ return hasPreviewContentType();
+}
+
QByteArray MessageUrl::urlId() const
{
return mUrlId;
diff --git a/src/core/messages/messageurl.h b/src/core/messages/messageurl.h
index 6a6b4d29b7..f0f36bb1dd 100644
--- a/src/core/messages/messageurl.h
+++ b/src/core/messages/messageurl.h
@@ -70,6 +70,11 @@ public:
[[nodiscard]] bool hasPreviewUrl() const;
+ // Whether the preview shows more than the bare link: an image, a description,
+ // or media content. A title-only preview just restates the link, so it is
+ // not rendered as a card.
+ [[nodiscard]] bool hasRichPreview() const;
+
[[nodiscard]] QString htmlDescription() const;
[[nodiscard]] bool hasHtmlDescription() const;
diff --git a/src/widgets/room/delegate/messagedelegatehelperurlpreview.cpp b/src/widgets/room/delegate/messagedelegatehelperurlpreview.cpp
index 617a31fc48..dbd11701e6 100644
--- a/src/widgets/room/delegate/messagedelegatehelperurlpreview.cpp
+++ b/src/widgets/room/delegate/messagedelegatehelperurlpreview.cpp
@@ -12,16 +12,34 @@
#include "rocketchataccount.h"
#include "ruqolawidgets_selection_debug.h"
-#include <KLocalizedString>
-
#include <QDrag>
#include <QListView>
#include <QMimeData>
#include <QPainter>
+#include <QPalette>
#include <QStyleOptionViewItem>
#include <QToolTip>
using namespace Qt::Literals::StringLiterals;
+
+namespace
+{
+// Maximum edge length (logical px) of the thumbnail for a regular link preview:
+// small enough that the card reads as a compact card, not a banner.
+constexpr int PreviewThumbnailMaxEdge = 64;
+// Larger cap for links whose content is itself an image/video.
+constexpr int PreviewMediaMaxEdge = 320;
+// Inner padding (logical px) between the card's rounded background and its
+// content, and the corner radius of that background (matching the card styling
+// used by the conference/section delegate helpers).
+constexpr int PreviewPadding = 6;
+constexpr int PreviewCornerRadius = 5;
+// Transparent gap (logical px) above the card, separating it from the message
+// text line. Reported as part of the preview's sizeHint height so the message
+// layout leaves room for it without any change to the stacking code.
+constexpr int PreviewTopGap = 6;
+}
+
MessageDelegateHelperUrlPreview::MessageDelegateHelperUrlPreview(RocketChatAccount *account, QListView *view, TextSelectionImpl *textSelectionImpl)
: MessageDelegateHelperBase(account, view, textSelectionImpl)
{
@@ -36,39 +54,47 @@ void MessageDelegateHelperUrlPreview::draw(const MessageUrl &messageUrl,
const QStyleOptionViewItem &option) const
{
const PreviewLayout layout = layoutPreview(messageUrl, option, previewRect.width(), previewRect.height());
- const QFont oldFont = painter->font();
- const QPen origPen = painter->pen();
- QColor lightColor(painter->pen().color());
- lightColor.setAlpha(60);
- painter->setPen(lightColor);
- QFont italicFont = oldFont;
- italicFont.setItalic(true);
- // italicFont.setBold(true);
- painter->setFont(italicFont);
- painter->drawText(previewRect.x(), previewRect.y() + option.fontMetrics.ascent(), layout.previewTitle);
- painter->setFont(oldFont);
- painter->setPen(origPen);
-
- const QIcon hideShowIcon = QIcon::fromTheme(layout.isShown ? u"visibility"_s : u"hint"_s);
- hideShowIcon.paint(painter, layout.hideShowButtonRect.translated(previewRect.topLeft()));
- if (layout.isShown) {
- int nextY = previewRect.y() + option.fontMetrics.ascent() + DelegatePaintUtil::margin();
- if (!layout.pixmap.isNull()) {
- QPixmap scaledPixmap;
- scaledPixmap = layout.pixmap.scaled(layout.imageSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
- painter->drawPixmap(previewRect.x(), nextY, scaledPixmap);
- // qDebug() << " image size " << scaledPixmap.size();
- nextY += scaledPixmap.height() / scaledPixmap.devicePixelRatioF() + DelegatePaintUtil::margin();
- }
- // qDebug() << " nextY " << nextY;
-#if 0
+
+ // A subtle rounded background sets the preview apart as embedded, secondary
+ // content (the job the old "Link Preview" label did, without the chrome).
+ // Colours come from the palette so it themes itself for light and dark.
+ painter->save();
+ painter->setRenderHint(QPainter::Antialiasing, true);
+ QColor borderColor = option.palette.color(QPalette::WindowText);
+ borderColor.setAlpha(30);
+ painter->setPen(borderColor);
+ painter->setBrush(option.palette.color(QPalette::AlternateBase));
+ const QRectF cardRect(previewRect.x() + 0.5, previewRect.y() + PreviewTopGap + 0.5, layout.contentWidth - 1, layout.contentHeight - PreviewTopGap - 1);
+ painter->drawRoundedRect(cardRect, PreviewCornerRadius, PreviewCornerRadius);
+ painter->restore();
+
+ const QPoint contentTopLeft = previewRect.topLeft() + QPoint(PreviewPadding, PreviewTopGap + PreviewPadding);
+
+ // A subtle collapse/expand affordance, right-aligned. A chevron reads as
+ // "there is more/less to see" without the eye icon's visual weight; fall
+ // back to the previous icons should the theme lack the chevrons.
+ QIcon toggleIcon = QIcon::fromTheme(layout.isShown ? u"go-up"_s : u"go-down"_s);
+ if (toggleIcon.isNull()) {
+ toggleIcon = QIcon::fromTheme(layout.isShown ? u"visibility"_s : u"hint"_s);
+ }
+ toggleIcon.paint(painter, layout.hideShowButtonRect.translated(previewRect.topLeft()));
+
+ if (!layout.isShown) {
+ // Collapsed: just the page title on one line, drawn as a clickable link
+ // so the preview still tells you where it goes.
painter->save();
- painter->setPen(Qt::red);
- painter->drawRect(previewRect);
+ painter->setPen(option.palette.color(QPalette::Link));
+ painter->drawText(contentTopLeft.x(), contentTopLeft.y() + option.fontMetrics.ascent(), layout.collapsedTitle);
painter->restore();
-#endif
- drawDescription(messageUrl, previewRect, painter, nextY, index, option);
+ return;
}
+
+ if (!layout.pixmap.isNull()) {
+ QPixmap scaledPixmap = layout.pixmap.scaled(layout.imageSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+ scaledPixmap.setDevicePixelRatio(layout.pixmap.devicePixelRatioF());
+ painter->drawPixmap(contentTopLeft, scaledPixmap);
+ }
+ drawDescription(messageUrl, previewRect, painter, index, option, layout);
}
MessageDelegateHelperUrlPreview::PreviewLayout MessageDelegateHelperUrlPreview::layoutPreview(const MessageUrl &messageUrl,
@@ -78,10 +104,29 @@ MessageDelegateHelperUrlPreview::PreviewLayout MessageDelegateHelperUrlPreview::
{
Q_UNUSED(urlsPreviewHeight);
MessageDelegateHelperUrlPreview::PreviewLayout layout;
- layout.previewTitle = i18n("Link Preview");
- layout.previewTitleSize = option.fontMetrics.size(Qt::TextSingleLine, layout.previewTitle);
- layout.hasDescription = messageUrl.hasHtmlDescription();
+ layout.isShown = messageUrl.showPreview();
+
+ const int margin = DelegatePaintUtil::margin();
+ const int iconSize = option.widget->style()->pixelMetric(QStyle::PM_ButtonIconSize);
+ // Space kept free on the right for the collapse/expand toggle (gap + icon).
+ const int rightReserve = margin + iconSize;
+
+ if (!layout.isShown) {
+ // Collapsed card: one compact line with the (elided) page title.
+ const int titleAvailableWidth = qMax(0, urlsPreviewWidth - 2 * PreviewPadding - rightReserve);
+ const QString title = messageUrl.pageTitle().isEmpty() ? messageUrl.url() : messageUrl.pageTitle();
+ layout.collapsedTitle = option.fontMetrics.elidedText(title, Qt::ElideRight, titleAvailableWidth);
+ layout.collapsedTitleSize = option.fontMetrics.size(Qt::TextSingleLine, layout.collapsedTitle);
+ layout.collapsedTitleRect = QRect(QPoint(PreviewPadding, PreviewTopGap + PreviewPadding), layout.collapsedTitleSize);
+ const int innerWidth = layout.collapsedTitleSize.width() + rightReserve;
+ const int innerHeight = qMax(layout.collapsedTitleSize.height(), iconSize);
+ layout.contentWidth = innerWidth + 2 * PreviewPadding;
+ layout.contentHeight = PreviewTopGap + innerHeight + 2 * PreviewPadding;
+ layout.hideShowButtonRect = QRect(layout.contentWidth - PreviewPadding - iconSize, PreviewTopGap + PreviewPadding, iconSize, iconSize);
+ return layout;
+ }
+ // Expanded card: [ thumbnail ] [ title + description ] [ toggle ]
QUrl previewImageUrl;
if (messageUrl.imageUrl().isEmpty() || messageUrl.hasPreviewUrl()) {
previewImageUrl = mRocketChatAccount ? mRocketChatAccount->previewUrlFromLocalCache(messageUrl.buildImageUrl()) : QUrl{};
@@ -93,14 +138,31 @@ MessageDelegateHelperUrlPreview::PreviewLayout MessageDelegateHelperUrlPreview::
layout.pixmap = mPixmapCache.pixmapForLocalFile(imagePreviewPath);
layout.pixmap.setDevicePixelRatio(option.widget->devicePixelRatioF());
const auto dpr = layout.pixmap.devicePixelRatioF();
- layout.imageSize = layout.pixmap.size().scaled(urlsPreviewWidth * dpr, /*imageMaxHeight*/ 100 * dpr, Qt::KeepAspectRatio);
- // qDebug() << " layout.imageSize " << layout.imageSize;
+ // Regular link previews get a small, favicon-like thumbnail; links whose
+ // content *is* an image/video keep a larger picture since that is the
+ // point. Never upscale past the image's own size.
+ const bool mediaContent = messageUrl.contentType() != MessageUrl::ContentType::None;
+ const int maxEdge = mediaContent ? PreviewMediaMaxEdge : PreviewThumbnailMaxEdge;
+ layout.imageSize = layout.pixmap.size().scaled(maxEdge * dpr, maxEdge * dpr, Qt::KeepAspectRatio).boundedTo(layout.pixmap.size());
}
- const int iconSize = option.widget->style()->pixelMetric(QStyle::PM_ButtonIconSize);
- layout.hideShowButtonRect = QRect(layout.previewTitleSize.width() + DelegatePaintUtil::margin(), 0, iconSize, iconSize);
- layout.isShown = messageUrl.showPreview();
- layout.descriptionSize = layout.isShown ? documentTypeForIndexSize(convertMessageUrlToDocumentDescriptionInfo(messageUrl, urlsPreviewWidth)) : QSize();
+ const qreal dpr = layout.pixmap.isNull() ? 1.0 : layout.pixmap.devicePixelRatioF();
+ const QSize thumbLogical = layout.imageSize.isEmpty() ? QSize() : QSize(qRound(layout.imageSize.width() / dpr), qRound(layout.imageSize.height() / dpr));
+ const int leftReserve = thumbLogical.isEmpty() ? 0 : (thumbLogical.width() + margin);
+
+ layout.textLeftOffset = leftReserve;
+ layout.docWidth = qMax(0, urlsPreviewWidth - 2 * PreviewPadding - leftReserve - rightReserve);
+ layout.descriptionSize = documentTypeForIndexSize(convertMessageUrlToDocumentDescriptionInfo(messageUrl, layout.docWidth));
+
+ // contentWidth is the exact inverse of docWidth above (same padding and
+ // reserves added back), so re-running this function with
+ // previewRect.width() == contentWidth reproduces the same docWidth (and thus
+ // the same document layout) at draw and hit-test time.
+ const int innerWidth = leftReserve + layout.descriptionSize.width() + rightReserve;
+ const int innerHeight = qMax(qMax(thumbLogical.height(), layout.descriptionSize.height()), iconSize);
+ layout.contentWidth = innerWidth + 2 * PreviewPadding;
+ layout.contentHeight = PreviewTopGap + innerHeight + 2 * PreviewPadding;
+ layout.hideShowButtonRect = QRect(layout.contentWidth - PreviewPadding - iconSize, PreviewTopGap + PreviewPadding, iconSize, iconSize);
return layout;
}
@@ -109,7 +171,10 @@ MessageDelegateHelperBase::DocumentTypeInfo MessageDelegateHelperUrlPreview::con
{
MessageDelegateHelperBase::DocumentTypeInfo info;
info.identifier = messageUrl.urlId();
- info.text = messageUrl.htmlDescription();
+ // The selectable description document only exists while the preview is
+ // expanded; when collapsed we paint the title ourselves, so return empty
+ // text (which yields a null document) to keep a single cache key per url.
+ info.text = messageUrl.showPreview() ? messageUrl.htmlDescription() : QString();
info.width = width;
return info;
}
@@ -117,37 +182,27 @@ MessageDelegateHelperBase::DocumentTypeInfo MessageDelegateHelperUrlPreview::con
void MessageDelegateHelperUrlPreview::drawDescription(const MessageUrl &messageUrl,
QRect previewRect,
QPainter *painter,
- int topPos,
const QModelIndex &index,
- const QStyleOptionViewItem &option) const
+ const QStyleOptionViewItem &option,
+ const PreviewLayout &layout) const
{
- auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, previewRect.width()));
+ auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, layout.docWidth));
if (!doc) {
return;
}
- MessageDelegateUtils::drawSelection(doc, previewRect, topPos, painter, index, option, mTextSelectionImpl->textSelection(), {}, messageUrl);
+ const QRect textRect(previewRect.x() + PreviewPadding + layout.textLeftOffset,
+ previewRect.y() + PreviewTopGap + PreviewPadding,
+ layout.docWidth,
+ layout.descriptionSize.height());
+ MessageDelegateUtils::drawSelection(doc, textRect, textRect.top(), painter, index, option, mTextSelectionImpl->textSelection(), {}, messageUrl);
}
QSize MessageDelegateHelperUrlPreview::sizeHint(const MessageUrl &messageUrl, const QModelIndex &index, int maxWidth, const QStyleOptionViewItem &option) const
{
Q_UNUSED(index);
const PreviewLayout layout = layoutPreview(messageUrl, option, maxWidth, -1);
- int height = layout.previewTitleSize.height() + DelegatePaintUtil::margin();
- // qDebug() << " height 1 " << height;
- int pixmapWidth = 0;
- if (layout.isShown) {
- pixmapWidth = qMin(layout.pixmap.width(), maxWidth);
- height += qMin(layout.imageSize.height(), 100) + DelegatePaintUtil::margin();
- // qDebug() << " height 2 " << height << " layout.pixmap.height() " << layout.imageSize.height();
- }
- int descriptionWidth = 0;
- if (layout.hasDescription && layout.isShown) {
- descriptionWidth = layout.descriptionSize.width();
- height += layout.descriptionSize.height() + DelegatePaintUtil::margin();
- // qDebug() << " height 3 " << height;
- }
- return {qMax(qMax(pixmapWidth, layout.previewTitleSize.width()), descriptionWidth), height};
+ return {layout.contentWidth, layout.contentHeight};
}
bool MessageDelegateHelperUrlPreview::handleHelpEvent(QHelpEvent *helpEvent,
@@ -159,11 +214,12 @@ bool MessageDelegateHelperUrlPreview::handleHelpEvent(QHelpEvent *helpEvent,
return false;
}
- const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, previewRect.width()));
+ const PreviewLayout layout = layoutPreview(messageUrl, option, previewRect.width(), previewRect.height());
+ const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, layout.docWidth));
if (!doc) {
return false;
}
- const QPoint pos = adaptMousePosition(helpEvent->pos(), messageUrl, previewRect, option);
+ const QPoint pos = relativePos(helpEvent->pos(), layout, previewRect);
QString formattedTooltip;
if (MessageDelegateUtils::generateToolTip(doc, pos, formattedTooltip)) {
QToolTip::showText(helpEvent->globalPos(), formattedTooltip);
@@ -180,9 +236,9 @@ bool MessageDelegateHelperUrlPreview::handleMouseEvent(const MessageUrl &message
{
const QEvent::Type eventType = mouseEvent->type();
const QPoint pos = mouseEvent->pos();
+ const PreviewLayout layout = layoutPreview(messageUrl, option, previewRect.width(), previewRect.height());
switch (eventType) {
case QEvent::MouseButtonRelease: {
- const PreviewLayout layout = layoutPreview(messageUrl, option, previewRect.width(), previewRect.height());
if (layout.hideShowButtonRect.translated(previewRect.topLeft()).contains(pos)) {
MessagesModel::AttachmentAndUrlPreviewVisibility previewUrlVisibility;
previewUrlVisibility.show = !layout.isShown;
@@ -191,8 +247,16 @@ bool MessageDelegateHelperUrlPreview::handleMouseEvent(const MessageUrl &message
model->setData(index, QVariant::fromValue(previewUrlVisibility), MessagesModel::DisplayUrlPreview);
return true;
}
- // Clicks on links
- if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, previewRect.width()))) {
+ if (!layout.isShown) {
+ // Clicking the collapsed title opens the link.
+ if (layout.collapsedTitleRect.translated(previewRect.topLeft()).contains(pos)) {
+ Q_EMIT mRocketChatAccount->openLinkRequested(messageUrl.url());
+ return true;
+ }
+ break;
+ }
+ // Clicks on links inside the description
+ if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, layout.docWidth))) {
const QPoint mouseClickPos = relativePos(pos, layout, previewRect);
const QString link = doc->documentLayout()->anchorAt(mouseClickPos);
if (!link.isEmpty()) {
@@ -204,30 +268,32 @@ bool MessageDelegateHelperUrlPreview::handleMouseEvent(const MessageUrl &message
}
case QEvent::MouseButtonPress:
mTextSelectionImpl->setMightStartDrag(false);
- if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, previewRect.width()))) {
- const int charPos = charPosition(doc, messageUrl, previewRect, pos, option);
- qCDebug(RUQOLAWIDGETS_SELECTION_LOG) << "pressed at pos" << charPos;
- if (charPos == -1) {
- return false;
- }
- if (mTextSelectionImpl->textSelection()->contains(index, charPos) && doc->documentLayout()->hitTest(pos, Qt::ExactHit) != -1) {
- mTextSelectionImpl->setMightStartDrag(true);
- return true;
- }
+ if (layout.isShown) {
+ if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, layout.docWidth))) {
+ const int charPos = charPosition(doc, layout, previewRect, pos);
+ qCDebug(RUQOLAWIDGETS_SELECTION_LOG) << "pressed at pos" << charPos;
+ if (charPos == -1) {
+ return false;
+ }
+ if (mTextSelectionImpl->textSelection()->contains(index, charPos)
+ && doc->documentLayout()->hitTest(relativePos(pos, layout, previewRect), Qt::ExactHit) != -1) {
+ mTextSelectionImpl->setMightStartDrag(true);
+ return true;
+ }
- // QWidgetTextControl also has code to support selectBlockOnTripleClick, shift to extend selection
- // (look there if you want to add these things)
+ // QWidgetTextControl also has code to support selectBlockOnTripleClick, shift to extend selection
+ // (look there if you want to add these things)
- mTextSelectionImpl->textSelection()->setPreviewUrlTextSelectionStart(index, charPos, messageUrl);
- return true;
- } else {
- mTextSelectionImpl->textSelection()->clear();
+ mTextSelectionImpl->textSelection()->setPreviewUrlTextSelectionStart(index, charPos, messageUrl);
+ return true;
+ }
}
+ mTextSelectionImpl->textSelection()->clear();
break;
case QEvent::MouseMove:
- if (!mTextSelectionImpl->mightStartDrag()) {
- if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, previewRect.width()))) {
- const int charPos = charPosition(doc, messageUrl, previewRect, pos, option);
+ if (layout.isShown && !mTextSelectionImpl->mightStartDrag()) {
+ if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, layout.docWidth))) {
+ const int charPos = charPosition(doc, layout, previewRect, pos);
if (charPos != -1) {
// QWidgetTextControl also has code to support isPreediting()/commitPreedit(), selectBlockOnTripleClick
mTextSelectionImpl->textSelection()->setPreviewUrlTextSelectionEnd(index, charPos, messageUrl);
@@ -237,9 +303,9 @@ bool MessageDelegateHelperUrlPreview::handleMouseEvent(const MessageUrl &message
}
break;
case QEvent::MouseButtonDblClick:
- if (!mTextSelectionImpl->textSelection()->hasSelection()) {
- if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, previewRect.width()))) {
- const int charPos = charPosition(doc, messageUrl, previewRect, pos, option);
+ if (layout.isShown && !mTextSelectionImpl->textSelection()->hasSelection()) {
+ if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, layout.docWidth))) {
+ const int charPos = charPosition(doc, layout, previewRect, pos);
qCDebug(RUQOLAWIDGETS_SELECTION_LOG) << "double-clicked at pos" << charPos;
if (charPos == -1) {
return false;
@@ -255,46 +321,34 @@ bool MessageDelegateHelperUrlPreview::handleMouseEvent(const MessageUrl &message
return false;
}
-int MessageDelegateHelperUrlPreview::charPosition(const QTextDocument *doc,
- const MessageUrl &messageUrl,
- QRect previewRect,
- const QPoint &pos,
- const QStyleOptionViewItem &option)
-{
- const QPoint relativePos = adaptMousePosition(pos, messageUrl, previewRect, option);
- const int charPos = doc->documentLayout()->hitTest(relativePos, Qt::FuzzyHit);
- return charPos;
-}
-
-QPoint
-MessageDelegateHelperUrlPreview::adaptMousePosition(const QPoint &pos, const MessageUrl &messageUrl, QRect previewRect, const QStyleOptionViewItem &option)
+int MessageDelegateHelperUrlPreview::charPosition(const QTextDocument *doc, const PreviewLayout &layout, QRect previewRect, const QPoint &pos)
{
- const PreviewLayout layout = layoutPreview(messageUrl, option, previewRect.width(), previewRect.height());
- return relativePos(pos, layout, previewRect);
+ return doc->documentLayout()->hitTest(relativePos(pos, layout, previewRect), Qt::FuzzyHit);
}
QPoint MessageDelegateHelperUrlPreview::relativePos(const QPoint &pos, const PreviewLayout &layout, QRect previewRect) const
{
- int offsetHeightImage = 0;
- if (layout.imageSize.height() > 0) {
- const auto dpr = layout.pixmap.devicePixelRatioF();
- offsetHeightImage = layout.imageSize.height() / dpr;
- }
- return pos - previewRect.topLeft() - QPoint(0, offsetHeightImage + layout.previewTitleSize.height() + DelegatePaintUtil::margin());
+ // The description document is laid out inside the card padding (below the
+ // top gap), to the right of the thumbnail; translate mouse coordinates into
+ // the document's frame.
+ return pos - previewRect.topLeft() - QPoint(PreviewPadding + layout.textLeftOffset, PreviewTopGap + PreviewPadding);
}
QString MessageDelegateHelperUrlPreview::urlAt(const QStyleOptionViewItem &option, const MessageUrl &messageUrl, QRect previewsRect, QPoint pos)
{
- auto document = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, previewsRect.width()));
+ const PreviewLayout layout = layoutPreview(messageUrl, option, previewsRect.width(), previewsRect.height());
+ auto document = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, layout.docWidth));
if (!document) {
return {};
}
- const QPoint relativePos = adaptMousePosition(pos, messageUrl, previewsRect, option);
- return document->documentLayout()->anchorAt(relativePos);
+ return document->documentLayout()->anchorAt(relativePos(pos, layout, previewsRect));
}
QTextDocument *MessageDelegateHelperUrlPreview::documentForUrlPreview(const MessageUrl &messageUrl) const
{
+ if (!messageUrl.showPreview()) {
+ return nullptr;
+ }
return documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, -1));
}
@@ -308,8 +362,9 @@ bool MessageDelegateHelperUrlPreview::maybeStartDrag(const MessageUrl &messageUr
return false;
}
if (mTextSelectionImpl->textSelection()->hasSelection()) {
- if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, previewsRect.width()))) {
- const QPoint pos = mouseEvent->pos() - previewsRect.topLeft();
+ const PreviewLayout layout = layoutPreview(messageUrl, option, previewsRect.width(), previewsRect.height());
+ if (const auto *doc = documentTypeForIndex(convertMessageUrlToDocumentDescriptionInfo(messageUrl, layout.docWidth))) {
+ const QPoint pos = relativePos(mouseEvent->pos(), layout, previewsRect);
const int charPos = doc->documentLayout()->hitTest(pos, Qt::FuzzyHit);
if (charPos != -1 && mTextSelectionImpl->textSelection()->contains(index, charPos)) {
auto mimeData = new QMimeData;
@@ -331,11 +386,14 @@ void MessageDelegateHelperUrlPreview::dump(const PreviewLayout &layout)
// Don't use debug category as we want to show it.
qDebug() << " pixmap " << layout.pixmap;
qDebug() << " imageUrl " << layout.imageUrl;
- qDebug() << " hasDescription " << layout.hasDescription;
- qDebug() << " previewTitleSize " << layout.previewTitleSize;
- qDebug() << " previewTitle " << layout.previewTitle;
+ qDebug() << " collapsedTitle " << layout.collapsedTitle;
+ qDebug() << " collapsedTitleSize " << layout.collapsedTitleSize;
qDebug() << " descriptionSize " << layout.descriptionSize;
qDebug() << " imageSize " << layout.imageSize;
+ qDebug() << " textLeftOffset " << layout.textLeftOffset;
+ qDebug() << " docWidth " << layout.docWidth;
+ qDebug() << " contentWidth " << layout.contentWidth;
+ qDebug() << " contentHeight " << layout.contentHeight;
qDebug() << " hideShowButtonRect " << layout.hideShowButtonRect;
qDebug() << " isShown " << layout.isShown;
}
diff --git a/src/widgets/room/delegate/messagedelegatehelperurlpreview.h b/src/widgets/room/delegate/messagedelegatehelperurlpreview.h
index 0667109097..4cbd11968c 100644
--- a/src/widgets/room/delegate/messagedelegatehelperurlpreview.h
+++ b/src/widgets/room/delegate/messagedelegatehelperurlpreview.h
@@ -37,12 +37,23 @@ private:
struct PreviewLayout {
QPixmap pixmap;
QString imageUrl;
- QString previewTitle;
+ // Elided page title, drawn as clickable text when the preview is collapsed.
+ QString collapsedTitle;
QRect hideShowButtonRect;
- QSize previewTitleSize;
+ QRect collapsedTitleRect;
+ QSize collapsedTitleSize;
QSize descriptionSize;
+ // Thumbnail size in device pixels (the pixmap carries the device pixel ratio).
QSize imageSize;
- bool hasDescription = false;
+ // Logical-pixel geometry of the compact card. textLeftOffset is where the
+ // description document starts (to the right of the thumbnail); docWidth is
+ // the text width it is laid out at. contentWidth/Height is the whole card
+ // and, by construction, contentWidth equals the rect width passed at draw
+ // and hit-test time, so the layout is stable between sizeHint() and draw().
+ int textLeftOffset = 0;
+ int docWidth = 0;
+ int contentWidth = 0;
+ int contentHeight = 0;
bool isShown = true;
};
LIBRUQOLAWIDGETS_NO_EXPORT void dump(const PreviewLayout &layout);
@@ -50,18 +61,13 @@ private:
layoutPreview(const MessageUrl &messageUrl, const QStyleOptionViewItem &option, int urlsPreviewWidth, int urlsPreviewHeight) const;
[[nodiscard]] LIBRUQOLAWIDGETS_NO_EXPORT MessageDelegateHelperBase::DocumentTypeInfo
convertMessageUrlToDocumentDescriptionInfo(const MessageUrl &messageUrl, int width) const;
- [[nodiscard]] LIBRUQOLAWIDGETS_NO_EXPORT int
- charPosition(const QTextDocument *doc, const MessageUrl &messageUrl, QRect previewRect, const QPoint &pos, const QStyleOptionViewItem &option);
- [[nodiscard]] LIBRUQOLAWIDGETS_NO_EXPORT QPoint adaptMousePosition(const QPoint &pos,
- const MessageUrl &messageUrl,
- QRect previewRect,
- const QStyleOptionViewItem &option);
+ [[nodiscard]] LIBRUQOLAWIDGETS_NO_EXPORT int charPosition(const QTextDocument *doc, const PreviewLayout &layout, QRect previewRect, const QPoint &pos);
LIBRUQOLAWIDGETS_NO_EXPORT void drawDescription(const MessageUrl &messageUrl,
QRect previewRect,
QPainter *painter,
- int topPos,
const QModelIndex &index,
- const QStyleOptionViewItem &option) const;
+ const QStyleOptionViewItem &option,
+ const PreviewLayout &layout) const;
[[nodiscard]] LIBRUQOLAWIDGETS_NO_EXPORT QTextDocument *documentForUrlPreview(const MessageUrl &messageUrl) const override;
[[nodiscard]] LIBRUQOLAWIDGETS_NO_EXPORT QPoint relativePos(const QPoint &pos, const PreviewLayout &layout, QRect previewRect) const;
diff --git a/src/widgets/room/delegate/messagelistdelegate.cpp b/src/widgets/room/delegate/messagelistdelegate.cpp
index 93457007e2..b243aa9942 100644
--- a/src/widgets/room/delegate/messagelistdelegate.cpp
+++ b/src/widgets/room/delegate/messagelistdelegate.cpp
@@ -686,7 +686,7 @@ void MessageListDelegate::paint(QPainter *painter, const QStyleOptionViewItem &o
const QList<MessageUrl> messageUrls = message->urls()->messageUrls();
int messageUrlIndex = 0;
for (const MessageUrl &messageUrl : messageUrls) {
- if (messageUrl.hasPreviewUrl()) {
+ if (messageUrl.hasRichPreview()) {
// qDebug() << "messageUrl " << messageUrl;
mHelperUrlPreview.get()->draw(messageUrl, painter, layout.messageUrlsRectList.at(messageUrlIndex), index, option);
}
diff --git a/src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.cpp b/src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.cpp
index 12068155b1..75e34f8aba 100644
--- a/src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.cpp
+++ b/src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.cpp
@@ -153,7 +153,7 @@ void MessageListLayoutBase::generateAttachmentBlockAndUrlPreviewLayout(MessageLi
QSize urlsPreviewSize;
int topUrlPreview = topBlock;
for (const MessageUrl &url : urls) {
- if (url.hasPreviewUrl()) {
+ if (url.hasRichPreview()) {
const MessageDelegateHelperUrlPreview *helperUrlPreview = delegate->helperUrlPreview();
if (urlsPreviewSize.isEmpty()) {
urlsPreviewSize = helperUrlPreview->sizeHint(url, index, maxWidth, option);