summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md5
-rw-r--r--src/messageview.cpp106
-rw-r--r--src/messageview.h32
-rw-r--r--tests/test_messageview.cpp126
-rw-r--r--translations/qtmaildir_it_IT.ts2
5 files changed, 258 insertions, 13 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ddbe50..57f215d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,8 +38,9 @@ state.
- The message pane's right-click menu offers **Select all**. Chromium's own menu
for this pane has never carried it.
- Copying from the message pane now says what was copied. Copy, Copy link
- address, Copy image and Copy image address each report in the status bar,
- where they expire like every other transient message.
+ address, Copy image and Copy image address each show a brief confirmation in
+ the bottom right of the pane, beside the gesture rather than at the far end
+ of the window.
## [0.26.0] - 2026-08-19
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 2dba6d1..a7d340a 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -33,6 +33,7 @@
#include <QPushButton>
#include <QStandardPaths>
#include <QtNumeric>
+#include <QResizeEvent>
#include <QTimer>
#include <QTreeWidget>
#include <QVBoxLayout>
@@ -180,13 +181,39 @@ MessageView::MessageView(QWidget *parent)
{ QWebEnginePage::CopyImageUrlToClipboard, QT_TR_NOOP("Copied the image address") },
};
+ // The toast itself, a child of the pane rather than a layout item: it
+ // floats OVER the message, so nothing reflows when it appears and the text
+ // the user just copied does not jump under the cursor.
+ m_copyToast = new QLabel(this);
+ m_copyToast->setObjectName(QStringLiteral("copyToast"));
+ // Plain text, deliberately. The strings are ours, but a label that guesses
+ // under Qt::AutoText is one careless change away from rendering markup,
+ // and this pane's whole job is displaying input from strangers.
+ m_copyToast->setTextFormat(Qt::PlainText);
+ m_copyToast->setAlignment(Qt::AlignCenter);
+ // Opaque, or the message underneath shows through and the confirmation is
+ // unreadable over exactly the content it is confirming.
+ m_copyToast->setAutoFillBackground(true);
+ applyToastPalette();
+ m_copyToast->hide();
+
+ m_copyToastTimer = new QTimer(this);
+ m_copyToastTimer->setSingleShot(true);
+ m_copyToastTimer->setInterval(kToastMs);
+ connect(m_copyToastTimer, &QTimer::timeout,
+ m_copyToast, &QWidget::hide);
+
for (const auto &report : kCopyReports) {
QAction *action = m_view->page()->action(report.action);
if (!action)
continue;
const QString message = tr(report.message);
connect(action, &QAction::triggered, this, [this, message]() {
- emit statusMessage(message);
+ // In the pane, at the user's request, rather than in the status
+ // bar item 115 first used: a copy happens here, and the status bar
+ // is at the other end of the window, so the confirmation was
+ // landing far from the gesture that caused it.
+ showCopyToast(message);
});
}
@@ -773,14 +800,87 @@ void MessageView::showDetailsDialog()
dialog.exec();
}
+void MessageView::applyToastPalette()
+{
+ if (!m_copyToast)
+ return;
+
+ // From the PALETTE, never hardcoded. The pane already re-renders its
+ // document on a PaletteChange so the message follows the desktop theme;
+ // a toast painted in fixed colours would be the one part of the pane that
+ // did not, and would be unreadable under whichever theme it was not
+ // designed for.
+ //
+ // ToolTipBase/ToolTipText specifically: a toast IS a tooltip in everything
+ // but how it is triggered, so this is the role the theme already styles
+ // for "small transient thing floating over content".
+ QPalette toastPalette = m_copyToast->palette();
+ toastPalette.setColor(QPalette::Window,
+ palette().color(QPalette::ToolTipBase));
+ toastPalette.setColor(QPalette::WindowText,
+ palette().color(QPalette::ToolTipText));
+ m_copyToast->setPalette(toastPalette);
+}
+
+void MessageView::showCopyToast(const QString &text)
+{
+ if (!m_copyToast)
+ return;
+
+ // A checkmark, per the user's description. Prepended here rather than
+ // baked into each string so the four messages stay translatable as plain
+ // sentences and the mark cannot go missing from one of them.
+ m_copyToast->setText(QStringLiteral("\u2713 ") + text);
+ m_copyToast->adjustSize();
+ positionToast();
+ m_copyToast->show();
+ m_copyToast->raise();
+
+ // Restarted, not merely started: a second copy while the first toast is up
+ // must get its own full reading time rather than inheriting what is left
+ // of the previous countdown.
+ m_copyToastTimer->start();
+}
+
+void MessageView::positionToast()
+{
+ if (!m_copyToast)
+ return;
+
+ // Anchored to the pane's bottom right, inset by a margin so it does not
+ // touch the edges. Placed against the WIDGET rather than against m_view:
+ // the web view's geometry shifts as the header grows and the attachment
+ // bar appears, and the toast should sit in the same corner regardless.
+ constexpr int margin = 12;
+ const QSize size = m_copyToast->sizeHint();
+ m_copyToast->setGeometry(width() - size.width() - margin,
+ height() - size.height() - margin,
+ size.width(), size.height());
+}
+
+void MessageView::resizeEvent(QResizeEvent *event)
+{
+ QWidget::resizeEvent(event);
+
+ // A hand-placed child does not follow its parent the way a laid-out one
+ // does, so without this the toast stays where the pane used to end.
+ positionToast();
+}
+
void MessageView::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
// Only when there is something to re-render: rendering an empty item list
// would replace a deliberately blank pane with an empty document.
- if (event->type() == QEvent::PaletteChange && !m_items.isEmpty())
- render();
+ if (event->type() == QEvent::PaletteChange) {
+ // The toast follows the theme too, and unconditionally: unlike the
+ // document it has no items to guard against, and a toast left in the
+ // old theme's colours would be unreadable the first time it appeared.
+ applyToastPalette();
+ if (!m_items.isEmpty())
+ render();
+ }
}
void MessageView::render()
diff --git a/src/messageview.h b/src/messageview.h
index 0b18769..09fc905 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -20,6 +20,7 @@
#include <QList>
#include <QUrl>
+#include <QTimer>
#include <QWidget>
#include "htmlbuilder.h"
@@ -97,6 +98,10 @@ public:
/// The body zoom factor. Chromium's own range is roughly 0.25 to 5.0;
/// these are tighter, since a pane at either extreme is unusable and the
/// only visible way back is a menu entry the user cannot read.
+ /// How long the copy confirmation stays up. Long enough to read four
+ /// words, short enough that it is gone before it becomes furniture.
+ static constexpr int kToastMs = 2000;
+
static constexpr qreal kMinZoom = 0.5;
static constexpr qreal kMaxZoom = 3.0;
static constexpr qreal kDefaultZoom = 1.0;
@@ -257,6 +262,9 @@ protected:
/// until the next selection.
void changeEvent(QEvent *event) override;
+ /// Keeps the hand-placed toast anchored to the bottom right.
+ void resizeEvent(QResizeEvent *event) override;
+
private:
void render();
void updateHeader();
@@ -304,6 +312,30 @@ private:
/// different wording or a different pair of operations.
void addSearchEntries(QMenu *menu, const QList<SearchOffer> &offers);
+ /// The copy confirmation, floating over the web view in the pane's bottom
+ /// right rather than in the window's status bar.
+ ///
+ /// A CHILD placed by hand, never a layout item: it must sit on top of the
+ /// message rather than take a strip away from it, so nothing reflows when
+ /// it appears and the text the user just copied does not jump. That is
+ /// also why positionToast() exists and why resizeEvent() is overridden;
+ /// a hand-placed child does not follow its parent the way a laid-out one
+ /// does.
+ QLabel *m_copyToast = nullptr;
+ QTimer *m_copyToastTimer = nullptr;
+
+ /// Paints the toast in the theme's tooltip colours.
+ ///
+ /// Re-applied on a PaletteChange, so it follows the desktop theme the way
+ /// the rendered document already does.
+ void applyToastPalette();
+
+ /// Shows the toast with `text` and restarts its countdown.
+ void showCopyToast(const QString &text);
+
+ /// Puts the toast in the pane's bottom right, inside the margins.
+ void positionToast();
+
QList<ThreadRenderItem> m_items;
bool m_preferHtml = true;
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index df41dd1..3d21536 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -60,6 +60,8 @@ private slots:
void theBodyMenuDropsTheBrowsersOwnActions();
void theBodyMenuOffersSelectAll();
void aCopyFromThePaneReportsWhatWasCopied();
+ void theCopyToastAppearsOverThePaneAndFades();
+ void theCopyToastStaysAnchoredWhenThePaneResizes();
void aSearchFromTheDetailsDialogClosesIt();
private:
@@ -825,7 +827,13 @@ void TestMessageView::aCopyFromThePaneReportsWhatWasCopied()
{
// Item 115. Copy link address, Copy image address and Copy image all work
// and none of them said so. Chromium does not report success, so the pane
- // listens to its actions and emits the pane's own status message.
+ // listens to its actions and shows its own confirmation.
+ //
+ // Reported through the in-pane TOAST since the user asked for it there
+ // rather than in the status bar: a copy happens in the pane, and the
+ // status bar is at the other end of the window. This test is about the
+ // four entries each saying something DIFFERENT; where it is displayed is
+ // theCopyToastAppearsOverThePaneAndFades().
//
// Unlike item 117's entry, this IS fully testable: the connections are made
// to the page's own QActions in the constructor, so triggering one runs the
@@ -834,8 +842,8 @@ void TestMessageView::aCopyFromThePaneReportsWhatWasCopied()
auto *page = view.findChild<QWebEnginePage *>();
QVERIFY2(page, "no page, so this test would assert nothing");
- QSignalSpy spy(&view, &MessageView::statusMessage);
- QVERIFY(spy.isValid());
+ auto *toast = view.findChild<QLabel *>(QStringLiteral("copyToast"));
+ QVERIFY2(toast, "there is no copy toast at all");
// Each entry names WHAT was copied. "Copied" alone is worse than nothing
// when three entries sit together in one menu, which the item states as a
@@ -858,12 +866,11 @@ void TestMessageView::aCopyFromThePaneReportsWhatWasCopied()
// nothing while looking thorough.
action->setEnabled(true);
- spy.clear();
+ toast->clear();
action->trigger();
- QTRY_VERIFY_WITH_TIMEOUT(spy.count() == 1, 5000);
- const QString message = spy.takeFirst().at(0).toString();
- QVERIFY2(!message.isEmpty(), "a copy reported an empty status message");
+ QTRY_VERIFY_WITH_TIMEOUT(!toast->text().isEmpty(), 5000);
+ const QString message = toast->text();
seen.append(message);
}
@@ -872,6 +879,111 @@ void TestMessageView::aCopyFromThePaneReportsWhatWasCopied()
QCOMPARE(QSet<QString>(seen.cbegin(), seen.cend()).size(), copies.size());
}
+void TestMessageView::theCopyToastAppearsOverThePaneAndFades()
+{
+ // The user's preference, given after item 115 shipped the status-bar
+ // version: "a small transient with a checkmark in the bottom right of the
+ // message pane". A copy happens IN the pane, and the status bar is at the
+ // other end of the window, so the confirmation was landing far from the
+ // gesture that caused it.
+ MessageView view;
+ view.resize(600, 400);
+ view.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&view));
+
+ auto *page = view.findChild<QWebEnginePage *>();
+ auto *toast = view.findChild<QLabel *>(QStringLiteral("copyToast"));
+ QVERIFY2(page, "no page, so this test would assert nothing");
+ QVERIFY2(toast, "there is no copy toast at all");
+
+ // Hidden until something is copied: a confirmation that is always visible
+ // confirms nothing.
+ QVERIFY2(!toast->isVisible(), "the toast is showing before anything was copied");
+
+ QAction *copy = page->action(QWebEnginePage::CopyLinkToClipboard);
+ QVERIFY(copy);
+ // Chromium disables a copy action when there is nothing of that kind under
+ // the cursor, and trigger() on a disabled QAction emits nothing at all.
+ copy->setEnabled(true);
+ copy->trigger();
+
+ QTRY_VERIFY_WITH_TIMEOUT(toast->isVisible(), 5000);
+ // It says WHAT was copied, not merely that something was, which is the
+ // constraint item 115 already carried: three copy entries sit together in
+ // one menu.
+ QVERIFY2(toast->text().contains(QStringLiteral("link")),
+ qPrintable(QStringLiteral("the toast says '%1'").arg(toast->text())));
+
+ // Opaque and theme-coloured. A transparent label over a rendered message
+ // is unreadable against exactly the content it is confirming, and a
+ // geometry assertion cannot see that: the rect is correct either way.
+ QVERIFY2(toast->autoFillBackground(),
+ "the toast is transparent, so it reads over the message body");
+ QCOMPARE(toast->palette().color(QPalette::Window),
+ view.palette().color(QPalette::ToolTipBase));
+
+ // Bottom right of the pane, inside it rather than beside it.
+ const QRect paneRect = view.rect();
+ const QRect toastRect = toast->geometry();
+ QVERIFY2(toastRect.right() <= paneRect.right(),
+ "the toast hangs off the right edge of the pane");
+ QVERIFY2(toastRect.bottom() <= paneRect.bottom(),
+ "the toast hangs off the bottom edge of the pane");
+ QVERIFY2(toastRect.center().x() > paneRect.center().x(),
+ "the toast is not in the right half of the pane");
+ QVERIFY2(toastRect.center().y() > paneRect.center().y(),
+ "the toast is not in the bottom half of the pane");
+
+ // And it goes away on its own. Transient is the whole point: a
+ // confirmation the user has to dismiss is worse than none.
+ QTRY_VERIFY_WITH_TIMEOUT(!toast->isVisible(),
+ int(MessageView::kToastMs) + 4000);
+}
+
+void TestMessageView::theCopyToastStaysAnchoredWhenThePaneResizes()
+{
+ // A manually positioned child does not follow its parent, unlike a widget
+ // in a layout. The toast cannot BE in the layout, since it floats over the
+ // web view rather than taking space from it, so the anchoring is this
+ // class's job and a resize is where that breaks.
+ MessageView view;
+ view.resize(600, 400);
+ view.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&view));
+
+ auto *page = view.findChild<QWebEnginePage *>();
+ auto *toast = view.findChild<QLabel *>(QStringLiteral("copyToast"));
+ QVERIFY(page && toast);
+
+ QAction *copy = page->action(QWebEnginePage::Copy);
+ QVERIFY(copy);
+ copy->setEnabled(true);
+ copy->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(toast->isVisible(), 5000);
+
+ // SHRUNK, not grown, and that distinction is the whole test. Growing the
+ // pane moves its right and bottom edges AWAY, so a toast left at the old
+ // position still satisfies "inside the pane" and the assertions below pass
+ // against a toast that never moved. Measured: with the reposition deleted,
+ // a 600x400 -> 900x700 resize left this test green.
+ //
+ // Shrinking puts the stale position outside the new rect, which is also
+ // what the user would actually see: a confirmation half off the pane.
+ view.resize(360, 240);
+ // The guard: the pane really did change size, so the assertion below is
+ // about the toast following rather than about nothing having moved.
+ QTRY_COMPARE_WITH_TIMEOUT(view.width(), 360, 5000);
+
+ const QRect paneRect = view.rect();
+ const QRect toastRect = toast->geometry();
+ QVERIFY2(toastRect.right() <= paneRect.right(),
+ "the toast did not follow the pane's right edge");
+ QVERIFY2(toastRect.center().x() > paneRect.center().x(),
+ "the toast is stranded in the left half after a resize");
+ QVERIFY2(toastRect.center().y() > paneRect.center().y(),
+ "the toast is stranded in the top half after a resize");
+}
+
void TestMessageView::aSearchFromTheDetailsDialogClosesIt()
{
// The dialog is modal. Without closing it, the query runs and the thread
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index e7a4b49..68b42f7 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -982,7 +982,7 @@
</message>
<message>
<source>Copied the image address</source>
- <translation>Indirizzo dell'immagine copiato</translation>
+ <translation>Indirizzo dell&apos;immagine copiato</translation>
</message>
<message>
<source>Details...</source>