aboutsummaryrefslogtreecommitdiffstats
path: root/src/cardlayout.cpp
blob: d60bff916c79218b36304b51c576c5a1c0e6927f (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
/*
 * 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);
}

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.
    //
    // 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");
    const QString word = replyCount == 1 ? QStringLiteral("reply")
                                         : QStringLiteral("replies");
    return QStringLiteral("%1 %2 %3").arg(glyph).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;
}

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));
        const int countWidth =
            qMax(collapsed, expanded) + 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),
                            metrics.height());

    out.tagRect = QRect(out.contentLeft, lineThreeTop,
                        qMax(0, right - out.contentLeft),
                        smallMetrics.height());

    return out;
}