diff options
Diffstat (limited to 'src/mainwindow.cpp')
| -rw-r--r-- | src/mainwindow.cpp | 495 |
1 files changed, 495 insertions, 0 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp new file mode 100644 index 0000000..ed16010 --- /dev/null +++ b/src/mainwindow.cpp @@ -0,0 +1,495 @@ +#include "mainwindow.h" + +#include <QComboBox> +#include <QEvent> +#include <QHBoxLayout> +#include <QHeaderView> +#include <QKeyEvent> +#include <QLabel> +#include <QLineEdit> +#include <QMessageBox> +#include <QPlainTextEdit> +#include <QPushButton> +#include <QSettings> +#include <QSplitter> +#include <QStatusBar> +#include <QTableView> +#include <QVBoxLayout> + +#include "mailsync.h" +#include "messageview.h" +#include "mimeparser.h" +#include "notmuchworker.h" +#include "threadlistmodel.h" + +QStringList MainWindow::registeredActionNames() +{ + // 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"), + }; +} + +QString MainWindow::cidPrefixForIndex(int index) +{ + // "m<index>" is digits only after the 'm', so it cannot contain '!'. + return QStringLiteral("m%1").arg(index); +} + +MainWindow::MainWindow(const Config &config, QWidget *parent) + : QMainWindow(parent), m_config(config) +{ + qRegisterMetaType<ThreadSummary>(); + qRegisterMetaType<MessageRef>(); + qRegisterMetaType<TagChange>(); + qRegisterMetaType<QVector<ThreadSummary>>(); + qRegisterMetaType<QVector<MessageRef>>(); + + m_keyMap.loadDefaults(); + { + QSettings settings(Config::defaultPath(), QSettings::IniFormat); + m_keyMap.loadOverrides(settings); + } + + buildUi(); + registerActions(); + wireWorker(); + showWarnings(); + + installEventFilter(this); + + if (!m_config.savedQueries().isEmpty()) { + m_queryEdit->setText(m_config.savedQueries().first().query); + runCurrentQuery(); + } +} + +MainWindow::~MainWindow() +{ + m_workerThread.quit(); + m_workerThread.wait(); +} + +void MainWindow::buildUi() +{ + auto *central = new QWidget(this); + auto *layout = new QVBoxLayout(central); + + // 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); + statusBar()->addWidget(m_statusLabel); + + // Query row. + auto *queryRow = new QHBoxLayout; + m_accountBox = new QComboBox(central); + m_accountBox->addItem(tr("All accounts"), QString()); + for (const Account &account : m_config.accounts()) + m_accountBox->addItem(account.key, account.key); + + m_queryEdit = new QLineEdit(central); + m_queryEdit->setPlaceholderText(tr("notmuch query, e.g. tag:inbox")); + connect(m_queryEdit, &QLineEdit::returnPressed, + this, &MainWindow::runCurrentQuery); + + m_syncLog = new QPlainTextEdit(central); + m_syncLog->setReadOnly(true); + m_syncLog->setMaximumHeight(120); + m_syncLog->hide(); + + m_syncButton = new QPushButton(tr("Sync"), central); + m_sync = new MailSync(m_config.syncCommand(), this); + m_syncButton->setEnabled(m_sync->isAvailable()); + if (!m_sync->isAvailable()) { + m_syncButton->setToolTip( + tr("No sync command configured ([sync] command in qtmaildir.conf)")); + } + connect(m_syncButton, &QPushButton::clicked, this, [this]() { + if (!m_sync->start()) + m_statusLabel->setText(tr("Sync already running")); + }); + connect(m_sync, &MailSync::finished, this, &MainWindow::onSyncFinished); + connect(m_sync, &MailSync::outputReceived, this, [this](const QString &chunk) { + m_syncLog->appendPlainText(chunk.trimmed()); + }); + + queryRow->addWidget(m_accountBox); + queryRow->addWidget(m_queryEdit, 1); + queryRow->addWidget(m_syncButton); + layout->addLayout(queryRow); + + // Saved query buttons. + auto *savedRow = new QHBoxLayout; + for (const SavedQuery &saved : m_config.savedQueries()) { + auto *button = new QPushButton(saved.name, central); + connect(button, &QPushButton::clicked, this, [this, saved]() { + m_queryEdit->setText(saved.query); + runCurrentQuery(); + }); + savedRow->addWidget(button); + } + savedRow->addStretch(); + layout->addLayout(savedRow); + + // Thread list and message pane. + m_model = new ThreadListModel(this); + 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); + + connect(m_threadView->selectionModel(), + &QItemSelectionModel::currentRowChanged, + this, &MainWindow::onThreadSelected); + + m_messageView = new MessageView(central); + connect(m_messageView, &MessageView::statusMessage, + this, [this](const QString &text) { m_statusLabel->setText(text); }); + + auto *splitter = new QSplitter(Qt::Horizontal, central); + splitter->addWidget(m_threadView); + splitter->addWidget(m_messageView); + splitter->setStretchFactor(1, 2); + layout->addWidget(splitter, 1); + + layout->addWidget(m_syncLog); + + setCentralWidget(central); + + resize(1200, 800); + setWindowTitle(tr("qtmaildir")); +} + +void MainWindow::registerActions() +{ + m_actions[QStringLiteral("focus_query")] = [this]() { + m_queryEdit->setFocus(); + m_queryEdit->selectAll(); + }; + m_actions[QStringLiteral("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]() { + const QModelIndex current = m_threadView->currentIndex(); + if (current.isValid() && current.row() > 0) + m_threadView->selectRow(current.row() - 1); + }; + m_actions[QStringLiteral("open_thread")] = [this]() { + m_threadView->setFocus(); + }; + m_actions[QStringLiteral("archive")] = [this]() { + tagSelected({}, { QStringLiteral("inbox") }, tr("Archive")); + }; + m_actions[QStringLiteral("delete")] = [this]() { + tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete")); + }; + m_actions[QStringLiteral("spam")] = [this]() { + tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") }, + tr("Mark spam")); + }; + m_actions[QStringLiteral("flag")] = [this]() { + tagSelected({ QStringLiteral("flagged") }, {}, tr("Flag")); + }; + m_actions[QStringLiteral("toggle_unread")] = [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. + const QModelIndex current = m_threadView->currentIndex(); + if (!current.isValid()) + return; + const ThreadSummary thread = m_model->threadAt(current.row()); + if (thread.isUnread()) + tagSelected({}, { QStringLiteral("unread") }, tr("Mark read")); + else + tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread")); + }; + m_actions[QStringLiteral("toggle_html")] = [this]() { + m_messageView->toggleHtml(); + }; + m_actions[QStringLiteral("load_remote")] = [this]() { + m_messageView->loadRemoteContent(); + }; + m_actions[QStringLiteral("undo")] = [this]() { + if (m_undoStack.canUndo()) + m_undoStack.undo(); + else + m_statusLabel->setText(tr("Nothing to undo")); + }; + m_actions[QStringLiteral("sync")] = [this]() { + if (m_sync->isAvailable()) + m_sync->start(); + }; + m_actions[QStringLiteral("quit")] = [this]() { close(); }; + + // 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()); +} + +void MainWindow::wireWorker() +{ + m_worker = new NotmuchWorker(m_config.notmuchConfig()); + m_worker->moveToThread(&m_workerThread); + connect(&m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater); + + connect(m_worker, &NotmuchWorker::threadsReady, + this, &MainWindow::onThreadsReady); + connect(m_worker, &NotmuchWorker::queryFinished, + this, &MainWindow::onQueryFinished); + connect(m_worker, &NotmuchWorker::threadLoaded, + this, &MainWindow::onThreadLoaded); + connect(m_worker, &NotmuchWorker::errorOccurred, + this, &MainWindow::onWorkerError); + + // A confirmed write clears the pending revert: without this, a later + // unrelated error would roll back a change that actually succeeded. + connect(m_worker, &NotmuchWorker::tagsApplied, this, [this](const TagChange &) { + m_pendingChange = {}; + m_pendingThreadIds.clear(); + }); + + m_workerThread.start(); +} + +void MainWindow::showWarnings() +{ + QStringList warnings = m_config.warnings() + m_keyMap.warnings(); + if (warnings.isEmpty()) + return; + + // Non-fatal: the app runs degraded rather than refusing to start. + m_statusLabel->setText( + tr("%1 configuration warning(s); see Help").arg(warnings.size())); + QMessageBox::warning(this, tr("Configuration warnings"), + warnings.join(QLatin1Char('\n'))); +} + +void MainWindow::runCurrentQuery() +{ + QString query = m_queryEdit->text().trimmed(); + + const QString accountKey = m_accountBox->currentData().toString(); + if (!accountKey.isEmpty()) + query = m_config.account(accountKey).scopedQuery(query); + + if (query.isEmpty()) + return; + + // Kept so loadThread() can work out which messages of a thread matched. + m_lastQuery = query; + + ++m_generation; + m_model->clear(); + m_messageView->clear(); + + // Undo entries refer to rows that are about to be discarded. The model + // update they invert would be a no-op against the new result set, leaving + // undo half-applied: the database would change and the list would not. + m_undoStack.clear(); + m_pendingChange = {}; + m_pendingThreadIds.clear(); + + m_statusLabel->setText(tr("Searching...")); + + QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection, + Q_ARG(QString, query), + Q_ARG(quint64, m_generation)); +} + +void MainWindow::onThreadsReady(const QVector<ThreadSummary> &threads, + quint64 generation) +{ + if (generation != m_generation) + return; // Superseded by a newer query. + m_model->appendBatch(threads); +} + +void MainWindow::onQueryFinished(int total, quint64 generation) +{ + if (generation != m_generation) + return; + m_statusLabel->setText(tr("%n thread(s)", "", total)); +} + +void MainWindow::onThreadSelected(const QModelIndex ¤t, + const QModelIndex &) +{ + if (!current.isValid()) + return; + + m_currentThreadId = m_model->threadAt(current.row()).threadId; + QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection, + Q_ARG(QString, m_currentThreadId), + Q_ARG(QString, m_lastQuery), + Q_ARG(quint64, m_generation)); +} + +void MainWindow::onThreadLoaded(const QVector<MessageRef> &messages, + quint64 generation) +{ + if (generation != m_generation || messages.isEmpty()) + return; + + MimeParser parser; + QList<ThreadRenderItem> items; + items.reserve(messages.size()); + + for (int i = 0; i < messages.size(); ++i) { + const MessageRef &ref = messages.at(i); + + ThreadRenderItem item; + item.message = parser.parse(ref.filePath); + + if (!item.message.ok) { + // One unreadable message must not lose the rest of the thread, so + // it becomes an inline note rather than replacing the whole pane. + item.message = {}; + item.message.ok = true; + item.message.from = tr("(unreadable message)"); + item.message.subject = ref.filePath; + item.message.plainBody = + tr("This message could not be parsed.\n%1").arg(ref.filePath); + } + + // Namespace prefix keeps cid: references distinct across the thread. + item.cidPrefix = cidPrefixForIndex(i); + + // Matched messages open; the rest collapse to a stub. The last message + // always opens, so a thread never renders as nothing but stubs. + item.expanded = ref.matched || i == messages.size() - 1; + + items.append(item); + } + + m_messageView->showThread(items); +} + +void MainWindow::revertPendingTagChange() +{ + if (m_pendingThreadIds.isEmpty()) + return; + + // Put the rows back the way they were. Only the model is touched: the + // worker never applied the change, so there is nothing to undo there. + for (const QString &threadId : m_pendingThreadIds) { + m_model->applyTagChange(threadId, m_pendingChange.removed, + m_pendingChange.added); + } + + // The undo entry describes a change that never landed, so it would apply a + // spurious inverse if the user pressed undo. + if (m_undoStack.canUndo()) + m_undoStack.undo(); + m_undoStack.clear(); + + m_pendingChange = {}; + m_pendingThreadIds.clear(); +} + +void MainWindow::onWorkerError(const QString &message) +{ + // Spec: the UI updates optimistically and reverts if the write fails. + // Without this the list would keep showing a tag the database never got. + revertPendingTagChange(); + m_statusLabel->setText(message); +} + +void MainWindow::onSyncFinished(bool success, int exitCode) +{ + if (success) { + m_statusLabel->setText(tr("Sync complete")); + runCurrentQuery(); + } else { + m_statusLabel->setText(tr("Sync failed (exit %1)").arg(exitCode)); + m_syncLog->show(); + } +} + +void MainWindow::tagSelected(const QStringList &add, const QStringList &remove, + const QString &description) +{ + const QModelIndexList rows = + m_threadView->selectionModel()->selectedRows(); + if (rows.isEmpty()) + return; + + QStringList threadIds; + threadIds.reserve(rows.size()); + for (const QModelIndex &index : rows) + threadIds.append(m_model->threadAt(index.row()).threadId); + + sendThreadTagChange(threadIds, add, remove, description); + + // Pushed for undo. The inverse re-resolves the same threads, so it works + // whether or not those rows are still selected. + m_undoStack.push(new ThreadTagCommand(this, threadIds, add, remove, + description)); + + m_statusLabel->setText( + tr("%1: %n thread(s)", "", threadIds.size()).arg(description)); +} + +void MainWindow::sendThreadTagChange(const QStringList &threadIds, + const QStringList &add, + const QStringList &remove, + const QString &description) +{ + // Optimistic: the rows change now, so a bulk archive of hundreds of threads + // feels instant. Recorded so onWorkerError() can put them back. + for (const QString &threadId : threadIds) + m_model->applyTagChange(threadId, add, remove); + + m_pendingThreadIds = threadIds; + m_pendingChange = TagChange{ {}, add, remove, description }; + + // The worker resolves thread ids to message ids: the UI does not hold + // message ids for rows it never opened. + QMetaObject::invokeMethod(m_worker, "applyTagsToThreads", + Qt::QueuedConnection, + Q_ARG(QStringList, threadIds), + Q_ARG(QStringList, add), + Q_ARG(QStringList, remove), + 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; +} |
