aboutsummaryrefslogtreecommitdiffstats
path: root/src/mainwindow.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/mainwindow.cpp')
-rw-r--r--src/mainwindow.cpp359
1 files changed, 276 insertions, 83 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 2a484fb..48b475c 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -18,13 +18,16 @@
#include "mainwindow.h"
+#include <QAction>
#include <QComboBox>
-#include <QEvent>
+#include <QDialog>
+#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QHeaderView>
-#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
+#include <QMenu>
+#include <QMenuBar>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
@@ -32,35 +35,24 @@
#include <QSplitter>
#include <QStatusBar>
#include <QTableView>
+#include <QToolBar>
#include <QVBoxLayout>
#include "mailsync.h"
#include "messageview.h"
#include "mimeparser.h"
#include "notmuchworker.h"
+#include "tagchip.h"
#include "threadlistmodel.h"
#include "version.h"
-QStringList MainWindow::registeredActionNames()
+QStringList MainWindow::registeredActionNames() const
{
- // Keep in sync with registerActions(). Held against KeyMap::knownActions()
- // by a test rather than by hope.
- return {
- QStringLiteral("next_thread"),
- QStringLiteral("prev_thread"),
- QStringLiteral("open_thread"),
- QStringLiteral("archive"),
- QStringLiteral("delete"),
- QStringLiteral("spam"),
- QStringLiteral("toggle_unread"),
- QStringLiteral("flag"),
- QStringLiteral("focus_query"),
- QStringLiteral("toggle_html"),
- QStringLiteral("load_remote"),
- QStringLiteral("undo"),
- QStringLiteral("sync"),
- QStringLiteral("quit"),
- };
+ // Derived from the actions themselves, so it cannot drift from what
+ // registerActions() really installed.
+ QStringList names = m_actions.keys();
+ names.sort();
+ return names;
}
QString MainWindow::cidPrefixForIndex(int index)
@@ -82,23 +74,28 @@ MainWindow::MainWindow(const Config &config, QWidget *parent)
{
QSettings settings(Config::defaultPath(), QSettings::IniFormat);
m_keyMap.loadOverrides(settings);
+ m_tagColors.load(settings);
+ }
+
+ // An account's chip colour comes from its own stanza, since an account tag
+ // is a different taxonomy from a functional one.
+ for (const Account &account : m_config.accounts()) {
+ m_tagColors.setAccountColour(account.key, account.color);
+ m_tagColors.setAccountLabel(account.key, account.label);
}
buildUi();
registerActions();
+ buildMenus();
wireWorker();
showWarnings();
- installEventFilter(this);
-
- // The thread view needs its own filter, not just the window's. A filter on
- // the window only sees key presses the focused child did not consume, and
- // QAbstractItemView consumes plain letters for its type-to-search feature:
- // with the list focused, 'h' jumped to the next thread whose subject began
- // with "h" instead of toggling HTML, and every other single-letter binding
- // (j, k, a, d, N, F, u, G) was swallowed the same way. Filtering the view
- // itself puts the keymap ahead of that search.
- m_threadView->installEventFilter(this);
+ // No event filter: QAction shortcuts are dispatched before the focused
+ // widget sees the key, so they beat QAbstractItemView's type-to-search
+ // without one. Qt also suppresses a plain-letter shortcut while an
+ // editable widget has focus, so typing in the query bar stays typing;
+ // modifier shortcuts such as Ctrl+Q still work there, which the old
+ // filter blocked.
if (!m_config.savedQueries().isEmpty()) {
m_queryEdit->setText(m_config.savedQueries().first().query);
@@ -175,20 +172,41 @@ void MainWindow::buildUi()
// Thread list and message pane.
m_model = new ThreadListModel(this);
+ m_model->setTagColors(&m_tagColors);
m_threadView = new QTableView(central);
m_threadView->setModel(m_model);
m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows);
m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection);
m_threadView->verticalHeader()->hide();
m_threadView->horizontalHeader()->setStretchLastSection(false);
- m_threadView->horizontalHeader()->setSectionResizeMode(
- ThreadListModel::SubjectColumn, QHeaderView::Stretch);
+ // 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->horizontalHeader()->setSectionResizeMode(
+ column, QHeaderView::Interactive);
+ }
+
+ // The subject cell carries the account chip in front of its text.
+ m_threadView->setItemDelegateForColumn(ThreadListModel::SubjectColumn,
+ new SubjectDelegate(this));
+ // 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.
+ 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.
+ m_threadView->setColumnWidth(ThreadListModel::DateColumn, 130);
+ m_threadView->setColumnWidth(ThreadListModel::AuthorsColumn, 180);
+ m_threadView->setColumnWidth(ThreadListModel::SubjectColumn, 520);
connect(m_threadView->selectionModel(),
&QItemSelectionModel::currentRowChanged,
this, &MainWindow::onThreadSelected);
m_messageView = new MessageView(central);
+ m_messageView->setTagColors(&m_tagColors);
connect(m_messageView, &MessageView::statusMessage,
this, [this](const QString &text) { m_statusLabel->setText(text); });
@@ -206,40 +224,77 @@ void MainWindow::buildUi()
setWindowTitle(QStringLiteral("qtmaildir %1").arg(QTMAILDIR_VERSION));
}
+QAction *MainWindow::addAction(const QString &name, const QString &text,
+ const QString &description,
+ const std::function<void()> &handler)
+{
+ auto *action = new QAction(text, this);
+ action->setObjectName(name);
+ action->setStatusTip(description);
+ m_actionDescriptions.insert(name, description);
+
+ // The binding comes from KeyMap, so a [keys] override reaches the menus
+ // and the shortcut reference as well as the keyboard.
+ const QKeySequence sequence = m_keyMap.sequenceFor(name);
+ if (!sequence.isEmpty())
+ action->setShortcut(sequence);
+
+ // Shortcuts must work while focus is in the thread list or the message
+ // view, not only on the window itself.
+ action->setShortcutContext(Qt::WindowShortcut);
+
+ connect(action, &QAction::triggered, this, handler);
+
+ // Added to the window so the shortcut is live even before the action is
+ // put in a menu; the ones that never reach a menu depend on this.
+ QMainWindow::addAction(action);
+ m_actions.insert(name, action);
+ return action;
+}
+
void MainWindow::registerActions()
{
- m_actions[QStringLiteral("focus_query")] = [this]() {
+ addAction(QStringLiteral("focus_query"), tr("&Find"),
+ tr("Focus and select the query bar"), [this]() {
m_queryEdit->setFocus();
m_queryEdit->selectAll();
- };
- m_actions[QStringLiteral("next_thread")] = [this]() {
+ });
+ addAction(QStringLiteral("next_thread"), tr("&Next thread"),
+ tr("Select the next thread"), [this]() {
const QModelIndex current = m_threadView->currentIndex();
const int row = current.isValid() ? current.row() + 1 : 0;
if (row < m_model->rowCount())
m_threadView->selectRow(row);
- };
- m_actions[QStringLiteral("prev_thread")] = [this]() {
+ });
+ addAction(QStringLiteral("prev_thread"), tr("&Previous thread"),
+ tr("Select the previous thread"), [this]() {
const QModelIndex current = m_threadView->currentIndex();
if (current.isValid() && current.row() > 0)
m_threadView->selectRow(current.row() - 1);
- };
- m_actions[QStringLiteral("open_thread")] = [this]() {
+ });
+ addAction(QStringLiteral("open_thread"), tr("&Open thread"),
+ tr("Focus the thread list"), [this]() {
m_threadView->setFocus();
- };
- m_actions[QStringLiteral("archive")] = [this]() {
+ });
+ addAction(QStringLiteral("archive"), tr("&Archive"),
+ tr("Remove inbox from every selected thread"), [this]() {
tagSelected({}, { QStringLiteral("inbox") }, tr("Archive"));
- };
- m_actions[QStringLiteral("delete")] = [this]() {
+ });
+ addAction(QStringLiteral("delete"), tr("&Delete"),
+ tr("Add the deleted tag"), [this]() {
tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete"));
- };
- m_actions[QStringLiteral("spam")] = [this]() {
+ });
+ addAction(QStringLiteral("spam"), tr("Mark &spam"),
+ tr("Add spam and remove inbox"), [this]() {
tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") },
tr("Mark spam"));
- };
- m_actions[QStringLiteral("flag")] = [this]() {
+ });
+ addAction(QStringLiteral("flag"), tr("&Flag"),
+ tr("Add the flagged tag"), [this]() {
tagSelected({ QStringLiteral("flagged") }, {}, tr("Flag"));
- };
- m_actions[QStringLiteral("toggle_unread")] = [this]() {
+ });
+ addAction(QStringLiteral("toggle_unread"), tr("Toggle &unread"),
+ tr("Toggle the unread tag"), [this]() {
// The direction comes from the current row, but the change applies to
// the whole selection, so a mixed selection lands in one consistent
// state rather than each row flipping its own way.
@@ -251,28 +306,176 @@ void MainWindow::registerActions()
tagSelected({}, { QStringLiteral("unread") }, tr("Mark read"));
else
tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread"));
- };
- m_actions[QStringLiteral("toggle_html")] = [this]() {
+ });
+ addAction(QStringLiteral("toggle_html"), tr("Toggle &HTML"),
+ tr("Switch the thread between HTML and plain text"), [this]() {
m_messageView->toggleHtml();
- };
- m_actions[QStringLiteral("load_remote")] = [this]() {
+ });
+ addAction(QStringLiteral("load_remote"), tr("Load &remote content"),
+ tr("Load remote images for the current thread"), [this]() {
m_messageView->loadRemoteContent();
- };
- m_actions[QStringLiteral("undo")] = [this]() {
+ });
+ addAction(QStringLiteral("undo"), tr("&Undo"),
+ tr("Undo the last tag change"), [this]() {
if (m_undoStack.canUndo())
m_undoStack.undo();
else
m_statusLabel->setText(tr("Nothing to undo"));
- };
- m_actions[QStringLiteral("sync")] = [this]() {
+ });
+ addAction(QStringLiteral("sync"), tr("&Sync"),
+ tr("Run the configured sync command"), [this]() {
if (m_sync->isAvailable())
m_sync->start();
+ });
+ addAction(QStringLiteral("quit"), tr("&Quit"),
+ tr("Quit qtmaildir"), [this]() { close(); });
+
+ // A binding the user wrote for an action that does not exist would be
+ // silently dead. KeyMap warns about unknown names, but only a check here
+ // catches the reverse: a known action nothing implements.
+ Q_ASSERT(m_actions.size() == KeyMap::knownActions().size());
+}
+
+void MainWindow::buildMenus()
+{
+ auto *fileMenu = menuBar()->addMenu(tr("&File"));
+ fileMenu->addAction(m_actions.value(QStringLiteral("sync")));
+ fileMenu->addSeparator();
+ fileMenu->addAction(m_actions.value(QStringLiteral("quit")));
+
+ auto *editMenu = menuBar()->addMenu(tr("&Edit"));
+ editMenu->addAction(m_actions.value(QStringLiteral("undo")));
+ editMenu->addSeparator();
+ editMenu->addAction(m_actions.value(QStringLiteral("focus_query")));
+
+ auto *messageMenu = menuBar()->addMenu(tr("&Message"));
+ messageMenu->addAction(m_actions.value(QStringLiteral("archive")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("delete")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("spam")));
+ messageMenu->addSeparator();
+ messageMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("flag")));
+
+ auto *viewMenu = menuBar()->addMenu(tr("&View"));
+ viewMenu->addAction(m_actions.value(QStringLiteral("prev_thread")));
+ viewMenu->addAction(m_actions.value(QStringLiteral("next_thread")));
+ viewMenu->addSeparator();
+ viewMenu->addAction(m_actions.value(QStringLiteral("toggle_html")));
+ viewMenu->addAction(m_actions.value(QStringLiteral("load_remote")));
+
+ auto *helpMenu = menuBar()->addMenu(tr("&Help"));
+ auto *shortcuts = helpMenu->addAction(tr("&Keyboard shortcuts"));
+ connect(shortcuts, &QAction::triggered,
+ this, &MainWindow::showShortcutReference);
+ auto *about = helpMenu->addAction(tr("&About"));
+ connect(about, &QAction::triggered, this, &MainWindow::showAbout);
+
+ // Standard names from the icon theme, so the buttons match the rest of the
+ // desktop rather than shipping bespoke art. A theme that lacks one leaves
+ // that action with text alone, which still works.
+ const QHash<QString, QString> themeIcons = {
+ { QStringLiteral("sync"), QStringLiteral("mail-receive") },
+ { QStringLiteral("archive"), QStringLiteral("mail-mark-read") },
+ { QStringLiteral("delete"), QStringLiteral("edit-delete") },
+ { QStringLiteral("undo"), QStringLiteral("edit-undo") },
+ { QStringLiteral("spam"), QStringLiteral("mail-mark-junk") },
+ { QStringLiteral("flag"), QStringLiteral("mail-mark-important") },
+ { QStringLiteral("quit"), QStringLiteral("application-exit") },
+ { QStringLiteral("focus_query"), QStringLiteral("edit-find") },
};
- m_actions[QStringLiteral("quit")] = [this]() { close(); };
+ for (auto it = themeIcons.cbegin(); it != themeIcons.cend(); ++it) {
+ QAction *action = m_actions.value(it.key());
+ if (!action)
+ continue;
+ const QIcon icon = QIcon::fromTheme(it.value());
+ if (!icon.isNull())
+ action->setIcon(icon);
+ }
+
+ // The frequent subset only. A toolbar holding every action is as
+ // unreadable as no toolbar.
+ auto *toolBar = addToolBar(tr("Main"));
+ toolBar->setObjectName(QStringLiteral("main_toolbar"));
+ toolBar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ toolBar->addAction(m_actions.value(QStringLiteral("sync")));
+ toolBar->addSeparator();
+ toolBar->addAction(m_actions.value(QStringLiteral("archive")));
+ toolBar->addAction(m_actions.value(QStringLiteral("delete")));
+ toolBar->addSeparator();
+ toolBar->addAction(m_actions.value(QStringLiteral("undo")));
+}
+
+void MainWindow::showShortcutReference()
+{
+ // Generated from the actions, so it cannot disagree with what the keys
+ // really do. A hand-written list would drift the first time a binding
+ // changed.
+ QStringList rows;
+ for (const QString &name : registeredActionNames()) {
+ const QAction *action = m_actions.value(name);
+ if (!action)
+ continue;
+ const QString sequence = action->shortcut().toString(QKeySequence::NativeText);
+ rows.append(QStringLiteral("<tr><td><tt>%1</tt>&nbsp;&nbsp;</td>"
+ "<td>%2&nbsp;&nbsp;</td>"
+ "<td><tt>%3</tt></td></tr>")
+ .arg(sequence.isEmpty() ? tr("(unbound)") : sequence.toHtmlEscaped(),
+ m_actionDescriptions.value(name).toHtmlEscaped(),
+ name.toHtmlEscaped()));
+ }
- // The two lists are maintained by hand and a test pins them together; this
- // catches the same drift in a debug run.
- Q_ASSERT(m_actions.size() == registeredActionNames().size());
+ // Two columns rather than one. Fourteen actions in a single table made a
+ // dialog taller than the screen, which cut off its own title bar.
+ const int half = (rows.size() + 1) / 2;
+ const QString header =
+ tr("<tr><th align='left'>Key</th><th align='left'>Does</th>"
+ "<th align='left'>Action name</th></tr>");
+ const QString left = header + rows.mid(0, half).join(QString());
+ const QString right = header + rows.mid(half).join(QString());
+
+ // A QDialog rather than QMessageBox: the message box wraps its text at a
+ // narrow default width, which turned every description into a column of
+ // single words and made the dialog taller than the screen.
+ QDialog dialog(this);
+ dialog.setWindowTitle(tr("Keyboard shortcuts"));
+
+ auto *label = new QLabel(&dialog);
+ label->setTextFormat(Qt::RichText);
+ label->setText(tr("<table cellspacing='0'><tr>"
+ "<td valign='top'><table cellpadding='3'>%1</table></td>"
+ "<td width='32'></td>"
+ "<td valign='top'><table cellpadding='3'>%2</table></td>"
+ "</tr></table>")
+ .arg(left, right));
+
+ auto *note = new QLabel(
+ tr("Rebind any of these in the <tt>[keys]</tt> section of "
+ "<tt>qtmaildir.conf</tt>, using the action name."),
+ &dialog);
+ note->setTextFormat(Qt::RichText);
+ note->setWordWrap(true);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok, &dialog);
+ connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
+
+ auto *layout = new QVBoxLayout(&dialog);
+ layout->addWidget(label);
+ layout->addWidget(note);
+ layout->addStretch();
+ layout->addWidget(buttons);
+
+ dialog.exec();
+}
+
+void MainWindow::showAbout()
+{
+ QMessageBox::about(
+ this, tr("About qtmaildir"),
+ tr("<h3>qtmaildir %1</h3>"
+ "<p>A Qt6 mail client for notmuch-indexed Maildirs.</p>"
+ "<p>Reads and organizes local mail. Fetching and sending are "
+ "handled by external scripts.</p>")
+ .arg(QStringLiteral(QTMAILDIR_VERSION)));
}
void MainWindow::wireWorker()
@@ -376,7 +579,9 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
if (!current.isValid())
return;
- m_currentThreadId = m_model->threadAt(current.row()).threadId;
+ const ThreadSummary thread = m_model->threadAt(current.row());
+ m_currentThreadId = thread.threadId;
+ m_messageView->setTags(thread.tags);
QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection,
Q_ARG(QString, m_currentThreadId),
Q_ARG(QString, m_lastQuery),
@@ -498,6 +703,14 @@ void MainWindow::sendThreadTagChange(const QStringList &threadIds,
for (const QString &threadId : threadIds)
m_model->applyTagChange(threadId, add, remove);
+ // The strip shows the open thread's tags, so it has to follow a change to
+ // that thread rather than waiting for the next selection.
+ if (threadIds.contains(m_currentThreadId)) {
+ const QModelIndex current = m_threadView->currentIndex();
+ if (current.isValid())
+ m_messageView->setTags(m_model->threadAt(current.row()).tags);
+ }
+
m_pendingThreadIds = threadIds;
m_pendingChange = TagChange{ {}, add, remove, description };
@@ -511,23 +724,3 @@ void MainWindow::sendThreadTagChange(const QStringList &threadIds,
Q_ARG(QString, description));
}
-bool MainWindow::eventFilter(QObject *watched, QEvent *event)
-{
- if (event->type() != QEvent::KeyPress)
- return QMainWindow::eventFilter(watched, event);
-
- // The query bar must receive ordinary typing, so single-key bindings are
- // suppressed while it has focus.
- if (m_queryEdit->hasFocus())
- return QMainWindow::eventFilter(watched, event);
-
- auto *keyEvent = static_cast<QKeyEvent *>(event);
- const QKeySequence sequence(keyEvent->keyCombination());
-
- const QString action = m_keyMap.actionFor(sequence);
- if (action.isEmpty() || !m_actions.contains(action))
- return QMainWindow::eventFilter(watched, event);
-
- m_actions.value(action)();
- return true;
-}