aboutsummaryrefslogtreecommitdiffstats
path: root/src/threadlistview.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-08 11:07:03 +0200
committerDanilo M. <danix@danix.xyz>2026-08-08 11:07:03 +0200
commitccd19bc04d73f802b4800aadb27a1de4d6f0ee0d (patch)
treee3a10829af5b9885430a342958d69cc415577b61 /src/threadlistview.cpp
parent0c987310113f148e97b59da74c446f78356b98ff (diff)
downloadqtmaildir-ccd19bc04d73f802b4800aadb27a1de4d6f0ee0d.tar.gz
qtmaildir-ccd19bc04d73f802b4800aadb27a1de4d6f0ee0d.zip
feat(ui): mark replies with a thread line, a tint and dimmer text
Indentation alone still read as a table, which was the user's original complaint about the whole item. Three cues now say the rows belong to the thread above them: a spine down the left of the expanded block with a stub out to each reply, a background tint, and text a size down and undimmed only when unread. Both colours are mixed from the palette rather than fixed, the same rule readColour follows: a tint that reads as grouping on a light theme is invisible or muddy on a dark one. The tint is deliberately near the threshold of noticing, since it sits beside the deleted and spam fills, which carry real meaning and must stay the loudest thing in the list. The spine is accumulated across the visible reply rows and drawn once after the loop. Drawn per row it left a gap at every row boundary and read as a column of dashes rather than as the structure holding the block together. Two bugs fixed here, both mine, both from the previous commit: Clicking the expander did nothing. setRootIsDecorated(false), needed to stop the style painting its own indicator under ours, also removed the style's hit area, so the glyph rendered perfectly and was inert. ThreadListView handles the press itself now, over the strip the delegate reserves, leaving the rest of the subject cell to select the row. Every click then expanded rather than toggling, because isExpanded and setExpanded are keyed on column 0 and were being asked about the subject-column index, which always answers false. Visible, clickable and toggling are three separate properties and a test for one passes against the other two being broken: the pixel test proved the triangle was drawn while it could not be clicked, and the first click test proved it opened while it could never close. The test now clicks twice and asserts open then closed. replyRowsKeepTheirTextUnderTheThreadLine covers the other trap. paintEvent runs AFTER the cells, so the first version of the tint filled the whole reply row and erased the sender and subject the delegate had just drawn: zero surviving text pixels, a block of blank tinted rows. The fill and the stub stay in the band below the text, where the tag strip lives on thread rows.
Diffstat (limited to 'src/threadlistview.cpp')
-rw-r--r--src/threadlistview.cpp109
1 files changed, 105 insertions, 4 deletions
diff --git a/src/threadlistview.cpp b/src/threadlistview.cpp
index ebca4cc..1ffec04 100644
--- a/src/threadlistview.cpp
+++ b/src/threadlistview.cpp
@@ -21,10 +21,44 @@
#include "tagchip.h"
#include "threadlistmodel.h"
+#include <QMouseEvent>
#include <QPaintEvent>
#include <QPainter>
#include <QScrollBar>
+void ThreadListView::mousePressEvent(QMouseEvent *event)
+{
+ const QModelIndex index = indexAt(event->pos());
+
+ // Only a thread row, only the subject column, only the strip the delegate
+ // reserved for the glyph. Anything wider would swallow clicks meant to
+ // select the row, which is what the rest of the subject cell is for.
+ if (event->button() == Qt::LeftButton && index.isValid()
+ && !index.parent().isValid()
+ && index.column() == ThreadListModel::SubjectColumn
+ && index.data(ThreadListModel::HasRepliesRole).toBool()) {
+
+ const QRect rect = visualRect(index);
+ if (event->pos().x() >= rect.left()
+ && event->pos().x() < rect.left() + SubjectDelegate::kExpanderWidth) {
+ // Column 0, not the clicked index. Expansion state belongs to the
+ // ROW, and QTreeView keys it on the first column: asking
+ // isExpanded() about the subject-column index always answers false,
+ // so every click expanded again instead of toggling.
+ const QModelIndex row = index.siblingAtColumn(0);
+ setExpanded(row, !isExpanded(row));
+
+ // Swallowed, so the click that opened a thread does not also load
+ // it into the message pane: expanding is a request to see the
+ // thread's shape, not to read it.
+ event->accept();
+ return;
+ }
+ }
+
+ QTreeView::mousePressEvent(event);
+}
+
void ThreadListView::paintEvent(QPaintEvent *event)
{
QTreeView::paintEvent(event);
@@ -55,16 +89,74 @@ void ThreadListView::paintEvent(QPaintEvent *event)
// the same one.
int visualRow = 0;
+ // The spine's extent, collected across the reply rows and drawn once after
+ // the loop. Per-row segments leave a gap at every row boundary and read as
+ // a column of dashes rather than as one line.
+ int spineX = -1;
+ int spineTop = std::numeric_limits<int>::max();
+ int spineBottom = std::numeric_limits<int>::min();
+
for (; walk.isValid(); walk = indexBelow(walk), ++visualRow) {
const QRect rowRect = visualRect(walk);
if (rowRect.top() > viewport()->height())
break;
- // No strip under a message row. The strip carries the THREAD's tags, so
- // one under each reply would stripe the list and repeat identical tags
- // down the whole expansion.
- if (walk.parent().isValid())
+ // A message row: no tag strip, but it does get the band filled to its
+ // own tint and a thread line down its left.
+ //
+ // The band has to be filled here for the same reason a thread row's is.
+ // The cells paint the tint per cell, so nothing covers the width to the
+ // right of the last column or the lower band the strip normally
+ // occupies, and an untouched reply row comes out tinted across its text
+ // and bare underneath it.
+ if (walk.parent().isValid()) {
+ // Only the band BELOW the text, never the whole row. paintEvent
+ // runs after the cells, so filling the row's full height paints
+ // over the sender and subject the delegate just drew: measured at
+ // zero surviving text pixels, a block of blank tinted rows.
+ const int bandTop = rowRect.top() + SubjectDelegate::kRowPadding
+ + rowMetrics.height();
+ const QRect band(columnViewportPosition(ThreadListModel::DateColumn),
+ bandTop,
+ viewport()->width()
+ - columnViewportPosition(
+ ThreadListModel::DateColumn),
+ rowRect.bottom() - bandTop + 1);
+
+ if (selectionModel()
+ && selectionModel()->isSelected(
+ walk.siblingAtColumn(ThreadListModel::SubjectColumn))) {
+ painter.fillRect(band, palette().brush(QPalette::Highlight));
+ } else {
+ painter.fillRect(band, ThreadListModel::replyBackground());
+ }
+
+ // The spine is NOT drawn here. Drawing it per row leaves a gap
+ // wherever consecutive rows do not abut exactly, which is every row
+ // boundary once the rows carry padding: the result reads as a
+ // column of dashes rather than as one line. It is drawn as a single
+ // continuous run after this loop, from the collected extents below.
+ const int subjectLeft =
+ columnViewportPosition(ThreadListModel::SubjectColumn);
+ const int lineX = subjectLeft + SubjectDelegate::kExpanderWidth / 2;
+
+ if (spineX < 0)
+ spineX = lineX;
+ spineTop = qMin(spineTop, rowRect.top());
+ spineBottom = qMax(spineBottom, rowRect.bottom() + 1);
+
+ // A stub out to the row, so each reply is visibly attached to the
+ // spine rather than merely beside it. Drawn in the LOWER band, not
+ // at the row's midpoint: the midpoint crosses the sender text, and
+ // this paints after the cells.
+ const int stubY = bandTop + (rowRect.bottom() - bandTop) / 2;
+ painter.setPen(QPen(ThreadListModel::threadLineColour(), 2));
+ painter.drawLine(lineX, stubY,
+ subjectLeft + SubjectDelegate::kReplyIndent
+ - TagChip::kSpacing * 2,
+ stubY);
continue;
+ }
const QModelIndex index = walk.siblingAtColumn(
ThreadListModel::SubjectColumn);
@@ -156,4 +248,13 @@ void ThreadListView::paintEvent(QPaintEvent *event)
x += size.width() + TagChip::kSpacing;
}
}
+
+ // One continuous spine over every visible reply row, drawn last so no
+ // cell fill can break it. Segments drawn per row left a dash at every row
+ // boundary, which read as a dotted decoration rather than as the structure
+ // holding the block together.
+ if (spineX >= 0 && spineBottom > spineTop) {
+ painter.setPen(QPen(ThreadListModel::threadLineColour(), 2));
+ painter.drawLine(spineX, spineTop, spineX, spineBottom);
+ }
}