summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 21:11:30 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:54:23 +0200
commitfc3073f452e28733c3910ceda403531b3e17404d (patch)
tree9abc7cebce905b2b6e5c7639f7c7fb0bf2659a11
parent1e587846a255592e5aab022c4e6e134d4ff64ed2 (diff)
downloadqtmaildir-fc3073f452e28733c3910ceda403531b3e17404d.tar.gz
qtmaildir-fc3073f452e28733c3910ceda403531b3e17404d.zip
feat(completion): wire query completion into the main window
Adds the complete_query action, whose registration the assertion in registerActions() has been demanding since keymap gained the name: a known action nothing implements would have been a silently dead binding. Also lands the QueryCompleter half of the manual trigger, which the keymap commit left behind: triggerCompletion() and the focus-in filter gated on completion_on_focus. Tags refresh at startup, after a sync, and after a mutation introduces a tag not already known, since that is the tag most likely to be typed again. onAllTagsReady deliberately drops the generation the signal carries: a tag list is not an ordered query result, so a later one is always at least as good as an earlier one and discarding on staleness could only throw away a good list.
-rw-r--r--src/mainwindow.cpp50
-rw-r--r--src/mainwindow.h12
-rw-r--r--src/querycompleter.cpp30
-rw-r--r--src/querycompleter.h8
4 files changed, 99 insertions, 1 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 517941a..8ca6aa9 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -47,6 +47,7 @@
#include "messageview.h"
#include "mimeparser.h"
#include "notmuchworker.h"
+#include "querycompleter.h"
#include "tagchip.h"
#include "threadlistmodel.h"
#include "version.h"
@@ -221,6 +222,7 @@ void MainWindow::buildUi()
m_queryEdit->setPlaceholderText(tr("notmuch query, e.g. tag:inbox"));
connect(m_queryEdit, &QLineEdit::returnPressed,
this, &MainWindow::runCurrentQuery);
+ m_queryCompleter = new QueryCompleter(m_queryEdit, m_config, this);
m_syncLog = new QPlainTextEdit(central);
m_syncLog->setReadOnly(true);
@@ -449,6 +451,13 @@ void MainWindow::registerActions()
if (m_sync->isAvailable())
m_sync->start();
});
+ addAction(QStringLiteral("complete_query"), tr("&Complete query"),
+ tr("Offer completions for the query bar"), [this]() {
+ // Focus first: the popup anchors on the line edit, and the binding is
+ // reachable from the thread list where the bar has no focus at all.
+ m_queryEdit->setFocus();
+ m_queryCompleter->triggerCompletion();
+ });
addAction(QStringLiteral("quit"), tr("&Quit"),
tr("Quit qtmaildir"), [this]() { close(); });
@@ -469,6 +478,7 @@ void MainWindow::buildMenus()
editMenu->addAction(m_actions.value(QStringLiteral("undo")));
editMenu->addSeparator();
editMenu->addAction(m_actions.value(QStringLiteral("focus_query")));
+ editMenu->addAction(m_actions.value(QStringLiteral("complete_query")));
auto *messageMenu = menuBar()->addMenu(tr("&Message"));
messageMenu->addAction(m_actions.value(QStringLiteral("archive")));
@@ -656,15 +666,51 @@ void MainWindow::wireWorker()
this, &MainWindow::onThreadLoaded);
connect(m_worker, &NotmuchWorker::errorOccurred,
this, &MainWindow::onWorkerError);
+ connect(m_worker, &NotmuchWorker::allTagsReady,
+ this, &MainWindow::onAllTagsReady);
// 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 &) {
+ connect(m_worker, &NotmuchWorker::tagsApplied,
+ this, [this](const TagChange &change) {
m_pendingChange = {};
m_pendingThreadIds.clear();
+
+ // A tag the user has just created is the one they are most likely to
+ // type again, so do not wait for the next sync to offer it. A set
+ // membership test, not a query.
+ for (const QString &tag : change.added) {
+ if (!m_knownTags.contains(tag)) {
+ requestAllTags();
+ break;
+ }
+ }
});
m_workerThread.start();
+
+ // Queued behind the thread start, so the completer has real tags as soon
+ // as the database can be read. Nothing waits on the answer: requestAllTags
+ // stays silent when the database cannot be opened.
+ requestAllTags();
+}
+
+void MainWindow::requestAllTags()
+{
+ // The generation is unused by the tag path, see onAllTagsReady().
+ QMetaObject::invokeMethod(m_worker, "requestAllTags", Qt::QueuedConnection,
+ Q_ARG(quint64, 0));
+}
+
+void MainWindow::onAllTagsReady(const QStringList &tags)
+{
+ // The signal carries a generation, this slot deliberately does not take
+ // it. A tag list is not an ordered query result: a later one is always at
+ // least as good as an earlier one, and there is no partial state a stale
+ // arrival could corrupt. Discarding on generation would only be able to
+ // throw away a good list.
+ m_knownTags = tags;
+ m_queryCompleter->setTags(tags);
}
void MainWindow::showWarnings()
@@ -827,6 +873,8 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
if (success) {
m_statusLabel->setText(tr("Sync complete"));
runCurrentQuery();
+ // A sync is the usual way new tags enter the database.
+ requestAllTags();
} else {
m_statusLabel->setText(tr("Sync failed (exit %1)").arg(exitCode));
m_syncLog->show();
diff --git a/src/mainwindow.h b/src/mainwindow.h
index f4186ff..3623b04 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -44,6 +44,7 @@ class ThreadListModel;
class MessageView;
class MailSync;
class NotmuchWorker;
+class QueryCompleter;
class MainWindow : public QMainWindow
{
@@ -81,6 +82,7 @@ private slots:
void onThreadLoaded(const QVector<MessageRef> &messages, quint64 generation);
void onWorkerError(const QString &message);
void onSyncFinished(bool success, int exitCode);
+ void onAllTagsReady(const QStringList &tags);
private:
void buildUi();
@@ -93,6 +95,10 @@ private:
void registerActions();
void buildMenus();
void wireWorker();
+
+ /// Asks the worker to re-enumerate the database tags for the completer.
+ void requestAllTags();
+
void showWarnings();
void showShortcutReference();
void showAbout();
@@ -131,6 +137,7 @@ private:
QUndoStack m_undoStack;
QLineEdit *m_queryEdit = nullptr;
+ QueryCompleter *m_queryCompleter = nullptr;
QTableView *m_threadView = nullptr;
QSplitter *m_splitter = nullptr;
QComboBox *m_accountBox = nullptr;
@@ -147,6 +154,11 @@ private:
/// parallel with them.
QHash<QString, QString> m_actionDescriptions;
+ /// The tag list last received from the worker. Held here and not only in
+ /// the completer so a mutation can ask whether it introduced a tag the
+ /// completer does not yet offer, without a round trip.
+ QStringList m_knownTags;
+
quint64 m_generation = 0;
QString m_lastQuery;
QString m_currentThreadId;
diff --git a/src/querycompleter.cpp b/src/querycompleter.cpp
index 8457776..1a6f6de 100644
--- a/src/querycompleter.cpp
+++ b/src/querycompleter.cpp
@@ -360,6 +360,36 @@ QueryCompleter::QueryCompleter(QLineEdit *edit, const Config &config,
this, [this](const QModelIndex &index) {
acceptCompletion(index.data(Qt::DisplayRole).toString());
});
+
+ if (m_config.completionOnFocus())
+ m_edit->installEventFilter(this);
+}
+
+void QueryCompleter::triggerCompletion()
+{
+ if (!m_edit || !m_completer)
+ return;
+
+ updateContext();
+ if (m_context.kind == CompletionContext::None)
+ return;
+
+ // The prefix must be set explicitly: complete() filters against whatever
+ // prefix QCompleter last derived from the line edit's full text, which is
+ // not the stem once a keyword or a range bound is in play.
+ m_completer->setCompletionPrefix(m_context.stem);
+ m_completer->complete();
+}
+
+bool QueryCompleter::eventFilter(QObject *watched, QEvent *event)
+{
+ // Only the empty-bar case: once there is text, ordinary typing has
+ // already driven completion.
+ if (watched == m_edit && event->type() == QEvent::FocusIn
+ && m_edit->text().isEmpty()) {
+ triggerCompletion();
+ }
+ return QObject::eventFilter(watched, event);
}
void QueryCompleter::acceptCompletion(const QString &value)
diff --git a/src/querycompleter.h b/src/querycompleter.h
index 374fe84..61b2555 100644
--- a/src/querycompleter.h
+++ b/src/querycompleter.h
@@ -110,6 +110,14 @@ public:
/// synthetic key events instead would test the keyboard layout, not this.
void acceptCompletion(const QString &value);
+public slots:
+ /// Opens the popup regardless of what has been typed. Bound to
+ /// complete_query, and the only trigger when completion_on_focus is off.
+ void triggerCompletion();
+
+protected:
+ bool eventFilter(QObject *watched, QEvent *event) override;
+
private:
QList<CompletionEntry> entriesFor(const CompletionContext &context) const;
void rebuildModel(const CompletionContext &context);