summaryrefslogtreecommitdiffstats
path: root/src/marks.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-11 20:24:05 +0200
committerDanilo M. <danix@danix.xyz>2026-08-11 20:24:05 +0200
commit1faf94eb35e8270a659f215d260db73bcaa3f8d7 (patch)
tree6305f6532cf1abc000eb261ce85c3ce12bc0a8d3 /src/marks.cpp
parent64d3138ba923071069da6c9bc458a25a9cc7d27f (diff)
downloadqtmaildir-1faf94eb35e8270a659f215d260db73bcaa3f8d7.tar.gz
qtmaildir-1faf94eb35e8270a659f215d260db73bcaa3f8d7.zip
feat(panes): draw the pane marks from shipped SVGs, not font glyphs
Items 70 and 69, the second folded into the first as item 70's own size note predicted it should be. The panes drew their state marks as font glyphs: U+1F4CE for an attachment and U+2605 for a flagged thread, each with a fallback for a font that cannot render it. Both fell back to "*", so on such a font a flagged thread and one carrying an attachment were indistinguishable, which is a defect the fallback introduced rather than prevented. What a mark looks like was also the desktop's decision rather than this application's, and the panes are exactly where it should not be: the user asked for the toolbar and menus to keep following their icon theme while the panes stop. Six marks now ship in assets/icons/marks/: flagged, attachment, passed, replied and the two expander triangles. QIcon::fromTheme still resolves every toolbar and menu icon and was not touched. Licensing chose the shapes. The look came from a GPL3 icon theme, and this project is GPLv2-only, which are incompatible: GPLv2's "no further restrictions" clause bars shipping GPL3 assets in a v2-only work. The six were drawn fresh in the same idiom instead, with no path data copied. The idiom is generic: solid single-path silhouettes at 16x16 with no strokes. They are compiled in as string literals rather than loaded from a .qrc. src/CMakeLists.txt already records why resources belong to the executable: a qrc in the static library registers itself from a global initialiser the linker drops. The tests link the library, so a resource-based mark would be missing exactly where it needs asserting. assets/icons/marks/ stays the editable source. One asset serves both palettes. Every payload paints with fill="currentColor", which QSvgRenderer renders black rather than resolving, so Marks::pixmap composites the wanted colour with CompositionMode_SourceIn. A mark then takes the card's own pen colour and follows selection and the read/unread dimming without a second variant to keep in step. CardLayout reserves a rect per mark and CardDelegate paints into it. The marks were glyphs inside the subject STRING, so their width came free from the text metrics; as icons the geometry has to know they are there or the subject runs underneath them. The expander pill had the same trap, its triangle being a glyph in expanderLabel(), and now reserves that width explicitly. Item 69's part: passed and replied were words in the tag strip and are marks beside the subject now. The message pane's header carries the flagged and attachment marks next to the subject, per the user's decision that the right pane needs those two and only outside the message area. A duplicate that no test caught is worth recording. Every geometry assertion passed while a card showed passed as BOTH an arrow and a green tag chip: the chip filter had no reason to know a mark had appeared. It was found by rendering real cards to an image and looking at them. isDrawnAsAMark() is now one list consulted by both PillTagsRole and MessageOwnTagsRole, since two copies drifting apart is how a tag ends up drawn twice on one row and not at all on another. Fourteen tests: nine in test_marks, four in test_cardlayout, one in test_threadlistmodel. Mutation-checked at four points, each failing a test: the subject ignoring the marks, the flag not indenting the subject, the pill forgetting the triangle's width, and the recolour composite removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/marks.cpp')
-rw-r--r--src/marks.cpp160
1 files changed, 160 insertions, 0 deletions
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