diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-04 11:36:16 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-04 12:54:54 +0200 |
| commit | 0a160651cfb9a0f580bcc446941058a339e55643 (patch) | |
| tree | 911d60baf92426f5f32a662d5e9ebf6e72ae0cf2 /src/mainwindow.cpp | |
| parent | 4574a6e8d1253c0a70972f0c6e4d13d528ea6420 (diff) | |
| download | qtmaildir-0a160651cfb9a0f580bcc446941058a339e55643.tar.gz qtmaildir-0a160651cfb9a0f580bcc446941058a339e55643.zip | |
feat(tags): add an Edit tags dialog on Ctrl+T
Five hardcoded tags were the only ones reachable from the UI: archive,
delete, spam, flag and toggle_unread. For an application whose purpose is
organising mail by tag, applying any other one meant leaving for a
terminal. Item 26 of the usability backlog, raised by the user asking how
to add a tag and finding they could not.
One dialog rather than separate add and remove actions, at the user's
choice: filing something under a new tag while dropping inbox is one
thought, not two. Type tags to add or remove, comma separated, or clear a
checkbox to drop a tag already on the selection without retyping its
name.
Both fields complete against the tag list MainWindow already holds for
the query completer. Completion is a guard against typing shoppping
beside shopping, never a whitelist: inventing a tag is the entire point,
so any valid name goes through whether or not it exists yet.
Tri-state checkboxes carry the multi-thread case, and are where the risk
is. A tag on some selected threads shows partially checked, and leaving
it alone changes nothing; the opposite reading would silently tag threads
the user never looked at. A tag already on every thread and left checked
is likewise not a change and is not sent as one.
Tag names are validated before anything is applied, through a free
function so the rules are testable on their own. Empty, a leading dash
(notmuch's CLI reads it as removal, making such a tag a trap), whitespace
and control characters are refused by name and reason. Nothing is applied
until the whole set passes, since the user cannot tell which half of a
partial change landed.
TagDialog is pure UI: handed the vocabulary and the current state,
returning two lists, contacting no worker. That is what lets its fifteen
tests run without a notmuch database. Integration is a single call to the
existing tagSelected(), so undo, the optimistic model update, the
combined multi-row query and the completer refresh for a brand-new tag
all come for free.
One test assumption was wrong and the code was right: a case asserted
that QStringLiteral("null\0byte") truncates at the null and reads as
empty. It does not, so the null is caught as a control character. The
test was corrected rather than the validator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/mainwindow.cpp')
| -rw-r--r-- | src/mainwindow.cpp | 41 |
1 files changed, 41 insertions, 0 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index d8fba3e..000be85 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -52,6 +52,7 @@ #include "notmuchworker.h" #include "querycompleter.h" #include "tagchip.h" +#include "tagdialog.h" #include "threadlistmodel.h" #include "version.h" @@ -548,6 +549,10 @@ void MainWindow::registerActions() else tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread")); }); + addAction(QStringLiteral("edit_tags"), tr("Edit &tags..."), + tr("Add or remove any tag on the selected threads"), [this]() { + editTagsOnSelection(); + }); addAction(QStringLiteral("toggle_html"), tr("Toggle &HTML"), tr("Switch the thread between HTML and plain text"), [this]() { m_messageView->toggleHtml(); @@ -634,6 +639,7 @@ void MainWindow::buildMenus() messageMenu->addAction(m_actions.value(QStringLiteral("spam"))); messageMenu->addSeparator(); messageMenu->addAction(m_actions.value(QStringLiteral("toggle_unread"))); + messageMenu->addAction(m_actions.value(QStringLiteral("edit_tags"))); messageMenu->addAction(m_actions.value(QStringLiteral("flag"))); auto *viewMenu = menuBar()->addMenu(tr("&View")); @@ -1152,6 +1158,41 @@ void MainWindow::markCurrentThreadRead() tr("Mark read")); } +void MainWindow::editTagsOnSelection() +{ + const QModelIndexList rows = + m_threadView->selectionModel()->selectedRows(); + if (rows.isEmpty()) { + m_statusLabel->setText(tr("Select a thread first")); + return; + } + + // How many of the selected threads carry each tag, which is what tells a + // tag that is on all of them from one that is on some. + QHash<QString, int> counts; + for (const QModelIndex &index : rows) { + const ThreadSummary thread = m_model->threadAt(index.row()); + for (const QString &tag : thread.tags) + counts[tag] += 1; + } + + // m_knownTags is the same list the query completer uses, so the dialog + // offers every tag in the database without a round trip. + TagDialog dialog(m_knownTags, counts, rows.size(), this); + if (dialog.exec() != QDialog::Accepted) + return; + + const QStringList add = dialog.tagsToAdd(); + const QStringList remove = dialog.tagsToRemove(); + if (add.isEmpty() && remove.isEmpty()) + return; // Applied with nothing changed. + + // Straight through tagSelected(), so this inherits undo, the optimistic + // model update, the one-query multi-row resolution, and the completer + // refresh for a tag that did not exist before. + tagSelected(add, remove, tr("Edit tags")); +} + void MainWindow::tagSelected(const QStringList &add, const QStringList &remove, const QString &description) { |
