diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mainwindow.cpp | 121 | ||||
| -rw-r--r-- | src/threadlistview.cpp | 243 | ||||
| -rw-r--r-- | src/threadlistview.h | 52 |
3 files changed, 68 insertions, 348 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 423aa7b..30148d4 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -42,7 +42,7 @@ #include <QSplitter> #include <QStandardPaths> #include <QStatusBar> -#include <QTableView> +#include <QScrollBar> #include <QTimer> #include <QToolBar> #include <QVBoxLayout> @@ -52,6 +52,8 @@ #include "mimeparser.h" #include "notmuchworker.h" #include "querycompleter.h" +#include "carddelegate.h" +#include "cardlayout.h" #include "tagchip.h" #include "tagdialog.h" #include "threadlistmodel.h" @@ -157,20 +159,9 @@ void MainWindow::restoreUiState() m_splitter->restoreState(splitter); } - // A header blob saved against a different set of columns must be - // discarded, not restored. QHeaderView::restoreState() returns TRUE for a - // blob with fewer sections than the model and applies the old widths to - // the wrong columns: adding the attachment column in front shifted every - // saved width one place right, silently mangling the layout with no error - // to detect it by (verified on Qt 6.11). The column count is stored - // alongside and the blob is only used when it still matches. - const QByteArray header = state.value(QStringLiteral("threadlist/header")) - .toByteArray(); - const int savedColumns = - state.value(QStringLiteral("threadlist/columns")).toInt(); - if (!header.isEmpty() && savedColumns == ThreadListModel::ColumnCount) { - m_threadView->header()->restoreState(header); - } + // No thread-list header state is read. The pane is one column drawn whole + // by CardDelegate, so there are no widths to restore; a blob saved by an + // older version is simply ignored (item 53's Upgrading note). // The config value is the starting point for a profile that has never // zoomed; once the user does, the state file is what they last had. @@ -187,11 +178,6 @@ void MainWindow::saveUiState() const state.setValue(QStringLiteral("window/geometry"), saveGeometry()); state.setValue(QStringLiteral("window/state"), saveState()); state.setValue(QStringLiteral("window/splitter"), m_splitter->saveState()); - state.setValue(QStringLiteral("threadlist/header"), - m_threadView->header()->saveState()); - // Guards the blob above: see restoreUiState(). - state.setValue(QStringLiteral("threadlist/columns"), - int(ThreadListModel::ColumnCount)); state.setValue(QStringLiteral("message/zoom"), m_messageView->zoomFactor()); } @@ -543,83 +529,42 @@ void MainWindow::buildUi() // delegate is confined to one column's rectangle. m_threadView = new ThreadListView(central); m_threadView->setModel(m_model); + m_threadView->setItemDelegate(new CardDelegate(this)); + m_threadView->setHeaderHidden(true); m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows); m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection); - m_threadView->header()->setStretchLastSection(false); - // Every column Interactive, Subject included: Stretch and ResizeToContents - // both compute a width and discard the user's drag. Nothing absorbs spare - // width as a result, so the columns end where they end. - for (int column = 0; column < ThreadListModel::ColumnCount; ++column) { - m_threadView->header()->setSectionResizeMode( - column, QHeaderView::Interactive); - } - // The expander goes on the subject column, not on column 0. Column 0 is the - // narrow attachment marker, and an expander there has no room: it pushes the - // paperclip out of a 28px column entirely. - m_threadView->setTreePosition(ThreadListModel::SubjectColumn); - - // No style-drawn branch decoration. SubjectDelegate draws the expander - // itself, because drawBranches runs BEFORE the row's cells and the - // delegate's own background paints straight over anything put there: a - // 60-pixel triangle survived as 8 pixels, indistinguishable from the - // near-invisible dot this replaces. Leaving both enabled would draw the - // theme's dot underneath the delegate's glyph. + // No style-drawn branch decoration. CardDelegate draws the expander itself, + // because drawBranches runs BEFORE the row's cells and the delegate's own + // background paints straight over anything put there: a 60-pixel triangle + // once survived as 8. Leaving both enabled would draw the theme's dot + // underneath the delegate's glyph. m_threadView->setRootIsDecorated(false); - // Wider than Qt's default 20px, and the reason is specific rather than - // aesthetic. A thread row carries an account chip in front of its subject - // and a reply row does not, so a reply's text already starts roughly a - // chip's width (~60px) to the LEFT of its thread's. At the default indent - // the 20px shift is swallowed by that difference and the replies read as - // flush with the thread, or even outdented. Verified against the running - // app, not assumed: visualRect reported a correct 20px indent while the - // rendered text showed none, because the geometry is indented and the - // delegate then lays the text out from its own left edge. - m_threadView->setIndentation(SubjectDelegate::kReplyIndent); - - // Two delegates, and the split is not cosmetic. RowStyleDelegate carries - // only the selection fix every column needs: the read/unread dimming - // arrives as a Qt::ForegroundRole, which Qt's painting prefers over the - // highlight, leaving a selected read row grey on the selection colour. - // - // SubjectDelegate adds the account chip and the tag pills, and must go on - // the subject column ALONE. It reads AccountLabelRole, a property of the - // row rather than of a cell, so installed view-wide it draws the chip into - // every column: tried once, and the list came out with a chip repeated - // four times per row. - m_threadView->setItemDelegate(new RowStyleDelegate(this)); - m_threadView->setItemDelegateForColumn(ThreadListModel::SubjectColumn, - new SubjectDelegate(this)); + // Zero, because CardLayout draws the indent itself. Qt's own indentation + // would shift the card's rect, and every rect on the card is measured from + // that rect's left edge, so the two would compound. + m_threadView->setIndentation(0); // One height for every row. A QTreeView has no vertical header to carry a - // default section size, so the height comes from uniformRowHeights plus the - // delegate's own sizeHint. uniformRowHeights is not merely an optimisation - // here: without it the tree measures every row separately and the tag strip, - // which is painted OUTSIDE any cell, is not accounted for in any of those - // measurements, so rows collapse to text height and the strip is clipped. + // default section size, so the height comes from uniformRowHeights plus + // CardDelegate::sizeHint. m_threadView->setUniformRowHeights(true); - // Widening a column past the viewport scrolls rather than squeezing the - // others. Per-pixel so the scroll does not jump a whole column at a time. - // Banding, so the eye can follow a row across four columns and a pill - // strip without losing it. The colour comes from the palette's - // AlternateBase, so it follows the desktop theme. + + // Banding, so the eye can follow a card across the pane. The colour comes + // from the palette's AlternateBase, so it follows the desktop theme. m_threadView->setAlternatingRowColors(true); - m_threadView->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); - m_threadView->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); - - // Starting widths only; a drag overrides them, and they are what the - // saved-widths item will persist. - // Without this the attachment column cannot be narrow at all: the default - // minimum section size is 58px on this platform, and setColumnWidth() - // clamps to it silently rather than reporting the smaller value back. - m_threadView->header()->setMinimumSectionSize(24); - m_threadView->setColumnWidth(ThreadListModel::AttachmentColumn, 28); - m_threadView->setColumnWidth(ThreadListModel::FlagColumn, 28); - m_threadView->setColumnWidth(ThreadListModel::DateColumn, 130); - m_threadView->setColumnWidth(ThreadListModel::AuthorsColumn, 180); - m_threadView->setColumnWidth(ThreadListModel::SubjectColumn, 520); + // A card is exactly viewport width, so there is nothing to scroll to + // sideways. Turning the bar off is what closes item 51: a click used to + // scroll the list horizontally, because the subject column was wider than + // the viewport and auto-scroll brought the clicked index fully into view. + m_threadView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + // Scrolling a whole card at a time rather than a fraction of one, so a + // card is never left half above the top edge. + m_threadView->verticalScrollBar()->setSingleStep( + CardLayout::heightFor(m_threadView->font())); // Replies are loaded when a thread is expanded, not with the query. // Walking the reply tree of every thread in a 10k-thread result would cost @@ -1061,7 +1006,7 @@ void MainWindow::buildMenus() m_threadContextMenu->addAction(m_actions.value(QStringLiteral("select_all"))); m_threadView->setContextMenuPolicy(Qt::CustomContextMenu); - connect(m_threadView, &QTableView::customContextMenuRequested, + connect(m_threadView, &QWidget::customContextMenuRequested, this, &MainWindow::showThreadContextMenu); // The frequent subset only. A toolbar holding every action is as diff --git a/src/threadlistview.cpp b/src/threadlistview.cpp index 1ffec04..5a3ce77 100644 --- a/src/threadlistview.cpp +++ b/src/threadlistview.cpp @@ -18,39 +18,34 @@ #include "threadlistview.h" -#include "tagchip.h" +#include "carddelegate.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. + // The reply count IS the expander. Anything outside its rect selects the + // card and opens it, which is what the rest of the card 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. + && index.data(ThreadListModel::ReplyCountRole).toInt() > 0) { + + QStyleOptionViewItem option; + initViewItemOption(&option); + option.rect = visualRect(index); + // State_Open decides which way the glyph points, and the rect is the + // same either way, but pass it so the layout sees the true state. + if (isExpanded(index)) + option.state |= QStyle::State_Open; + + if (CardDelegate::expanderRectFor(option, index) + .contains(event->pos())) { + setExpanded(index, !isExpanded(index)); + // Swallowed, so expanding does not also load the thread into the + // message pane: it is a request to see the thread's shape, not to + // read it. event->accept(); return; } @@ -58,203 +53,3 @@ void ThreadListView::mousePressEvent(QMouseEvent *event) QTreeView::mousePressEvent(event); } - -void ThreadListView::paintEvent(QPaintEvent *event) -{ - QTreeView::paintEvent(event); - - if (!model()) - return; - - QPainter painter(viewport()); - - // Two fonts, deliberately. The row's own font fixes where the text band - // ends, and the pills are drawn a size smaller: at the same size they read - // as a second row of content competing with the subject, rather than as - // annotation beneath it. - const QFontMetrics rowMetrics(font()); - const QFont pillFont = SubjectDelegate::pillFont(font()); - const QFontMetrics metrics(pillFont); - painter.setFont(pillFont); - - // Only the rows actually on screen, walked by INDEX rather than by row - // number. A tree numbers rows per parent, so row 0 exists once per expanded - // thread and the old flat 0..N walk would paint the first thread's strip - // over every one of them. - QModelIndex walk = indexAt(QPoint(0, 0)); - - // Counts the rows actually painted, for the alternating colour. In a tree - // that has to follow VISUAL position: row 0 under three different threads - // is three different stripes, and using index.row() would give all three - // 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; - - // 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); - - const int rowTop = rowRect.top(); - const int height = rowRect.height(); - if (height <= 0) - continue; - - // The strip's band, filled to match the row before anything is drawn - // on it. - // - // A QTableView paints alternating colours and the selection PER CELL, - // so nothing paints the width to the right of the last column, and - // nothing paints the band at all where a column does not reach. Left - // unfilled, an alternate-coloured or selected row shows the viewport - // background in a strip across its lower half. Filled for every - // visible row, not only tagged ones, since an untagged row has the - // same band to account for. - // Starting at the date column, NOT at the viewport edge. The two - // leading columns hold the attachment and flag glyphs, centred in the - // full row height, so a band drawn over them cuts those glyphs in half. - const int bandLeft = - columnViewportPosition(ThreadListModel::DateColumn); - const QRect band(bandLeft, rowTop + SubjectDelegate::kRowPadding - + rowMetrics.height(), - viewport()->width() - bandLeft, - height - SubjectDelegate::kRowPadding - - rowMetrics.height()); - - // The model's own row colour wins where it has one: a deleted or spam - // thread fills its cells with crimson or orange, and painting the base - // colour across the band beneath them would cut the row in half. - const QVariant background = index.data(Qt::BackgroundRole); - - if (background.isValid()) - painter.fillRect(band, background.value<QBrush>()); - // isSelected on the index, not isRowSelected(int): a QTreeView has no - // such overload, and a row number alone cannot name a row in a tree - // anyway since it is only unique under one parent. - else if (selectionModel() && selectionModel()->isSelected(index)) - painter.fillRect(band, palette().brush(QPalette::Highlight)); - else if (alternatingRowColors() && (visualRow % 2)) - painter.fillRect(band, palette().brush(QPalette::AlternateBase)); - else - painter.fillRect(band, palette().brush(QPalette::Base)); - - const QStringList tags = - index.data(ThreadListModel::PillTagsRole).toStringList(); - if (tags.isEmpty()) - continue; - - const QVariantList colours = - index.data(ThreadListModel::PillColoursRole).toList(); - - // The band the cells leave free, below the text they draw in the - // upper one. Measured from SubjectDelegate by both sides, so neither - // can drift into the other's half. The row's own font metrics set the - // text band; the strip's smaller font must not be used for it, or the - // pills ride up over the date and sender. - const int top = rowTop + SubjectDelegate::kRowPadding - + rowMetrics.height() + TagChip::kSpacing; - - // Aligned with the first text column rather than the viewport edge: - // the two leading columns are narrow markers for the attachment and - // flag glyphs, and a strip starting at x=0 paints straight over them. - // Indented past the date column's own left edge rather than flush with - // it: a chip starting exactly where the column does reads as part of - // the column rather than as a strip laid under the row. - int x = columnViewportPosition(ThreadListModel::DateColumn) - + TagChip::kSpacing * 2; - const int available = viewport()->width() - TagChip::kSpacing; - - for (int i = 0; i < tags.size(); ++i) { - const QSize size = TagChip::sizeFor(metrics, tags.at(i)); - - // Stop rather than wrap or elide. A row that grew to fit its tags - // would break the uniform height the list depends on, and half a - // chip reads as a rendering fault. - if (x + size.width() > available) - break; - - const QColor colour = i < colours.size() - ? colours.at(i).value<QColor>() - : QColor(0x55, 0x55, 0x5f); - - TagChip::paint(&painter, QRect(x, top, size.width(), size.height()), - tags.at(i), colour); - 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); - } -} diff --git a/src/threadlistview.h b/src/threadlistview.h index 0ebe3ea..3215153 100644 --- a/src/threadlistview.h +++ b/src/threadlistview.h @@ -20,34 +20,19 @@ #include <QTreeView> -/// The thread list, with a row-wide strip of tag chips under each row's cells. +/// The thread list. /// -/// The strip is painted by the VIEW rather than by a delegate, and that is the -/// whole reason this class exists. A delegate is handed one cell's rectangle -/// and cannot paint outside its column, so pills drawn from the subject -/// column's delegate stop at that column's edge, losing the last tags of a -/// well-tagged thread, and start at that column's left edge, which puts them -/// under the subject instead of under the row. Painting after the cells lets -/// the strip run the full width, which is what the layout asks for: +/// It exists for ONE reason now: the expander is drawn by CardDelegate, and a +/// delegate gets no click of its own without an editor, so the view has to own +/// the hit-test. Everything else it used to do is gone. /// -/// [ date ][ from ][ subject ...................... ] -/// [ pill ][ pill ][ pill ] -/// -/// The cells confine themselves to the upper band so the lower one is free; -/// SubjectDelegate::kRowPadding and rowHeightFor() are the shared measurements -/// that keep the two halves agreeing. -/// -/// A QTreeView rather than a QTableView since item 20: a thread's replies are -/// child rows, and a table can neither indent nor expand. The strip survived -/// the port because every geometry call it needs (visualRect, -/// columnViewportPosition, indexAt, indexBelow) exists on both. What did NOT -/// survive is anything keyed on a row NUMBER: a tree numbers rows per parent, -/// so row 0 exists once per expanded thread and a flat 0..N walk paints the -/// first thread's strip over every one of them. The walk below goes by index. -/// -/// The strip is painted for THREAD rows only. It carries the thread's tags, so -/// one under each reply would stripe the list and repeat identical tags down -/// the whole expansion. +/// Until item 53 this class also painted a row-wide strip of tag chips after +/// the cells, because a delegate cannot paint outside its column and the strip +/// spanned all five. With one column and one delegate painting the whole card, +/// that reason is gone and so is the paintEvent, along with the two faults it +/// kept producing: a deleted row cut in half, and every other row showing a +/// bare stripe, both from the view having to re-honour alternating colours, +/// the selection and BackgroundRole across cells it did not own. class ThreadListView : public QTreeView { Q_OBJECT @@ -55,16 +40,11 @@ public: using QTreeView::QTreeView; protected: - void paintEvent(QPaintEvent *event) override; - - /// Toggles a thread when its expander glyph is clicked. + /// Toggles a thread when its reply count is clicked. /// - /// The view owns this because the glyph is drawn by SubjectDelegate and a - /// delegate gets no click of its own without an editor. Being VISIBLE and - /// being CLICKABLE are separate properties: setRootIsDecorated(false), - /// needed to stop the style drawing its own indicator underneath ours, also - /// removed the style's hit area, so the expander painted correctly and did - /// nothing at all. + /// Being VISIBLE and being CLICKABLE are separate properties: + /// setRootIsDecorated(false), needed to stop the style drawing its own + /// indicator underneath, also removed the style's hit area, so an expander + /// once painted correctly and did nothing at all. void mousePressEvent(QMouseEvent *event) override; - }; |
