From 2c51c9653e4e045373120d50ce026f0a752a608f Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 28 Aug 2026 18:14:58 +0200 Subject: feat: add the thread dashboard A widget over a ThreadDigest: header, tags, counts, the unread list capped with a link to the rest, and an activity sparkline, scrolling under a pinned action strip. It invents no colours. ThreadDigest::unread is QVector rather than QVector. The dashboard draws rows for messages it never opens, which is what MessageNode exists for; MessageRef carries only what the pane needs once a message is already open and has no subject, sender or date. The struct's no-file-opening contract still holds, since all four of those fields are served from the notmuch index. --- src/CMakeLists.txt | 1 + src/threaddashboard.cpp | 479 ++++++++++++++++++++++++++++++++++++++++++++++++ src/threaddashboard.h | 150 +++++++++++++++ src/threaddigest.h | 60 ++++++ 4 files changed, 690 insertions(+) create mode 100644 src/threaddashboard.cpp create mode 100644 src/threaddashboard.h create mode 100644 src/threaddigest.h (limited to 'src') diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 85b5e56..b933f3d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -29,6 +29,7 @@ add_library(qtmaildir_lib STATIC tagrules.cpp tagrulesdialog.cpp tagstrip.cpp + threaddashboard.cpp threadlistmodel.cpp threadlistview.cpp mailsync.cpp diff --git a/src/threaddashboard.cpp b/src/threaddashboard.cpp new file mode 100644 index 0000000..8583379 --- /dev/null +++ b/src/threaddashboard.cpp @@ -0,0 +1,479 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include "threaddashboard.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "avatar.h" +#include "cardlayout.h" +#include "tagstrip.h" + +namespace { + +/// The gap between blocks. One constant so the pane reads as evenly spaced +/// rather than as whatever each block happened to ask for. +constexpr int kBlockSpacing = 12; + +/// The avatar beside a participant and beside an unread entry. +int avatarSide(const QFont &font) +{ + // From the font's metrics, not a fixed pixel count, for the reason + // CardLayout::markSide records: qt6ct sets fonts in PIXELS, where + // pointSizeF() returns -1, so the metric is what can be measured. + return qMax(16, QFontMetrics(font).height() + 6); +} + +/// How long ago `when` was, in words. +/// +/// Deliberately coarse. The dashboard answers "is this stale", not "at what +/// minute did this arrive", and the exact timestamp is one click away on the +/// message itself. +QString relativeTime(const QDateTime &when) +{ + if (!when.isValid()) + return QString(); + + const qint64 seconds = when.secsTo(QDateTime::currentDateTime()); + if (seconds < 60) + return ThreadDashboard::tr("just now"); + if (seconds < 3600) + return ThreadDashboard::tr("%1 min ago").arg(seconds / 60); + if (seconds < 86400) + return ThreadDashboard::tr("%1 h ago").arg(seconds / 3600); + if (seconds < 86400 * 30) + return ThreadDashboard::tr("%1 d ago").arg(seconds / 86400); + return CardLayout::formatDate(when); +} + +/// A label for a value that came from a stranger. +/// +/// Qt::PlainText EXPLICITLY, on every one of them. A QLabel guesses under +/// Qt::AutoText, so a subject or a display name carrying markup would be +/// interpreted rather than shown. This is the same security property +/// MessageDetailsDialog states at each of its value labels, and it is not a +/// style choice: escaping into a rich-text label is the same protection one +/// mistake away from failing. +QLabel *untrustedLabel(const QString &text) +{ + auto *label = new QLabel(text); + label->setTextFormat(Qt::PlainText); + return label; +} + +} // namespace + +ActivitySparkline::ActivitySparkline(QWidget *parent) : QWidget(parent) +{ + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void ActivitySparkline::setBuckets(const QVector &buckets, int busiest) +{ + m_buckets = buckets; + m_busiest = busiest; + update(); +} + +QSize ActivitySparkline::sizeHint() const +{ + // Scaled off the font rather than fixed, so the bars keep their proportion + // to the text beside them at any desktop font size. + return QSize(120, qMax(24, QFontMetrics(font()).height() * 2)); +} + +void ActivitySparkline::paintEvent(QPaintEvent *) +{ + if (m_buckets.isEmpty()) + return; + + int tallest = 0; + for (int n : m_buckets) + tallest = qMax(tallest, n); + if (tallest <= 0) + return; + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, false); + + // Two colours, both already meaning something: the palette's text at low + // opacity for the bars, and its highlight for the busiest bucket. A bucket + // is a quantity, not a category, so nothing here is a new colour. + QColor bar = palette().color(QPalette::Text); + bar.setAlpha(90); + const QColor busy = palette().color(QPalette::Highlight); + + const int gap = 2; + const int count = m_buckets.size(); + const int available = width() - gap * (count - 1); + if (available <= 0) + return; + const int barWidth = qMax(1, available / count); + + for (int i = 0; i < count; ++i) { + const int x = i * (barWidth + gap); + // A non-empty bucket always gets at least one pixel, so "some activity" + // and "none at all" cannot render identically. + const int barHeight = + m_buckets.at(i) == 0 + ? 0 + : qMax(1, m_buckets.at(i) * height() / tallest); + if (barHeight == 0) + continue; + painter.fillRect(QRect(x, height() - barHeight, barWidth, barHeight), + i == m_busiest ? busy : bar); + } +} + +ThreadDashboard::ThreadDashboard(QWidget *parent) : QWidget(parent) +{ + auto *outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + + // The content scrolls; the action strip below is its SIBLING, outside the + // scroll area, so the actions stay on the pane's bottom edge. Hunting for + // them past a long participant list is the case the scrolling exists for. + auto *scroll = new QScrollArea; + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + outer->addWidget(scroll, 1); + + auto *content = new QWidget; + auto *layout = new QVBoxLayout(content); + layout->setContentsMargins(12, 12, 12, 12); + layout->setSpacing(kBlockSpacing); + scroll->setWidget(content); + + // Block 1: the header. + m_subject = untrustedLabel(QString()); + m_subject->setWordWrap(true); + QFont subjectFont = m_subject->font(); + subjectFont.setBold(true); + m_subject->setFont(subjectFont); + layout->addWidget(m_subject); + + m_summaryLine = new QLabel; + m_summaryLine->setTextFormat(Qt::PlainText); + layout->addWidget(m_summaryLine); + + // The account label is configured by the user rather than received from a + // stranger, but it is shown as plain text like everything else here: one + // rule for the pane is easier to keep than an exception per label. + m_account = untrustedLabel(QString()); + layout->addWidget(m_account); + + m_participants = new QWidget; + m_participantsLayout = new QVBoxLayout(m_participants); + m_participantsLayout->setContentsMargins(0, 0, 0, 0); + m_participantsLayout->setSpacing(4); + layout->addWidget(m_participants); + + // Block 2: the tag chips. One tier, no muting, drawn by the same strip the + // message pane uses so the two cannot drift. + m_tags = new TagStrip; + layout->addWidget(m_tags); + + // Block 3: the counts, with the read-progress bar beneath. + m_counts = new QLabel; + m_counts->setTextFormat(Qt::PlainText); + layout->addWidget(m_counts); + + m_progress = new QProgressBar; + m_progress->setTextVisible(false); + // Deliberately short: it is a proportion, not a control. The palette's + // highlight fills it, which is the one colour a quantity gets here. + m_progress->setMaximumHeight(6); + layout->addWidget(m_progress); + + // Block 4: Waiting for you. + m_waitingHeading = new QLabel(tr("Waiting for you")); + m_waitingHeading->setTextFormat(Qt::PlainText); + QFont headingFont = m_waitingHeading->font(); + headingFont.setBold(true); + m_waitingHeading->setFont(headingFont); + layout->addWidget(m_waitingHeading); + + m_unreadBlock = new QWidget; + m_unreadLayout = new QVBoxLayout(m_unreadBlock); + m_unreadLayout->setContentsMargins(0, 0, 0, 0); + m_unreadLayout->setSpacing(6); + layout->addWidget(m_unreadBlock); + + m_moreLink = new QPushButton; + m_moreLink->setFlat(true); + m_moreLink->setCursor(Qt::PointingHandCursor); + connect(m_moreLink, &QPushButton::clicked, this, + &ThreadDashboard::expandRequested); + layout->addWidget(m_moreLink); + + m_allCaughtUp = new QLabel(tr("All caught up")); + m_allCaughtUp->setTextFormat(Qt::PlainText); + layout->addWidget(m_allCaughtUp); + + // Block 5: activity. + m_sparkline = new ActivitySparkline; + layout->addWidget(m_sparkline); + + m_activityLine = new QLabel; + m_activityLine->setTextFormat(Qt::PlainText); + layout->addWidget(m_activityLine); + + layout->addStretch(1); + + // Block 6: the pinned action strip. + auto *actions = new QWidget; + auto *actionsLayout = new QHBoxLayout(actions); + actionsLayout->setContentsMargins(12, 6, 12, 12); + actionsLayout->setSpacing(6); + + auto *markRead = new QPushButton(tr("Mark all read")); + connect(markRead, &QPushButton::clicked, this, + &ThreadDashboard::markAllReadRequested); + actionsLayout->addWidget(markRead); + + auto *archive = new QPushButton(tr("Archive")); + connect(archive, &QPushButton::clicked, this, + &ThreadDashboard::archiveRequested); + actionsLayout->addWidget(archive); + + auto *remove = new QPushButton(tr("Delete")); + connect(remove, &QPushButton::clicked, this, + &ThreadDashboard::deleteRequested); + actionsLayout->addWidget(remove); + + actionsLayout->addStretch(1); + outer->addWidget(actions, 0); + + setDigest(ThreadDigest()); +} + +void ThreadDashboard::setTagColors(const TagColors *colours) +{ + m_tags->setTagColors(colours); +} + +void ThreadDashboard::setThreadHeading(const QString &subject, + const QString &accountLabel) +{ + m_subject->setText(subject.isEmpty() ? tr("(no subject)") : subject); + m_account->setText(accountLabel); + m_account->setVisible(!accountLabel.isEmpty()); +} + +void ThreadDashboard::setTags(const QStringList &tags) +{ + m_tags->setTags(tags); + m_tags->setVisible(!tags.isEmpty()); +} + +int ThreadDashboard::hiddenUnreadCount() const +{ + return qMax(0, m_digest.unreadTotal - m_unreadShown.size()); +} + +void ThreadDashboard::setDigest(const ThreadDigest &digest) +{ + m_digest = digest; + + // The cap belongs here as well as in the worker. The worker truncates so + // the value crossing the boundary stays small; this one is what makes the + // accessors truthful whatever a caller hands over, which is what task 9 + // and the tests read. + m_unreadShown = digest.unread; + if (m_unreadShown.size() > ThreadDigest::kUnreadShown) + m_unreadShown.resize(ThreadDigest::kUnreadShown); + + const int people = digest.senders.size(); + QString span; + if (digest.firstTimestamp > 0 && digest.lastTimestamp > 0) { + const qint64 days = + (digest.lastTimestamp - digest.firstTimestamp) / 86400; + span = days <= 0 ? tr("today") : tr("%n day(s)", nullptr, int(days)); + } + if (span.isEmpty()) { + m_summaryLine->setText(tr("%n person(s)", nullptr, people)); + } else { + // Two facts joined by a separator, each already translated. Building + // the sentence from a format string keeps the word order the + // translator's to choose. + m_summaryLine->setText( + tr("%1 · %2").arg(tr("%n person(s)", nullptr, people), span)); + } + + rebuildParticipants(); + + m_counts->setText(tr("%n message(s)", nullptr, digest.totalCount) + + QStringLiteral(" · ") + + tr("%n unread", nullptr, digest.unreadTotal)); + + // The bar shows what has been READ, so a thread with nothing unread reads + // as full rather than empty. + m_progress->setMaximum(qMax(1, digest.totalCount)); + m_progress->setValue(qMax(0, digest.totalCount - digest.unreadTotal)); + m_progress->setVisible(digest.totalCount > 0); + + rebuildUnread(); + + m_sparkline->setBuckets(digest.buckets, digest.busiestBucket); + + const bool haveSpan = digest.firstTimestamp > 0 && digest.lastTimestamp > 0; + m_sparkline->setVisible(haveSpan); + m_activityLine->setVisible(haveSpan); + if (haveSpan) { + const QDateTime first = + QDateTime::fromSecsSinceEpoch(digest.firstTimestamp); + const QDateTime last = + QDateTime::fromSecsSinceEpoch(digest.lastTimestamp); + QString line = tr("%1 → %2") + .arg(CardLayout::formatDate(first), + CardLayout::formatDate(last)); + if (digest.busiestBucket >= 0 && + digest.busiestBucket < digest.buckets.size() && + digest.buckets.at(digest.busiestBucket) > 0) { + // Which bucket was busiest, named by the date it starts on: the + // bucket size varies with the thread's span, so a bucket INDEX + // means nothing to the reader and the date is the only honest + // label. + const qint64 bucketSpan = + qMax(1, (digest.lastTimestamp - digest.firstTimestamp) / + ThreadDigest::kBuckets); + const QDateTime busiest = QDateTime::fromSecsSinceEpoch( + digest.firstTimestamp + bucketSpan * digest.busiestBucket); + line += QStringLiteral(" · ") + + tr("busiest %1").arg(CardLayout::formatDate(busiest)); + } + m_activityLine->setText(line); + } +} + +void ThreadDashboard::rebuildParticipants() +{ + while (QLayoutItem *item = m_participantsLayout->takeAt(0)) { + delete item->widget(); + delete item; + } + + const int side = avatarSide(font()); + for (const auto &sender : m_digest.senders) { + auto *row = new QWidget; + auto *rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(0, 0, 0, 0); + rowLayout->setSpacing(6); + + // Avatar::pixmapFor unchanged, so a participant is the same squircle + // and the same colour they carry on every card in the list. The + // dashboard chooses nothing here. + auto *avatar = new QLabel; + avatar->setPixmap(Avatar::pixmapFor( + sender.first, + Avatar::initialsFor(sender.first, sender.first, QString()), + Avatar::fillFor(sender.first, false), side, font())); + avatar->setFixedSize(side, side); + rowLayout->addWidget(avatar); + + auto *name = untrustedLabel(sender.first); + rowLayout->addWidget(name, 1); + + auto *count = new QLabel(tr("%n message(s)", nullptr, sender.second)); + count->setTextFormat(Qt::PlainText); + rowLayout->addWidget(count); + + m_participantsLayout->addWidget(row); + } + m_participants->setVisible(!m_digest.senders.isEmpty()); +} + +void ThreadDashboard::rebuildUnread() +{ + while (QLayoutItem *item = m_unreadLayout->takeAt(0)) { + delete item->widget(); + delete item; + } + + const bool caughtUp = showingAllCaughtUp(); + m_allCaughtUp->setVisible(caughtUp); + m_waitingHeading->setVisible(!caughtUp); + m_unreadBlock->setVisible(!caughtUp); + + const int hidden = hiddenUnreadCount(); + m_moreLink->setVisible(!caughtUp && hidden > 0); + if (hidden > 0) + m_moreLink->setText(tr("+%n more", nullptr, hidden)); + + if (caughtUp) + return; + + const int side = avatarSide(font()); + for (const MessageNode &node : std::as_const(m_unreadShown)) { + // A button rather than a label, so the entry is clickable and + // keyboard-reachable without the pane growing an event filter. Flat, + // so it reads as a row rather than as a control. + auto *entry = new QPushButton; + entry->setFlat(true); + entry->setCursor(Qt::PointingHandCursor); + entry->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + auto *entryLayout = new QHBoxLayout(entry); + entryLayout->setContentsMargins(4, 2, 4, 2); + entryLayout->setSpacing(6); + + auto *avatar = new QLabel; + avatar->setAttribute(Qt::WA_TransparentForMouseEvents); + avatar->setPixmap(Avatar::pixmapFor( + node.senderAddress, + Avatar::initialsFor(node.from, node.senderAddress, QString()), + Avatar::fillFor(node.from, false), side, font())); + avatar->setFixedSize(side, side); + entryLayout->addWidget(avatar); + + // Sender, then subject: both come from a stranger, so both are + // explicitly plain text. + const QString who = + node.from.isEmpty() ? node.senderAddress : node.from; + auto *text = untrustedLabel( + who.isEmpty() ? node.subject + : QStringLiteral("%1 · %2").arg(who, node.subject)); + text->setAttribute(Qt::WA_TransparentForMouseEvents); + entryLayout->addWidget(text, 1); + + const QString when = relativeTime(node.date); + if (!when.isEmpty()) { + auto *age = new QLabel(when); + age->setTextFormat(Qt::PlainText); + age->setAttribute(Qt::WA_TransparentForMouseEvents); + entryLayout->addWidget(age); + } + + const QString messageId = node.messageId; + connect(entry, &QPushButton::clicked, this, + [this, messageId] { emit messageActivated(messageId); }); + + m_unreadLayout->addWidget(entry); + } +} diff --git a/src/threaddashboard.h b/src/threaddashboard.h new file mode 100644 index 0000000..0fe9773 --- /dev/null +++ b/src/threaddashboard.h @@ -0,0 +1,150 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#pragma once + +#include +#include + +#include "threaddigest.h" +#include "types.h" + +class TagColors; +class TagStrip; +class QLabel; +class QProgressBar; +class QPushButton; +class QVBoxLayout; + +/// The activity histogram, as its own widget. +/// +/// Separate because it is the one part of the dashboard that must paint, and +/// keeping it apart is what leaves the rest of the pane assertable without a +/// painter. It draws ThreadDigest::buckets at a fixed height, one colour for +/// the bars with the palette's highlight on the busiest. +class ActivitySparkline : public QWidget +{ + Q_OBJECT +public: + explicit ActivitySparkline(QWidget *parent = nullptr); + + void setBuckets(const QVector &buckets, int busiest); + + QSize sizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + QVector m_buckets; + int m_busiest = -1; +}; + +/// The right pane for a THREAD row: what a conversation is, rather than one of +/// its messages. +/// +/// A thread row stands for the conversation, so there is no single message to +/// render. This shows the conversation itself: who is in it, what it is +/// tagged, how much of it is unread, which messages are still waiting, and +/// when it was busy. +/// +/// Built once in the constructor and repopulated by setDigest(), so switching +/// rows rebuilds no widgets. +/// +/// **It invents no colours.** Participants reuse Avatar::pixmapFor(), tags use +/// TagColors::colourFor() through a TagStrip, and the progress bar and the +/// sparkline use the palette's highlight. Every colour here already means the +/// same thing somewhere else in the application. +class ThreadDashboard : public QWidget +{ + Q_OBJECT +public: + explicit ThreadDashboard(QWidget *parent = nullptr); + + /// Not owned; must outlive the dashboard. Forwarded to the tag strip. + void setTagColors(const TagColors *colours); + + /// The thread's subject and account, which the digest does not carry: it + /// is built from the index by the worker, while these are already on the + /// summary the list holds. Call before or after setDigest(), either way. + void setThreadHeading(const QString &subject, const QString &accountLabel); + + /// The thread's tags, from the summary. The digest carries none: a + /// thread's tags are notmuch's union and the model already has them. + void setTags(const QStringList &tags); + + void setDigest(const ThreadDigest &digest); + + /// How many unread entries the Waiting-for-you block actually lists. + /// + /// This and the two below exist so the pane's content is assertable + /// without rendering it. A probe that renders and counts pixels proves + /// nothing here, for every reason under "Rendering probes lie". + int unreadCountShown() const { return m_unreadShown.size(); } + + /// How many unread messages the block could not list. Zero when the list + /// is complete, which is also when no "+N more" link is shown. + /// + /// Taken from unreadTotal rather than from the list's own size, because + /// the list is capped and a count derived from it would under-report on + /// exactly the threads where the number matters. + int hiddenUnreadCount() const; + + /// True when the thread has nothing unread, so the block is replaced by a + /// single "All caught up" line. + bool showingAllCaughtUp() const { return m_digest.unreadTotal == 0; } + +signals: + /// An unread entry was clicked. The pane is a dead end without this: it + /// has just told the user what they have not read. + void messageActivated(const QString &messageId); + + /// The "+N more" link was clicked. Expands the thread in the LEFT pane, + /// where the full list already lives, and leaves the selection alone. + void expandRequested(); + + void markAllReadRequested(); + void archiveRequested(); + void deleteRequested(); + +private: + /// Rebuilds the Waiting-for-you block from m_digest. + void rebuildUnread(); + + /// Rebuilds the participants row from m_digest.senders. + void rebuildParticipants(); + + ThreadDigest m_digest; + QVector m_unreadShown; + + QLabel *m_subject = nullptr; + QLabel *m_summaryLine = nullptr; ///< "N people, N days". + QLabel *m_account = nullptr; + QWidget *m_participants = nullptr; + QVBoxLayout *m_participantsLayout = nullptr; + TagStrip *m_tags = nullptr; + QLabel *m_counts = nullptr; + QProgressBar *m_progress = nullptr; + QLabel *m_waitingHeading = nullptr; + QWidget *m_unreadBlock = nullptr; + QVBoxLayout *m_unreadLayout = nullptr; + QLabel *m_allCaughtUp = nullptr; + QPushButton *m_moreLink = nullptr; + ActivitySparkline *m_sparkline = nullptr; + QLabel *m_activityLine = nullptr; +}; diff --git a/src/threaddigest.h b/src/threaddigest.h new file mode 100644 index 0000000..e78a2ac --- /dev/null +++ b/src/threaddigest.h @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Copyright (C) 2026 Danilo M. + */ +#ifndef QTMAILDIR_THREADDIGEST_H +#define QTMAILDIR_THREADDIGEST_H + +#include +#include +#include +#include +#include + +#include "types.h" + +/// What the thread dashboard shows, as a plain value. +/// +/// Crosses the worker boundary like every other structure here: no +/// notmuch_* pointer, no widget, no model. Everything in it is read from the +/// INDEX, so building one opens no message files. +struct ThreadDigest +{ + QString threadId; + + /// Display name and message count, most prolific first. + QList> senders; + + /// The unread messages, NEWEST FIRST, at most kUnreadShown of them. + /// + /// MessageNode rather than MessageRef, because the dashboard DRAWS these + /// rows for messages it never opens, which is what MessageNode exists for; + /// MessageRef carries only what the pane needs once a message is already + /// open, and has no subject, sender or date. The no-file-opening contract + /// above still holds: all four of those are served from the index. + QVector unread; + + /// How many there really are. The list above is capped, so a count taken + /// from its size would under-report on exactly the threads that need the + /// number most. + int unreadTotal = 0; + + int totalCount = 0; + + /// Always kBuckets entries. A fixed count is what keeps the sparkline's + /// geometry testable; a thread spanning five days and one spanning two + /// years cannot share a bucket size, so the span is what varies and the + /// label beneath carries the truth. + QVector buckets; + + qint64 firstTimestamp = 0; + qint64 lastTimestamp = 0; + int busiestBucket = -1; + + static constexpr int kBuckets = 7; + static constexpr int kUnreadShown = 5; +}; + +Q_DECLARE_METATYPE(ThreadDigest) + +#endif // QTMAILDIR_THREADDIGEST_H -- cgit v1.2.3