diff options
Diffstat (limited to 'src/mainwindow.cpp')
| -rw-r--r-- | src/mainwindow.cpp | 359 |
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> </td>" + "<td>%2 </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 ¤t, 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; -} |
