diff options
| -rw-r--r-- | CHANGELOG.md | 5 | ||||
| -rw-r--r-- | src/messageview.cpp | 106 | ||||
| -rw-r--r-- | src/messageview.h | 32 | ||||
| -rw-r--r-- | tests/test_messageview.cpp | 126 | ||||
| -rw-r--r-- | translations/qtmaildir_it_IT.ts | 2 |
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'immagine copiato</translation> </message> <message> <source>Details...</source> |
