aboutsummaryrefslogtreecommitdiffstats
path: root/src/cardlayout.cpp
blob: 371959161e4a1eaf531ae6a0400dc9a76af636d1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/*
 * 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 "cardlayout.h"

#include <QFontMetrics>
#include <QLocale>

QString CardLayout::formatDate(const QDateTime &date, const QString &format)
{
    // The system locale's own short format by default, not a hardcoded
    // pattern: an Italian desktop writes 10/08/2025, not 2025-08-10, and a mail
    // client that disagrees with every other application on screen is simply
    // wrong. [general] date_format overrides it for a user who wants one
    // specific shape regardless of the locale.
    if (format.isEmpty())
        return QLocale::system().toString(date, QLocale::ShortFormat);
    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.
    Q_UNUSED(expanded);
    const QString word = replyCount == 1 ? QStringLiteral("reply")
                                         : QStringLiteral("replies");
    return QStringLiteral("%1 %2").arg(replyCount).arg(word);
}

QString CardLayout::widestDateSample(const QString &format)
{
    // A real date run through the same formatter, with the wide digits and a
    // two-digit day and month, so the reserved width matches what is drawn
    // whatever the locale's pattern turns out to be. Guessing a pattern here
    // would reintroduce the clipping this exists to prevent.
    //
    // Not cached in a static any more: the sample depends on the format, and a
    // single static computed for whichever format arrived first would reserve
    // the system format's width for a custom pattern. The formatter is one
    // QLocale call per row, which is the same cost the date itself already pays.
    const QDateTime wide(QDate(2028, 12, 28), QTime(22, 58));
    return formatDate(wide, format);
}

QFont CardLayout::smallFont(const QFont &cardFont)
{
    QFont small = cardFont;
    // Derived from the card's font rather than fixed, so it follows the
    // desktop's font size instead of shrinking to nothing on a large one.
    //
    // pointSizeF() returns -1 for a font set in PIXELS, which qt6ct and some
    // styles do. Subtracting from -1 would ask for an invalid size and Qt
    // would silently keep the original, making the small font the same size as
    // the card's; the pixel branch avoids that.
    if (small.pointSizeF() > 0.0)
        small.setPointSizeF(qMax(6.0, cardFont.pointSizeF() - 1.0));
    else if (small.pixelSize() > 0)
        small.setPixelSize(qMax(8, cardFont.pixelSize() - 1));
    return small;
}

QFont CardLayout::siblingFont(const QFont &cardFont)
{
    // A FRACTION of the card's font, not a fixed number of points off it.
    //
    // Subtracting one point was the first attempt and the user reported the
    // tiers as indistinguishable. The reason is arithmetic: their desktop is
    // 14pt, so the two chip tiers were 13 and 12, a 7% step. Subtraction gives
    // a step whose size depends on the desktop font, which is exactly backwards
    // — it is largest where the text is already small enough to be fragile.
    //
    // 0.70 of the card font, against smallFont()'s one point off, so on a 14pt
    // desktop the tiers are 13 and 10. Chosen with the user against rendered
    // sizes rather than picked.
    constexpr qreal kSiblingScale = 0.70;

    QFont small = cardFont;
    // pointSizeF() returns -1 for a font set in PIXELS, which qt6ct does, and
    // scaling -1 asks for an invalid size that Qt silently ignores, leaving
    // both tiers identical. Same split as smallFont(), same reason.
    if (small.pointSizeF() > 0.0)
        small.setPointSizeF(qMax(6.0, cardFont.pointSizeF() * kSiblingScale));
    else if (small.pixelSize() > 0)
        small.setPixelSize(qMax(8, qRound(cardFont.pixelSize() * kSiblingScale)));
    return small;
}

int CardLayout::heightFor(const QFont &font)
{
    const QFontMetrics metrics(font);
    const QFontMetrics smallMetrics(smallFont(font));
    // Two lines at the card's font, one at the small one, plus the padding
    // above the first and below the last.
    return kPaddingY * 2 + metrics.height() * 2 + smallMetrics.height();
}

CardLayout CardLayout::compute(const Input &input, const QRect &rect,
                               const QFont &font)
{
    CardLayout out;
    const QFontMetrics metrics(font);
    const QFontMetrics smallMetrics(smallFont(font));

    out.totalHeight = rect.height();

    // The accent bar sits flush against the card's left edge, on thread cards
    // only, and everything else starts after it so no text sits on the colour.
    if (!input.isMessage) {
        out.accentRect =
            QRect(rect.left(), rect.top(), kAccentWidth, rect.height());
    }
    const int textLeft = rect.left() + kAccentWidth;

    // Indent, capped. qMin rather than a branch so depth 5 and depth 50 land
    // in exactly the same place.
    //
    // A MESSAGE row is nested at least one level whatever depth it reports.
    // notmuch numbers every message of a thread with no usable In-Reply-To as
    // depth 0, so a flat thread's replies arrived here claiming no nesting and
    // drew flush against their own thread with no spine, while a nested
    // thread's replies indented normally: two different shapes on screen for
    // the same relationship. Being a child row IS the nesting; the depth only
    // says how much further to go.
    const int effectiveDepth =
        input.isMessage ? qMax(1, input.depth) : input.depth;
    const int depth = qMin(effectiveDepth, kMaxDepth);
    const int indent = depth * kIndentStep;
    out.contentLeft = textLeft + kPaddingX + indent;

    // One spine per level actually indented, each running the card's full
    // height so an expansion reads as one continuous block.
    for (int level = 0; level < depth; ++level) {
        const int x = textLeft + kPaddingX + level * kIndentStep
                      + kIndentStep / 2;
        out.spines.append(QRect(x, rect.top(), 2, rect.height()));
    }

    // The EXCLUSIVE right edge: one past the last pixel a card may draw on.
    // QRect::right() is inclusive (left + width - 1), so building widths from
    // it directly lands everything one pixel short of the intended padding.
    const int right = rect.right() + 1 - kPaddingX;
    const int lineOneTop = rect.top() + kPaddingY;
    const int lineTwoTop = lineOneTop + metrics.height();
    const int lineThreeTop = lineTwoTop + metrics.height();

    // The date is measured first and the sender gets what is left, so a long
    // sender is elided rather than painting over the date.
    //
    // Measured BOLD whatever font this is handed. An unread card draws bold and
    // the delegate computes its layout from the view's regular font, so a rect
    // sized regular clips a bold date: 154px reserved against 170px needed, one
    // digit of the year gone from every unread card. Reserving the wider of the
    // two costs a few pixels on a read card and cannot disagree with what is
    // painted.
    QFont dateFont = font;
    dateFont.setBold(true);
    const int dateWidth =
        QFontMetrics(dateFont).horizontalAdvance(
            widestDateSample(input.dateFormat));
    out.dateRect = QRect(right - dateWidth, lineOneTop, dateWidth,
                         metrics.height());
    out.senderRect = QRect(out.contentLeft, lineOneTop,
                           qMax(0, out.dateRect.left() - out.contentLeft
                                       - kPaddingX),
                           metrics.height());

    // The expander is the reply count as a PILL, on line two and on the right.
    //
    // Sized from the label actually drawn rather than from a fixed sample, so
    // the background and the text inside it cannot disagree. Both states of the
    // glyph are measured because the rect must not change width when the card
    // is expanded: a pill that resized on click would shift the subject's
    // elision under the pointer.
    if (input.replyCount > 0) {
        const int collapsed = smallMetrics.horizontalAdvance(
            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) + triangle + kPillPaddingX * 2;
        out.expanderRect = QRect(right - countWidth, lineTwoTop, countWidth,
                                 metrics.height());
    }

    // 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,
                        qMax(0, right - out.contentLeft),
                        smallMetrics.height());

    return out;
}