diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 4 | ||||
| -rw-r--r-- | src/carddelegate.cpp | 57 | ||||
| -rw-r--r-- | src/cardlayout.cpp | 85 | ||||
| -rw-r--r-- | src/cardlayout.h | 35 | ||||
| -rw-r--r-- | src/htmlbuilder.h | 10 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 4 | ||||
| -rw-r--r-- | src/marks.cpp | 160 | ||||
| -rw-r--r-- | src/marks.h | 83 | ||||
| -rw-r--r-- | src/messageview.cpp | 59 | ||||
| -rw-r--r-- | src/messageview.h | 5 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 75 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 14 | ||||
| -rw-r--r-- | src/types.h | 21 |
13 files changed, 552 insertions, 60 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fdac2c1..6f5101e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(qtmaildir_lib STATIC htmlbuilder.cpp cidschemehandler.cpp cardlayout.cpp + marks.cpp carddelegate.cpp notmuchworker.cpp tagchip.cpp @@ -27,7 +28,8 @@ target_include_directories(qtmaildir_lib ${CMAKE_BINARY_DIR}/generated/qtmaildir) target_link_libraries(qtmaildir_lib - PUBLIC Qt6::Widgets Qt6::WebEngineWidgets PkgConfig::GMIME ${NOTMUCH_LIBRARY}) + PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets PkgConfig::GMIME + ${NOTMUCH_LIBRARY}) # resources.qrc belongs to the executable, not to the static library. A qrc # compiled into a .a registers itself from a global initialiser, and the linker diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp index 3039853..d7d06aa 100644 --- a/src/carddelegate.cpp +++ b/src/carddelegate.cpp @@ -19,6 +19,7 @@ #include "carddelegate.h" #include "cardlayout.h" +#include "marks.h" #include "threadlistmodel.h" #include <QApplication> @@ -37,6 +38,14 @@ CardLayout::Input inputFor(const QModelIndex &index) in.depth = index.data(ThreadListModel::MessageDepthRole).toInt(); in.replyCount = index.data(ThreadListModel::ReplyCountRole).toInt(); in.dateFormat = index.data(ThreadListModel::DateFormatRole).toString(); + + // Item 70's marks. The layout reserves a rect for each, so these have to + // reach it: a mark drawn without its rect reserved lands on top of the + // subject rather than beside it. + in.flagged = index.data(ThreadListModel::IsFlaggedRole).toBool(); + in.hasAttachment = index.data(ThreadListModel::HasAttachmentRole).toBool(); + in.passed = index.data(ThreadListModel::IsPassedRole).toBool(); + in.replied = index.data(ThreadListModel::IsRepliedRole).toBool(); return in; } @@ -190,16 +199,25 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, 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, + metrics.elidedText(subject, Qt::ElideRight, card.subjectRect.width())); + // Item 70's marks, drawn into the rects the layout reserved rather than + // appended to the subject STRING as glyphs. The colour is the pen's, which + // is already resolved above against selection and the read/unread + // foreground, so a mark follows its card's text exactly: white on a + // selected row, dimmed on a read one. + const QColor markColour = painter->pen().color(); + const auto drawMark = [&](const QRect &rect, Marks::Mark mark) { + if (!rect.isEmpty()) + Marks::paint(painter, rect, mark, markColour); + }; + drawMark(card.flagRect, Marks::Mark::Flagged); + drawMark(card.attachmentRect, Marks::Mark::Attachment); + drawMark(card.passedRect, Marks::Mark::Passed); + drawMark(card.repliedRect, Marks::Mark::Replied); + // The reply count, which is also the expander, drawn as a PILL. // // A bare "3" on the card's own background read as an unexplained number @@ -243,7 +261,30 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, painter->setPen(option.state & QStyle::State_Selected ? option.palette.highlightedText().color() : text); - painter->drawText(card.expanderRect, Qt::AlignCenter, label); + + // The triangle is a drawn mark since item 70, not a glyph in the label, + // so the pill lays out its two pieces itself: the mark, a gap, then the + // count. The layout reserved width for exactly this (ascent + kMarkGap), + // so the two must agree or the text drifts out of its own background. + const QFontMetrics pillMetrics(painter->font()); + const int side = pillMetrics.ascent(); + const int textWidth = pillMetrics.horizontalAdvance(label); + const int contentWidth = side + CardLayout::kMarkGap + textWidth; + const int left = card.expanderRect.left() + + (card.expanderRect.width() - contentWidth) / 2; + const QRect markRect(left, + card.expanderRect.top() + + (card.expanderRect.height() - side) / 2, + side, side); + Marks::paint(painter, markRect, + option.state & QStyle::State_Open + ? Marks::Mark::ExpanderExpanded + : Marks::Mark::ExpanderCollapsed, + painter->pen().color()); + painter->drawText(QRect(markRect.right() + 1 + CardLayout::kMarkGap, + card.expanderRect.top(), textWidth, + card.expanderRect.height()), + Qt::AlignVCenter | Qt::AlignLeft, label); painter->restore(); } diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp index d60bff9..0febb4b 100644 --- a/src/cardlayout.cpp +++ b/src/cardlayout.cpp @@ -33,20 +33,46 @@ QString CardLayout::formatDate(const QDateTime &date, const QString &format) return QLocale::system().toString(date, format); } +int CardLayout::markSide(const QFont &font) +{ + // Derived from the font's ascent rather than fixed, so the marks grow with + // the user's text size. A pixel count settled on one desktop is wrong on + // the next one, and qt6ct sets fonts in PIXELS, where pointSizeF() returns + // -1 (see the note in this file's header) which is why the metric and not + // the size is the thing measured. + // + // Ascent rather than height: height includes the descent, which no mark + // occupies, and marks sized from it read noticeably larger than the text + // beside them. + const int side = QFontMetrics(font).ascent(); + + // Floor of 8: below that the shapes stop being tellable apart, which is the + // defect item 70 exists to fix rather than one to reintroduce at a small + // font size. + return qMax(8, side); +} + QString CardLayout::expanderLabel(int replyCount, bool expanded) { // "3 replies", not a bare "3". The count alone reads as an unexplained // number beside the subject, and the word is what says the card opens. // + // The triangle is NO LONGER part of this string. Item 70 made it a drawn + // mark, so the pill reserves a rect for it and the delegate paints it; a + // glyph left here would be a second triangle beside the drawn one. The + // `expanded` parameter therefore no longer changes the label, and is kept + // because both states are still measured: the pill must not change width + // when the card opens, and a caller that stopped passing the state would + // hide that requirement rather than satisfy it. + // // Not translated through tr() here because CardLayout is a plain struct // rather than a QObject; the delegate is where a translated build would // wrap this, and the string is deliberately kept in one place so there is // exactly one thing to change. - const QString glyph = expanded ? QStringLiteral("\u25be") - : QStringLiteral("\u25b8"); + Q_UNUSED(expanded); const QString word = replyCount == 1 ? QStringLiteral("reply") : QStringLiteral("replies"); - return QStringLiteral("%1 %2 %3").arg(glyph).arg(replyCount).arg(word); + return QStringLiteral("%1 %2").arg(replyCount).arg(word); } QString CardLayout::widestDateSample(const QString &format) @@ -172,17 +198,58 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect, expanderLabel(input.replyCount, false)); const int expanded = smallMetrics.horizontalAdvance( expanderLabel(input.replyCount, true)); + // The triangle is a drawn mark since item 70, so the pill has to + // reserve its width explicitly. It came free from the text metrics + // while it was a glyph in the label, which is exactly the kind of + // width that disappears silently when the glyph does. + const int triangle = smallMetrics.ascent() + kMarkGap; const int countWidth = - qMax(collapsed, expanded) + kPillPaddingX * 2; + qMax(collapsed, expanded) + triangle + kPillPaddingX * 2; out.expanderRect = QRect(right - countWidth, lineTwoTop, countWidth, metrics.height()); } - const int subjectRight = out.expanderRect.isEmpty() - ? right - : out.expanderRect.left() - kPaddingX; - out.subjectRect = QRect(out.contentLeft, lineTwoTop, - qMax(0, subjectRight - out.contentLeft), + // Item 70's marks. Until it they were glyphs inside the subject string, so + // the text metrics reserved their width without anyone arranging it; as + // icons they need rects, and the subject needs to end before them or it + // runs underneath. + // + // Laid out from the RIGHT, inwards: the expander is already placed, and + // each mark present takes the next slot to its left. The subject then gets + // whatever is left, which is what keeps a card with four marks from eliding + // its subject to nothing on a narrow window: the marks are small and fixed, + // the subject is the elastic part. + const int side = markSide(font); + const int markTop = lineTwoTop + (metrics.height() - side) / 2; + int markRight = out.expanderRect.isEmpty() + ? right + : out.expanderRect.left() - kPaddingX; + + // Order matters and is the drawing order reversed: placing right to left + // here puts attachment nearest the subject and replied furthest right, + // which is the order the delegate then paints them in. + const auto placeMark = [&](bool present, QRect &target) { + if (!present) + return; + target = QRect(markRight - side, markTop, side, side); + markRight = target.left() - kMarkGap; + }; + placeMark(input.replied, out.repliedRect); + placeMark(input.passed, out.passedRect); + placeMark(input.hasAttachment, out.attachmentRect); + + // The flag sits at the START of line two, where the glyph did, so a flagged + // card still reads flagged from the left edge. It indents the subject + // rather than overlapping it. + int subjectLeft = out.contentLeft; + if (input.flagged) { + out.flagRect = QRect(out.contentLeft, markTop, side, side); + subjectLeft = out.flagRect.right() + 1 + kMarkGap; + } + + const int subjectRight = markRight; + out.subjectRect = QRect(subjectLeft, lineTwoTop, + qMax(0, subjectRight - subjectLeft), metrics.height()); out.tagRect = QRect(out.contentLeft, lineThreeTop, diff --git a/src/cardlayout.h b/src/cardlayout.h index 1a2fd18..3512242 100644 --- a/src/cardlayout.h +++ b/src/cardlayout.h @@ -56,6 +56,18 @@ struct CardLayout /// the geometry is exactly how a longer date gets elided into a rect /// sized for a shorter one. QString dateFormat; + + /// Which marks line two carries (item 70). + /// + /// On the INPUT for the same reason dateFormat is: the marks were + /// glyphs inside the subject STRING until item 70, so their width came + /// free from the text metrics. Drawn as icons they occupy rects of + /// their own, and a subject sized as though they were absent would run + /// underneath them. The layout has to know they are there. + bool flagged = false; + bool hasAttachment = false; + bool passed = false; + bool replied = false; }; /// Width of the account accent bar down a thread card's left edge. @@ -94,6 +106,29 @@ struct CardLayout /// Empty when the row has no replies. QRect expanderRect; + /// The flagged mark, at the start of line two before the subject. Empty + /// when the row is not flagged. + QRect flagRect; + + /// The state marks after the subject, in this order: attachment, passed, + /// replied. Each is empty when its state does not apply. + /// + /// Separate rects rather than one strip, because each is independently + /// present or absent and a strip would have to encode which. They are laid + /// out right to left from the expander, so the subject keeps whatever is + /// left. + QRect attachmentRect; + QRect passedRect; + QRect repliedRect; + + /// The side of a square mark on line two, derived from the card's font so + /// the marks scale with the user's text size rather than being pinned to a + /// pixel count that is right on one desktop only. + static int markSide(const QFont &font); + + /// Gap between two adjacent marks, and between a mark and the subject. + static constexpr int kMarkGap = 4; + /// The account accent bar down the card's left edge. /// /// Thread cards only. A reply's account is its thread's, stated once at the diff --git a/src/htmlbuilder.h b/src/htmlbuilder.h index 3ade530..4078065 100644 --- a/src/htmlbuilder.h +++ b/src/htmlbuilder.h @@ -32,6 +32,16 @@ struct ThreadRenderItem /// Matched messages render in full; unmatched collapse to a one-line stub. bool expanded = true; + /// Whether the message is flagged, for the mark beside the subject in the + /// message pane's header (item 70). + /// + /// Carried here rather than derived from `message`, because it comes from + /// the notmuch TAGS and ParsedMessage holds only what the MIME parser found + /// in the file. MessageRef already answers both, so this costs no query. + /// Unused by the generated HTML itself: the header is a QLabel above the + /// web view, not part of the sandboxed document. + bool flagged = false; + /// Disambiguates cid: references. Two newsletters in one thread commonly /// use the same Content-ID (cid:logo@example.org), which would collide in /// a single document, so every reference is rewritten to diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 04e951d..f731511 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -2037,6 +2037,10 @@ void MainWindow::onThreadLoaded(const QVector<MessageRef> &messages, // Namespace prefix keeps cid: references distinct across the thread. item.cidPrefix = cidPrefixForIndex(i); + // For the header's marks (item 70). From the REF's tags, since the + // parsed message carries only what was in the file. + item.flagged = ref.isFlagged(); + // Matched messages open; the rest collapse to a stub. The last message // always opens, so a thread never renders as nothing but stubs. item.expanded = ref.matched || i == messages.size() - 1; diff --git a/src/marks.cpp b/src/marks.cpp new file mode 100644 index 0000000..8777f84 --- /dev/null +++ b/src/marks.cpp @@ -0,0 +1,160 @@ +/* + * qtmaildir - a Qt6 GUI for a local notmuch-indexed Maildir + * 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 "marks.h" + +#include <QHash> +#include <QPainter> +#include <QRect> +#include <QSvgRenderer> + +namespace Marks { + +QByteArray svg(Mark mark) +{ + // Compiled in rather than loaded from a .qrc, for the linker reason given + // in marks.h. Generated from assets/icons/marks/*.svg, which stay the + // editable originals: change the asset, regenerate, do not hand-edit here. + switch (mark) { + case Mark::Attachment: + return QByteArray( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " + "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 10.4,1.1 C " + "8.75,1.1 7.4,2.45 7.4,4.1 V 11.6 a 1.1,1.1 0 0 0 2.2,0 V 4.6 a " + "0.85,0.85 0 0 1 1.7,0 V 11.7 a 2.75,2.75 0 0 1 -5.5,0 V 4.35 a 1.1,1.1 " + "0 0 0 -2.2,0 V 11.7 C 3.6,14.4 5.8,16 8.35,16 10.9,16 13.1,14.4 " + "13.1,11.7 V 4.1 C 13.1,2.45 11.9,1.1 10.4,1.1 Z\"/> </svg>"); + case Mark::Flagged: + return QByteArray( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " + "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 8.00,1.05 " + "9.73,5.96 14.94,6.09 10.81,9.26 12.29,14.26 8.00,11.30 3.71,14.26 " + "5.19,9.26 1.06,6.09 6.27,5.96 Z\"/> </svg>"); + case Mark::Passed: + return QByteArray( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " + "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 9.2,2.2 V 5.0 " + "H 7.3 C 4.1,5.0 1.8,7.4 1.8,10.8 V 13.8 a 0.9,0.9 0 0 0 1.75,0.28 C " + "4.2,12.1 5.6,10.9 7.3,10.9 H 9.2 V 13.7 L 15.0,7.95 Z\"/> </svg>"); + case Mark::Replied: + return QByteArray( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " + "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 6.8,2.2 V 5.0 " + "H 8.7 C 11.9,5.0 14.2,7.4 14.2,10.8 V 13.8 a 0.9,0.9 0 0 1 -1.75,0.28 C " + "11.8,12.1 10.4,10.9 8.7,10.9 H 6.8 V 13.7 L 1.0,7.95 Z\"/> </svg>"); + case Mark::ExpanderCollapsed: + return QByteArray( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " + "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 5.38,2.38 " + "12.25,8 5.38,13.62 Z\"/> </svg>"); + case Mark::ExpanderExpanded: + return QByteArray( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " + "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 2.38,5.38 " + "13.62,5.38 8,12.25 Z\"/> </svg>"); } + return {}; +} + +namespace { + +/// Key for the pixmap cache. The colour belongs in it because the mark is +/// recoloured per palette, and the ratio because a pixmap rendered for a 1x +/// screen is blurry on a 2x one. +struct CacheKey +{ + Mark mark; + int width; + int height; + QRgb color; + int ratio; ///< devicePixelRatio scaled by 100, so it can be hashed. + + bool operator==(const CacheKey &other) const + { + return mark == other.mark && width == other.width + && height == other.height && color == other.color + && ratio == other.ratio; + } +}; + +size_t qHash(const CacheKey &key, size_t seed = 0) +{ + return qHashMulti(seed, static_cast<int>(key.mark), key.width, key.height, + key.color, key.ratio); +} + +} // namespace + +QPixmap pixmap(Mark mark, const QSize &size, const QColor &color, + qreal devicePixelRatio) +{ + if (size.isEmpty() || !color.isValid()) + return {}; + + static QHash<CacheKey, QPixmap> cache; + + const CacheKey key{ mark, size.width(), size.height(), color.rgba(), + qRound(devicePixelRatio * 100) }; + const auto cached = cache.constFind(key); + if (cached != cache.constEnd()) + return *cached; + + QPixmap pm(size * devicePixelRatio); + pm.setDevicePixelRatio(devicePixelRatio); + pm.fill(Qt::transparent); + + { + QSvgRenderer renderer(svg(mark)); + QPainter painter(&pm); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter, QRectF(QPointF(0, 0), QSizeF(size))); + + // The payloads paint with fill="currentColor", which QSvgRenderer does + // not resolve: it renders them black. SourceIn keeps the alpha the + // shape just produced and replaces the colour, which is what makes one + // asset serve both a light and a dark palette. + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.fillRect(QRect(QPoint(0, 0), size), color); + } + + // Unbounded in principle, bounded in practice: the marks are six, the sizes + // come from a handful of font heights, and the colours from the palette. + cache.insert(key, pm); + return pm; +} + +void paint(QPainter *painter, const QRect &rect, Mark mark, const QColor &color) +{ + if (!painter || rect.isEmpty()) + return; + + // Square, sized to the shorter side, so a mark never stretches. The rects + // CardLayout reserves are square already; the message pane's are not + // necessarily. + const int side = qMin(rect.width(), rect.height()); + const QSize size(side, side); + const QPixmap pm = pixmap(mark, size, color, + painter->device()->devicePixelRatioF()); + if (pm.isNull()) + return; + + const QPoint at(rect.left() + (rect.width() - side) / 2, + rect.top() + (rect.height() - side) / 2); + painter->drawPixmap(at, pm); +} + +} // namespace Marks diff --git a/src/marks.h b/src/marks.h new file mode 100644 index 0000000..823c129 --- /dev/null +++ b/src/marks.h @@ -0,0 +1,83 @@ +/* + * qtmaildir - a Qt6 GUI for a local notmuch-indexed Maildir + * 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. + */ + +#ifndef QTMAILDIR_MARKS_H +#define QTMAILDIR_MARKS_H + +#include <QColor> +#include <QPixmap> +#include <QSize> + +class QPainter; +class QRect; + +/// The pane marks: the small state icons drawn on a card and in the message +/// pane's header. +/// +/// These are qtmaildir's own, deliberately, and that is the point of item 70. +/// Toolbar and menu icons come from QIcon::fromTheme and follow the user's icon +/// theme; the panes must not, because a mark that changes shape under a theme +/// change is a mark whose meaning the application cannot state. They are also +/// no longer font glyphs: U+1F4CE and U+2605 render at the mercy of whatever +/// font the desktop supplies, and both fell back to a bare "*" on a font that +/// lacked them, which made a flagged thread and one with an attachment +/// indistinguishable. +/// +/// The SVG payloads are compiled in as string literals rather than loaded from +/// a .qrc, and the reason is in src/CMakeLists.txt: a qrc compiled into the +/// static library registers itself from a global initialiser that the linker +/// drops, so resources belong to the executable. The tests link the library, +/// not the executable, so a resource-based mark would be absent exactly where +/// it needs asserting. The editable originals live in assets/icons/marks/ and +/// are the source these were taken from. +namespace Marks { + +enum class Mark { + Attachment, + Flagged, + Passed, + Replied, + ExpanderCollapsed, + ExpanderExpanded, +}; + +/// The mark rendered at `size`, filled with `color`. +/// +/// Recoloured rather than shipped in light and dark variants: every payload +/// paints with fill="currentColor", which QSvgRenderer does not resolve, so the +/// colour is composited in. One asset then serves both palettes and cannot fall +/// out of step with itself. +/// +/// Cached by (mark, size, colour, devicePixelRatio). A delegate paints these on +/// every row of every repaint, and re-parsing six XML documents per frame is +/// the kind of cost that does not show up until a list is long. +QPixmap pixmap(Mark mark, const QSize &size, const QColor &color, + qreal devicePixelRatio = 1.0); + +/// Paints `mark` centred in `rect`, scaled to fit its shorter side. +void paint(QPainter *painter, const QRect &rect, Mark mark, + const QColor &color); + +/// The raw SVG payload, exposed for tests and for the message pane, which +/// embeds marks as data: URIs in the header label's rich text rather than +/// painting them. +QByteArray svg(Mark mark); + +} // namespace Marks + +#endif // QTMAILDIR_MARKS_H diff --git a/src/messageview.cpp b/src/messageview.cpp index b6c3fa3..d2be380 100644 --- a/src/messageview.cpp +++ b/src/messageview.cpp @@ -25,6 +25,7 @@ #include <QFontDatabase> #include <QPlainTextEdit> #include <QDir> +#include <QBuffer> #include <QFileDialog> #include <QHBoxLayout> #include <QLabel> @@ -392,6 +393,39 @@ void MessageView::showError(const QString &text, const QString &filePath) setDocument(html); } +QString MessageView::headerMark(Marks::Mark mark) const +{ + // A data: URI rather than a resource path, for the same reason the marks + // are compiled in rather than shipped in a .qrc, and one more besides: this + // string goes into a QLabel's rich text, and Qt resolves a src= against the + // resource system only when one is registered. The image travels with the + // markup instead. + // + // Rendered at the label's OWN text colour so the mark tracks the palette + // exactly as the subject beside it does, on a light or a dark theme. + const int side = QFontMetrics(m_headerLabel->font()).ascent(); + const QPixmap pm = Marks::pixmap(mark, QSize(side, side), + m_headerLabel->palette().color( + QPalette::WindowText), + m_headerLabel->devicePixelRatioF()); + if (pm.isNull()) + return {}; + + QByteArray png; + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + if (!pm.save(&buffer, "PNG")) + return {}; + + // A hair of margin on both sides, so a mark does not touch the subject. + return QStringLiteral( + "<img src=\"data:image/png;base64,%1\" width=\"%2\" " + "height=\"%3\" style=\"vertical-align: middle;\"> ") + .arg(QString::fromLatin1(png.toBase64())) + .arg(side) + .arg(side); +} + void MessageView::updateHeader() { if (m_items.isEmpty()) { @@ -406,7 +440,30 @@ void MessageView::updateHeader() // Re: prefixes that add nothing. const QString subject = m_items.first().message.subject; - QString text = QStringLiteral("<b>%1</b>").arg(subject.toHtmlEscaped()); + // Item 70's marks, beside the subject and OUTSIDE the message area. The + // user asked for these two only: whether the thread is flagged and whether + // it carries an attachment, which are the two states worth knowing before + // reading. They belong to the header label, which is application chrome, + // rather than to the generated document, which is untrusted content in a + // sandboxed web view. + // + // Any message in the thread having the state marks the whole thread, since + // the header describes the thread: an attachment on reply four is still an + // attachment the reader wants to know about. + const bool anyFlagged = std::any_of( + m_items.cbegin(), m_items.cend(), + [](const ThreadRenderItem &item) { return item.flagged; }); + const bool anyAttachment = std::any_of( + m_items.cbegin(), m_items.cend(), [](const ThreadRenderItem &item) { + return !item.message.attachments.isEmpty(); + }); + + QString text; + if (anyFlagged) + text += headerMark(Marks::Mark::Flagged); + text += QStringLiteral("<b>%1</b>").arg(subject.toHtmlEscaped()); + if (anyAttachment) + text += headerMark(Marks::Mark::Attachment); // The header adapts to what it can say honestly. From, To and Cc are // per-message, and the pane shows a whole thread, so they are only diff --git a/src/messageview.h b/src/messageview.h index 63fd6d8..422869a 100644 --- a/src/messageview.h +++ b/src/messageview.h @@ -23,6 +23,7 @@ #include <QWidget> #include "htmlbuilder.h" +#include "marks.h" #include "mimeparser.h" class QLabel; @@ -175,6 +176,10 @@ protected: private: void render(); void updateHeader(); + + /// One header mark as an <img> data: URI, sized and coloured to the header + /// label's own font and palette. Empty when the mark cannot be rendered. + QString headerMark(Marks::Mark mark) const; void setDocument(const QString &html); /// Rebuilds the attachment bar from m_items. Called from render(), so a diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp index 5874648..20d6915 100644 --- a/src/threadlistmodel.cpp +++ b/src/threadlistmodel.cpp @@ -27,25 +27,27 @@ #include <QFontDatabase> #include <QFontMetrics> -QString ThreadListModel::attachmentGlyph() +namespace { + +/// Whether a tag is already drawn on the card as a mark, and so must not also +/// appear as a chip. +/// +/// One list, consulted by both PillTagsRole (a thread's chips) and +/// MessageOwnTagsRole (a reply's own chips). Two copies drifted apart is +/// exactly how a tag ends up drawn twice on one row and not at all on another. +bool isDrawnAsAMark(const QString &tag) { - // U+1F4CE PAPERCLIP, with a fallback for a system whose default font - // cannot draw it: an unrenderable codepoint shows as a tofu box, which - // reads as "something is broken" rather than "this has an attachment". - // Computed once; the font does not change under a running application. - static const QString glyph = [] { - const char32_t paperclip = 0x1F4CE; - const QString preferred = QString::fromUcs4(&paperclip, 1); - const QFontMetrics metrics{QFontDatabase::systemFont( - QFontDatabase::GeneralFont)}; - // "*" as the fallback: ASCII, present in every practical font, and - // unambiguous in a column that shows nothing else. - return metrics.inFontUcs4(paperclip) ? preferred - : QStringLiteral("*"); - }(); - return glyph; + static const QStringList marks = { + QStringLiteral("flagged"), + QStringLiteral("attachment"), + QStringLiteral("passed"), + QStringLiteral("replied"), + }; + return marks.contains(tag); } +} // namespace + QColor ThreadListModel::deletedColour() { // Desaturated crimson: legible under white text on a dark theme, and calm @@ -61,22 +63,6 @@ QColor ThreadListModel::spamColour() return QColor(0xa8, 0x5c, 0x18); } -QString ThreadListModel::flagGlyph() -{ - // U+2605 BLACK STAR, with the same fallback reasoning as the paperclip: an - // unrenderable codepoint shows as tofu, which reads as breakage rather - // than as "flagged". The solid star, not the outlined U+2606, since it has - // to register at small size beside a paperclip. - static const QString glyph = [] { - const char32_t star = 0x2605; - const QString preferred = QString::fromUcs4(&star, 1); - const QFontMetrics metrics{QFontDatabase::systemFont( - QFontDatabase::GeneralFont)}; - return metrics.inFontUcs4(star) ? preferred : QStringLiteral("*"); - }(); - return glyph; -} - QColor ThreadListModel::replyBackground() { // Mixed from the palette rather than fixed, for the same reason as @@ -308,6 +294,13 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const : QStringList(); QStringList own; for (const QString &tag : node.tags) { + // Drawn as a mark on this row's own line two since items 69 + // and 70, so a chip would repeat it. Filtered here as well as + // in PillTagsRole because "own" is a difference against the + // THREAD, and a reply that is flagged where its thread is not + // would otherwise show both the mark and the word. + if (isDrawnAsAMark(tag)) + continue; if (!threadTags.contains(tag)) own.append(tag); } @@ -343,6 +336,10 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const return node.hasAttachment(); case IsFlaggedRole: return node.isFlagged(); + case IsPassedRole: + return node.isPassed(); + case IsRepliedRole: + return node.isReplied(); case ReplyCountRole: // A reply never offers an expander: nesting past the first level is // drawn from depth, not from further parent-child structure. @@ -425,16 +422,20 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const // // deleted and spam are kept: they repaint the whole row, so a pill is // redundant there too, but a doomed thread is rare and worth naming. + // passed and replied joined this list with item 69: they are drawn + // marks on line two now, so a chip repeating the word is the same + // duplication flagged and attachment were already dropped for. This is + // what item 69 asked for, "tags like Passed and Replied should use + // icons instead", and the chip has to go or both appear at once. static const QStringList hidden = { QStringLiteral("inbox"), QStringLiteral("unread"), - QStringLiteral("flagged"), - QStringLiteral("attachment"), }; QStringList pills; for (const QString &tag : thread.tags) { - if (hidden.contains(tag) || TagColors::isAccountTag(tag)) + if (hidden.contains(tag) || isDrawnAsAMark(tag) + || TagColors::isAccountTag(tag)) continue; pills.append(tag); } @@ -512,6 +513,10 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const return thread.hasAttachment(); case IsFlaggedRole: return thread.isFlagged(); + case IsPassedRole: + return thread.isPassed(); + case IsRepliedRole: + return thread.isReplied(); case ReplyCountRole: // Zero in a flat list, so the card draws no expander pill. The count // and hasChildren() must agree: a card advertising "3 replies" that diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h index 8b8f414..2b8d2b0 100644 --- a/src/threadlistmodel.h +++ b/src/threadlistmodel.h @@ -115,6 +115,14 @@ public: DateRole, ///< A QDateTime. The delegate formats it. HasAttachmentRole, ///< bool IsFlaggedRole, ///< bool + + /// bool; the message was forwarded, from the Maildir "P" flag. + /// Item 69 draws this as a mark where it used to read as the word + /// "passed" in the tag strip. + IsPassedRole, + + /// bool; the message was replied to, from the Maildir "R" flag. + IsRepliedRole, ReplyCountRole, ///< int; 0 when a thread has no replies. /// The [general] date_format pattern, or empty for the system's short @@ -126,13 +134,7 @@ public: DateFormatRole, }; - /// The mark drawn on a card's second line when the message has an - /// attachment. A paperclip when the system font can draw it, "*" otherwise. - static QString attachmentGlyph(); - /// The mark drawn on a card's second line when the message is flagged. - /// A star when the system font can draw it, "*" otherwise. - static QString flagGlyph(); /// Row fill for a thread tagged `deleted`, and for one tagged `spam`. /// Muted rather than saturated: a bulk delete paints every selected row, diff --git a/src/types.h b/src/types.h index 2211619..97ab43f 100644 --- a/src/types.h +++ b/src/types.h @@ -47,6 +47,14 @@ struct ThreadSummary bool isUnread() const { return tags.contains(QStringLiteral("unread")); } bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); } + /// True when this message was forwarded. The Maildir "P" (passed) flag, + /// which notmuch translates to a tag under maildir.synchronize_flags. + /// Item 68 measured the whole database: nothing derives this from a + /// subject line, so a "Fwd:" subject with no flag is correctly unmarked. + bool isPassed() const { return tags.contains(QStringLiteral("passed")); } + + /// True when this message was replied to. The Maildir "R" flag. + bool isReplied() const { return tags.contains(QStringLiteral("replied")); } bool isDeleted() const { return tags.contains(QStringLiteral("deleted")); } bool isSpam() const { return tags.contains(QStringLiteral("spam")); } @@ -73,6 +81,11 @@ struct MessageRef /// being pulled in only because a sibling in its thread matched. Drives /// whether it renders expanded or as a stub. bool matched = true; + + /// For the message pane header's flagged mark (item 70). The tags are + /// already carried, so this is the same predicate MessageNode and + /// ThreadSummary offer rather than new state. + bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); } }; /// One message as a row in the thread list. @@ -97,6 +110,14 @@ struct MessageNode bool isUnread() const { return tags.contains(QStringLiteral("unread")); } bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); } + /// True when this message was forwarded. The Maildir "P" (passed) flag, + /// which notmuch translates to a tag under maildir.synchronize_flags. + /// Item 68 measured the whole database: nothing derives this from a + /// subject line, so a "Fwd:" subject with no flag is correctly unmarked. + bool isPassed() const { return tags.contains(QStringLiteral("passed")); } + + /// True when this message was replied to. The Maildir "R" flag. + bool isReplied() const { return tags.contains(QStringLiteral("replied")); } /// notmuch applies "attachment" while indexing, so this needs no MIME /// parsing, exactly as on ThreadSummary. |
