aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-01 10:49:18 +0200
committerDanilo M. <danix@danix.xyz>2026-10-01 10:49:18 +0200
commit9ae9473acdb7cb3310c3028765cc4b30fedfce27 (patch)
tree438576dd9a5b9e4bfc060e3cd22310f3e03a6130
parent107933409c9044a248ef483734176cbde03cc137 (diff)
downloadqtmaildir-9ae9473acdb7cb3310c3028765cc4b30fedfce27.tar.gz
qtmaildir-9ae9473acdb7cb3310c3028765cc4b30fedfce27.zip
feat: scroll the thread list smoothlyHEADmaster
The list scrolled per ITEM, Qt's default, so a wheel notch jumped several whole cards at once and a touchpad's small deltas were quantised into card-sized jumps. The eye lost track of how far the list had moved. The view now scrolls per pixel, so a touchpad moves it continuously, and ThreadListView::wheelEvent turns each wheel notch into a 160ms eased glide of exactly one card. Notches arriving mid-glide add to its target rather than restarting from where it has got to. Pixel deltas and modified wheels pass through to Qt unchanged. The cost of per-pixel scrolling is that a card can rest half above the top edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md2
-rw-r--r--src/mainwindow.cpp9
-rw-r--r--src/threadlistview.cpp33
-rw-r--r--src/threadlistview.h13
-rw-r--r--tests/test_mainwindow.cpp48
5 files changed, 103 insertions, 2 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31ad3d8..21744de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,8 @@ point at which they are stable.
### Changed
- The status bar renders its messages as plain text.
+- The thread list scrolls smoothly. A mouse wheel notch glides one card
+ instead of jumping several, and a touchpad moves the list continuously.
### Fixed
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 19373e3..e93898c 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -962,8 +962,13 @@ void MainWindow::buildUi()
// the viewport and auto-scroll brought the clicked index fully into view.
m_threadView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
- // Scrolling a whole card at a time rather than a fraction of one, so a
- // card is never left half above the top edge.
+ // Per PIXEL, so a touchpad moves the list continuously. Qt's default is
+ // per ITEM, which jumped several whole cards per wheel notch and quantised
+ // a touchpad's small deltas into card-sized jumps, so the eye lost track
+ // of how far the list had moved. The cost is that a card can rest half
+ // above the top edge. A wheel notch glides one step, which is one card;
+ // see ThreadListView::wheelEvent.
+ m_threadView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
m_threadView->verticalScrollBar()->setSingleStep(
CardLayout::heightFor(m_threadView->font()));
diff --git a/src/threadlistview.cpp b/src/threadlistview.cpp
index 019d7c9..b5176bf 100644
--- a/src/threadlistview.cpp
+++ b/src/threadlistview.cpp
@@ -22,6 +22,9 @@
#include "threadlistmodel.h"
#include <QMouseEvent>
+#include <QPropertyAnimation>
+#include <QScrollBar>
+#include <QWheelEvent>
void ThreadListView::mousePressEvent(QMouseEvent *event)
{
@@ -53,3 +56,33 @@ void ThreadListView::mousePressEvent(QMouseEvent *event)
QTreeView::mousePressEvent(event);
}
+
+void ThreadListView::wheelEvent(QWheelEvent *event)
+{
+ const int notches = event->angleDelta().y() / QWheelEvent::DefaultDeltasPerStep;
+ if (!event->pixelDelta().isNull() || event->modifiers() != Qt::NoModifier
+ || notches == 0) {
+ // ponytail: a high-resolution wheel sending less than one notch per
+ // event falls through to the plain per-pixel scroll, unanimated.
+ QTreeView::wheelEvent(event);
+ return;
+ }
+
+ QScrollBar *bar = verticalScrollBar();
+ if (!m_glide) {
+ m_glide = new QPropertyAnimation(bar, "value", this);
+ m_glide->setDuration(160);
+ m_glide->setEasingCurve(QEasingCurve::OutCubic);
+ }
+
+ const bool gliding = m_glide->state() == QAbstractAnimation::Running;
+ const int from = gliding ? m_glide->endValue().toInt() : bar->value();
+ const int to = qBound(bar->minimum(), from - notches * bar->singleStep(),
+ bar->maximum());
+
+ m_glide->stop();
+ m_glide->setStartValue(bar->value());
+ m_glide->setEndValue(to);
+ m_glide->start();
+ event->accept();
+}
diff --git a/src/threadlistview.h b/src/threadlistview.h
index a509389..79db424 100644
--- a/src/threadlistview.h
+++ b/src/threadlistview.h
@@ -20,6 +20,8 @@
#include <QTreeView>
+class QPropertyAnimation;
+
/// The thread list.
///
/// It exists for ONE reason now: the expander is drawn by CardDelegate, and a
@@ -47,4 +49,15 @@ protected:
/// indicator underneath, also removed the style's hit area, so an expander
/// once painted correctly and did nothing at all.
void mousePressEvent(QMouseEvent *event) override;
+
+ /// A mouse wheel notch GLIDES one scrollbar step instead of jumping, so
+ /// the eye can follow how far the list moved. Notches arriving mid-glide
+ /// add to its target rather than restarting from where it has got to. A
+ /// touchpad's pixel deltas are already continuous and pass straight
+ /// through, as does any wheel with a modifier held.
+ void wheelEvent(QWheelEvent *event) override;
+
+private:
+ /// Created on the first notch, owned by the view.
+ QPropertyAnimation *m_glide = nullptr;
};
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 7ef89e7..2b7008d 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -409,6 +409,7 @@ private slots:
void theStatusBarFollowsTheSyncPhase();
void aSelectedReadThreadIsNotDimmedIntoTheHighlight();
void childRowsAreIndentedUnderTheirThread();
+ void oneWheelNotchGlidesOneCard();
void aThreadWithRepliesDrawsAVisibleExpander();
void cardsNeverScrollSideways();
void selectingAConversationArmsNoMarkRead();
@@ -1157,6 +1158,53 @@ static ThreadSummary makeThread(const QString &id, const QStringList &tags)
return thread;
}
+void TestMainWindow::oneWheelNotchGlidesOneCard()
+{
+ // The list scrolled per ITEM, Qt's default, so a notch jumped several
+ // whole cards and a touchpad's small deltas were quantised into card-sized
+ // jumps: the user lost track of how far the list had moved. Per PIXEL,
+ // one notch moves exactly one card.
+ const Config config;
+ MainWindow window(config);
+ window.resize(800, 600);
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+
+ QVector<ThreadSummary> threads;
+ for (int i = 0; i < 50; ++i)
+ threads.append(makeThread(QStringLiteral("t%1").arg(i), {}));
+ model->appendBatch(threads);
+
+ QCOMPARE(view->verticalScrollMode(), QAbstractItemView::ScrollPerPixel);
+
+ QScrollBar *bar = view->verticalScrollBar();
+ // The range is laid out lazily after the rows arrive.
+ QTRY_VERIFY2(bar->maximum() > 0,
+ "nothing to scroll, so the test measures nothing");
+ QCOMPARE(bar->value(), 0);
+
+ const QPoint centre = view->viewport()->rect().center();
+ QWheelEvent notch(centre, view->viewport()->mapToGlobal(centre), QPoint(),
+ QPoint(0, -QWheelEvent::DefaultDeltasPerStep),
+ Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false);
+ QApplication::sendEvent(view->viewport(), &notch);
+
+ // A glide, not a jump: the first notch is still on its way.
+ const int card = CardLayout::heightFor(view->font());
+ QVERIFY2(bar->value() < card, "the notch jumped rather than glided");
+ QTRY_COMPARE(bar->value(), card);
+
+ // A second notch sent mid-glide lands on the NEXT card, not short of it.
+ QApplication::sendEvent(view->viewport(), &notch);
+ QApplication::sendEvent(view->viewport(), &notch);
+ QTRY_COMPARE(bar->value(), 3 * card);
+}
+
void TestMainWindow::childRowsAreIndentedUnderTheirThread()
{
const Config config;