summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/keymap.cpp4
-rw-r--r--src/mainwindow.cpp147
-rw-r--r--src/mainwindow.h23
3 files changed, 174 insertions, 0 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 0901cfb..dcb63a8 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -35,6 +35,7 @@ QStringList KeyMap::knownActions()
QStringLiteral("flag"),
QStringLiteral("focus_query"),
QStringLiteral("complete_query"),
+ QStringLiteral("select_all"),
QStringLiteral("toggle_html"),
QStringLiteral("load_remote"),
QStringLiteral("message_details"),
@@ -72,6 +73,9 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
// shells and editors, and it is a named key rather than a symbol, so
// no layout has to shift it.
{ QStringLiteral("Ctrl+Space"), QStringLiteral("complete_query") },
+ // The conventional select-all key, and free here: the thread list is a
+ // read-only view, so nothing else in the window wants it.
+ { QStringLiteral("Ctrl+A"), QStringLiteral("select_all") },
{ QStringLiteral("Ctrl+H"), QStringLiteral("toggle_html") },
{ QStringLiteral("Ctrl+M"), QStringLiteral("load_remote") },
// Shifted because Ctrl+D is delete. Both are "D for details/delete"
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 0a9f9d8..b3b67c5 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -325,6 +325,7 @@ void MainWindow::buildUi()
// The status label is created first: the sync wiring below can report into
// it before the rest of the UI exists.
m_statusLabel = new QLabel(this);
+ m_statusLabel->setObjectName(QStringLiteral("statusMessage"));
statusBar()->addWidget(m_statusLabel);
// Beside the sync status rather than as a widget competing with it: the two
@@ -488,6 +489,16 @@ void MainWindow::buildUi()
&QItemSelectionModel::currentRowChanged,
this, &MainWindow::onThreadSelected);
+ // Separate from currentRowChanged: a selection can grow without current
+ // moving at all. Ctrl+click adds a row and leaves current where it was, and
+ // selectAll() emits no currentRowChanged whatsoever (verified against
+ // Qt 6.11). Both are multi-select gestures that have to blank the pane and
+ // cancel a pending mark-read, so neither can rely on the current-index
+ // signal to notice them.
+ connect(m_threadView->selectionModel(),
+ &QItemSelectionModel::selectionChanged,
+ this, &MainWindow::onSelectionChanged);
+
m_messageView = new MessageView(central);
m_messageView->setTagColors(&m_tagColors);
connect(m_messageView, &MessageView::statusMessage,
@@ -659,6 +670,14 @@ void MainWindow::registerActions()
m_queryEdit->setFocus();
m_queryCompleter->triggerCompletion();
});
+ addAction(QStringLiteral("select_all"), tr("Select &all threads"),
+ tr("Select every thread in the current result list"), [this]() {
+ // A registered action rather than the view's built-in SelectAll key, so
+ // it reaches the Edit menu, the shortcut reference and [keys] the same
+ // way every other binding does. That is the whole point: multi-select
+ // already worked, it was simply invisible.
+ m_threadView->selectAll();
+ });
addAction(QStringLiteral("quit"), tr("&Quit"),
tr("Quit qtmaildir"), [this]() { close(); });
@@ -680,6 +699,8 @@ void MainWindow::buildMenus()
editMenu->addSeparator();
editMenu->addAction(m_actions.value(QStringLiteral("focus_query")));
editMenu->addAction(m_actions.value(QStringLiteral("complete_query")));
+ editMenu->addSeparator();
+ editMenu->addAction(m_actions.value(QStringLiteral("select_all")));
auto *messageMenu = menuBar()->addMenu(tr("&Message"));
messageMenu->addAction(m_actions.value(QStringLiteral("archive")));
@@ -731,6 +752,29 @@ void MainWindow::buildMenus()
action->setIcon(icon);
}
+ // Right-click on the thread list. Built from the same registered QActions
+ // as the menu bar, never from parallel copies: a [keys] override then shows
+ // the right shortcut here too, and an action cannot end up doing one thing
+ // from the menu bar and another from the context menu.
+ //
+ // Every entry applies to the whole selection already, since they all funnel
+ // through tagSelected(), so this needs no multi-row special casing.
+ m_threadContextMenu = new QMenu(this);
+ m_threadContextMenu->setObjectName(QStringLiteral("threadContextMenu"));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("archive")));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("delete")));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("spam")));
+ m_threadContextMenu->addSeparator();
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("flag")));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
+ m_threadContextMenu->addSeparator();
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("select_all")));
+
+ m_threadView->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(m_threadView, &QTableView::customContextMenuRequested,
+ this, &MainWindow::showThreadContextMenu);
+
// The frequent subset only. A toolbar holding every action is as
// unreadable as no toolbar.
auto *toolBar = addToolBar(tr("Main"));
@@ -787,6 +831,18 @@ void MainWindow::showShortcutReference()
"</tr></table>")
.arg(left, right));
+ // Mouse selection is view behaviour, not an action, so it cannot appear in
+ // the table above however the table is generated. Said here because it is
+ // otherwise undiscoverable: nothing in the UI hints that a thread list
+ // takes more than one row at a time.
+ auto *selectionNote = new QLabel(
+ tr("<b>Thread list:</b> <tt>Ctrl</tt>+click adds or removes a single "
+ "row, <tt>Shift</tt>+click extends the selection to a range. Tag, "
+ "archive and delete all apply to every selected thread."),
+ &dialog);
+ selectionNote->setTextFormat(Qt::RichText);
+ selectionNote->setWordWrap(true);
+
auto *note = new QLabel(
tr("Rebind any of these in the <tt>[keys]</tt> section of "
"<tt>qtmaildir.conf</tt>, using the action name."),
@@ -799,6 +855,7 @@ void MainWindow::showShortcutReference()
auto *layout = new QVBoxLayout(&dialog);
layout->addWidget(label);
+ layout->addWidget(selectionNote);
layout->addWidget(note);
layout->addStretch();
layout->addWidget(buttons);
@@ -973,12 +1030,94 @@ void MainWindow::onQueryFinished(int total, quint64 generation)
m_statusLabel->setText(tr("%n thread(s)", "", total));
}
+void MainWindow::showThreadContextMenu(const QPoint &pos)
+{
+ const QModelIndex index = m_threadView->indexAt(pos);
+ if (!index.isValid())
+ return; // Right-click on empty space below the rows.
+
+ // Right-clicking a row that is already part of the selection must leave
+ // that selection alone: the actions apply to every selected thread, so
+ // collapsing to the clicked row here would silently narrow a deliberate
+ // multi-row selection to one. Right-clicking outside it selects that row
+ // instead, which is what every other list does.
+ if (!m_threadView->selectionModel()->isRowSelected(index.row()))
+ m_threadView->selectRow(index.row());
+
+ m_threadContextMenu->popup(m_threadView->viewport()->mapToGlobal(pos));
+}
+
+void MainWindow::onSelectionChanged()
+{
+ const int selected = m_threadView->selectionModel()->selectedRows().size();
+ if (selected <= 1) {
+ // Clearing the count here would wipe whatever the last action reported
+ // ("Archive: 3 threads"), which is the more useful message once the
+ // selection is gone. Only a count this function wrote is taken back.
+ if (m_statusLabel->text() == m_selectionMessage)
+ m_statusLabel->clear();
+ m_selectionMessage.clear();
+
+ // Collapsing a multi-row selection back to one row has to load that
+ // row here, and cannot be left to onThreadSelected. currentRowChanged
+ // is emitted BEFORE the selection model is updated (verified against
+ // Qt 6.11), so when a click collapses three rows to one, that handler
+ // still sees three selected, takes the multi-select branch and returns
+ // without loading anything. Only this signal sees the real count.
+ const QModelIndex current = m_threadView->currentIndex();
+ if (current.isValid()
+ && m_model->threadAt(current.row()).threadId != m_currentThreadId) {
+ onThreadSelected(current, QModelIndex());
+ }
+ return;
+ }
+
+ // The count is the part that actually teaches multi-select: it acknowledges
+ // the selection while it is being built, rather than only after an action
+ // has already been applied to it.
+ m_selectionMessage = tr("%n thread(s) selected", "", selected);
+ m_statusLabel->setText(m_selectionMessage);
+
+ // Ctrl+click and selectAll() reach a multi-row selection without moving
+ // current, so onThreadSelected never runs and its guard never fires. The
+ // pane and the pending timer have to be dealt with here as well.
+ m_markReadTimer->stop();
+ m_markReadThreadId.clear();
+ m_currentThreadId.clear();
+ m_messageView->clear();
+}
+
void MainWindow::onThreadSelected(const QModelIndex &current,
const QModelIndex &)
{
if (!current.isValid())
return;
+ // A selection spanning more than one row is aimed at a bulk action, not at
+ // reading. current follows the keyboard cursor as the selection extends, so
+ // without this every row swept through would be rendered and, worse,
+ // queued to be marked read: a selection gesture must not mutate mail.
+ //
+ // The count read here is deliberately not trusted on its own. This signal
+ // is emitted BEFORE the selection model is updated (verified against
+ // Qt 6.11), so a Ctrl+click that takes the selection from one row to two
+ // arrives here still reporting one. onSelectionChanged() always follows and
+ // sees the true count, and it is what finally blanks the pane and cancels
+ // the timer; this branch only catches the case where the count is already
+ // stale in the other direction.
+ //
+ // The stop() is not redundant with the guard. Clicking one row arms a timer
+ // legitimately and only then does the selection grow, so the timer already
+ // running for that first row has to be cancelled here or it fires behind a
+ // pane that no longer shows the thread.
+ if (m_threadView->selectionModel()->selectedRows().size() > 1) {
+ m_markReadTimer->stop();
+ m_markReadThreadId.clear();
+ m_currentThreadId.clear();
+ m_messageView->clear();
+ return;
+ }
+
const ThreadSummary thread = m_model->threadAt(current.row());
m_currentThreadId = thread.threadId;
m_messageView->setTags(thread.tags);
@@ -995,6 +1134,14 @@ void MainWindow::onThreadLoaded(const QVector<MessageRef> &messages,
if (generation != m_generation || messages.isEmpty())
return;
+ // A load started while the selection was still a single row can land after
+ // it has grown: loadThread crosses to the worker on a queued connection, so
+ // the reply arrives after onSelectionChanged() has already blanked the
+ // pane. Without this it would paint a thread back over the blank, and the
+ // pane would only look right once a third row made the count stale-proof.
+ if (m_threadView->selectionModel()->selectedRows().size() > 1)
+ return;
+
MimeParser parser;
QList<ThreadRenderItem> items;
items.reserve(messages.size());
diff --git a/src/mainwindow.h b/src/mainwindow.h
index d1f7b1c..933c71b 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -33,6 +33,7 @@
class QAction;
class QLineEdit;
+class QMenu;
class QTableView;
class QLabel;
class QPushButton;
@@ -60,6 +61,12 @@ public:
/// really registered.
QStringList registeredActionNames() const;
+ /// The thread currently shown in the message pane, empty when it is blank.
+ ///
+ /// Empty is what "the pane is blanked" means internally: a late-arriving
+ /// load is discarded rather than painted, so no thread can reappear.
+ QString currentThreadId() const { return m_currentThreadId; }
+
/// The cid: namespace prefix for the nth message of a thread.
///
/// MainWindow is the only producer of this value in the application. It
@@ -95,6 +102,14 @@ private slots:
void onThreadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
void onQueryFinished(int total, quint64 generation);
void onThreadSelected(const QModelIndex &current, const QModelIndex &previous);
+
+ /// Keeps the status bar's selection count and the multi-select guard in
+ /// step with selections that never move the current index.
+ void onSelectionChanged();
+
+ /// Pops up the thread-list context menu, preserving a multi-row selection
+ /// the click lands inside.
+ void showThreadContextMenu(const QPoint &pos);
void onThreadLoaded(const QVector<MessageRef> &messages, quint64 generation);
void onWorkerError(const QString &message);
void onSyncFinished(bool success, int exitCode);
@@ -196,6 +211,10 @@ private:
QLineEdit *m_queryEdit = nullptr;
QueryCompleter *m_queryCompleter = nullptr;
QTableView *m_threadView = nullptr;
+
+ /// Right-click menu for the thread list, holding the same QActions the
+ /// menu bar does.
+ QMenu *m_threadContextMenu = nullptr;
QSplitter *m_splitter = nullptr;
QComboBox *m_accountBox = nullptr;
QPushButton *m_syncButton = nullptr;
@@ -230,6 +249,10 @@ private:
QString m_lastQuery;
QString m_currentThreadId;
+ /// The selection count last written to the status bar, so it can be taken
+ /// back without clobbering a message some other action put there.
+ QString m_selectionMessage;
+
/// Confirmed tag mutations not yet known to have reached the mail store.
///
/// A count of its own rather than QUndoStack::isClean(), which cannot serve