diff options
Diffstat (limited to 'src/carddelegate.cpp')
| -rw-r--r-- | src/carddelegate.cpp | 208 |
1 files changed, 208 insertions, 0 deletions
diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp new file mode 100644 index 0000000..bc03b5a --- /dev/null +++ b/src/carddelegate.cpp @@ -0,0 +1,208 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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 "carddelegate.h" + +#include "cardlayout.h" +#include "threadlistmodel.h" + +#include <QApplication> +#include <QDateTime> +#include <QGuiApplication> +#include <QPainter> +#include <QRegularExpression> +#include <QStyle> + +namespace { + +CardLayout::Input inputFor(const QModelIndex &index) +{ + CardLayout::Input in; + in.isMessage = index.data(ThreadListModel::IsMessageRole).toBool(); + in.depth = index.data(ThreadListModel::MessageDepthRole).toInt(); + in.replyCount = index.data(ThreadListModel::ReplyCountRole).toInt(); + return in; +} + +} // namespace + +QRect CardDelegate::expanderRectFor(const QStyleOptionViewItem &option, + const QModelIndex &index) +{ + return CardLayout::compute(inputFor(index), option.rect, option.font) + .expanderRect; +} + +QColor CardDelegate::accentLineColour(const QColor &accountColour) +{ + if (!accountColour.isValid()) + return ThreadListModel::threadLineColour(); + + // The same 0.35 weight threadLineColour() uses, toward Base rather than + // toward Text, so the two kinds of line sit at the same visual strength. + const QColor base = QGuiApplication::palette().color(QPalette::Base); + constexpr qreal kWeight = 0.35; + const qreal inverse = 1.0 - kWeight; + return QColor::fromRgbF( + accountColour.redF() * kWeight + base.redF() * inverse, + accountColour.greenF() * kWeight + base.greenF() * inverse, + accountColour.blueF() * kWeight + base.blueF() * inverse); +} + +QSize CardDelegate::sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + Q_UNUSED(index); + // One height for every row, thread and reply alike. Asserted directly in + // test_cardlayout rather than left to two cards happening to agree. + return QSize(option.rect.width(), CardLayout::heightFor(option.font)); +} + +void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + // Background, selection and any model fill first, through the style, so a + // selected or doomed card looks right before anything is drawn on top. + QStyleOptionViewItem chrome = option; + initStyleOption(&chrome, index); + chrome.text.clear(); + const QWidget *widget = option.widget; + QStyle *style = widget ? widget->style() : QApplication::style(); + style->drawControl(QStyle::CE_ItemViewItem, &chrome, painter, widget); + + const CardLayout card = + CardLayout::compute(inputFor(index), option.rect, option.font); + + painter->save(); + + // The account's colour, for both the accent bar and the spines. + // + // A reply must resolve its THREAD's colour, not its own: AccountColourRole + // is empty on a message row, and a spine that fell back to the neutral + // line under an accented root would break the one continuous edge this + // design is built on. index.parent() is the thread for a depth-1 reply and + // the containing subtree for a deeper one, so walk to the root. + QModelIndex root = index; + while (root.parent().isValid()) + root = root.parent(); + const QColor accountColour = + root.data(ThreadListModel::AccountColourRole).value<QColor>(); + const QColor lineColour = accentLineColour(accountColour); + + // The accent bar, thread cards only. Drawn after the chrome so the + // selection highlight cannot cover it: which account a card belongs to + // must stay readable on the row the user is looking at. + if (!card.accentRect.isEmpty()) + painter->fillRect(card.accentRect, lineColour); + + // Spines, under everything else, in the same accent so an expanded thread + // is bounded by one colour from its root to its last reply. + for (const QRect &spine : card.spines) + painter->fillRect(spine, lineColour); + + // Selection outranks the model's foreground, and the order matters: a read + // card carries a dimmed colour blended against the UNSELECTED background, + // so over the highlight it lands grey-on-highlight and close to unreadable. + const QVariant foreground = index.data(Qt::ForegroundRole); + if (option.state & QStyle::State_Selected) + painter->setPen(option.palette.highlightedText().color()); + else if (foreground.isValid()) + painter->setPen(foreground.value<QBrush>().color()); + else + painter->setPen(option.palette.text().color()); + + // The model's font carries bold for unread and strike-out for deleted; + // initStyleOption resolved it into chrome.font. + painter->setFont(chrome.font); + const QFontMetrics metrics(chrome.font); + + // Line 1: sender, then the date flush right. + painter->drawText(card.senderRect, Qt::AlignVCenter | Qt::AlignLeft, + metrics.elidedText( + index.data(ThreadListModel::SendersRole).toString(), + Qt::ElideRight, card.senderRect.width())); + const QDateTime date = + index.data(ThreadListModel::DateRole).toDateTime(); + painter->drawText(card.dateRect, Qt::AlignVCenter | Qt::AlignRight, + date.toString(QStringLiteral("yyyy-MM-dd hh:mm"))); + + // Line 2: the flag mark, the subject, the attachment mark. + QString subject = index.data(ThreadListModel::SubjectRole).toString(); + if (index.data(ThreadListModel::IsMessageRole).toBool()) { + // Every reply repeating "Re: <the thread's subject>" is the visual + // signature of a table of records, which is what item 53 is about. + static const QRegularExpression re( + QStringLiteral("^\\s*(?:[Rr][Ee]\\s*:\\s*)+")); + subject.remove(re); + } + QString line2; + if (index.data(ThreadListModel::IsFlaggedRole).toBool()) + line2 += ThreadListModel::flagGlyph() + QLatin1Char(' '); + line2 += subject; + if (index.data(ThreadListModel::HasAttachmentRole).toBool()) + line2 += QLatin1Char(' ') + ThreadListModel::attachmentGlyph(); + painter->drawText(card.subjectRect, Qt::AlignVCenter | Qt::AlignLeft, + metrics.elidedText(line2, Qt::ElideRight, + card.subjectRect.width())); + + // The reply count, which is also the expander. + if (!card.expanderRect.isEmpty()) { + painter->setFont(CardLayout::smallFont(chrome.font)); + const int count = index.data(ThreadListModel::ReplyCountRole).toInt(); + const QString glyph = (option.state & QStyle::State_Open) + ? QStringLiteral("▾") + : QStringLiteral("▸"); + painter->drawText(card.expanderRect, Qt::AlignVCenter | Qt::AlignRight, + QStringLiteral("%1 %2").arg(glyph).arg(count)); + painter->setFont(chrome.font); + } + + painter->restore(); + + // Line 3: the chips. A thread card draws its own tags; a reply draws only + // the tags its thread does not already carry, so the thread's chips are + // not repeated down the whole expansion. + const bool isMessage = + index.data(ThreadListModel::IsMessageRole).toBool(); + const QStringList tags = + index.data(isMessage ? ThreadListModel::MessageOwnTagsRole + : ThreadListModel::PillTagsRole) + .toStringList(); + const QVariantList colours = + index.data(isMessage ? ThreadListModel::MessageOwnColoursRole + : ThreadListModel::PillColoursRole) + .toList(); + + const QFont chipFont = CardLayout::smallFont(chrome.font); + const QFontMetrics chipMetrics(chipFont); + painter->save(); + painter->setFont(chipFont); + int x = card.tagRect.left(); + for (int i = 0; i < tags.size(); ++i) { + const QSize size = TagChip::sizeFor(chipMetrics, tags.at(i)); + if (x + size.width() > card.tagRect.right()) + break; // Out of room; a clipped chip reads as a rendering fault. + const QColor colour = i < colours.size() + ? colours.at(i).value<QColor>() + : QColor(0x55, 0x55, 0x5f); + TagChip::paint(painter, QRect(QPoint(x, card.tagRect.top()), size), + tags.at(i), colour); + x += size.width() + TagChip::kSpacing; + } + painter->restore(); +} |
