summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt4
-rw-r--r--src/carddelegate.cpp57
-rw-r--r--src/cardlayout.cpp85
-rw-r--r--src/cardlayout.h35
-rw-r--r--src/htmlbuilder.h10
-rw-r--r--src/mainwindow.cpp4
-rw-r--r--src/marks.cpp160
-rw-r--r--src/marks.h83
-rw-r--r--src/messageview.cpp59
-rw-r--r--src/messageview.h5
-rw-r--r--src/threadlistmodel.cpp75
-rw-r--r--src/threadlistmodel.h14
-rw-r--r--src/types.h21
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;\">&nbsp;")
+ .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.