summaryrefslogtreecommitdiffstats
path: root/src/tagdialog.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-04 11:36:16 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:54:54 +0200
commit0a160651cfb9a0f580bcc446941058a339e55643 (patch)
tree911d60baf92426f5f32a662d5e9ebf6e72ae0cf2 /src/tagdialog.cpp
parent4574a6e8d1253c0a70972f0c6e4d13d528ea6420 (diff)
downloadqtmaildir-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/tagdialog.cpp')
-rw-r--r--src/tagdialog.cpp237
1 files changed, 237 insertions, 0 deletions
diff --git a/src/tagdialog.cpp b/src/tagdialog.cpp
new file mode 100644
index 0000000..94d2e05
--- /dev/null
+++ b/src/tagdialog.cpp
@@ -0,0 +1,237 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "tagdialog.h"
+
+#include <QCompleter>
+#include <QCoreApplication>
+#include <QDialogButtonBox>
+#include <QFormLayout>
+#include <QHash>
+#include <QLabel>
+#include <QLineEdit>
+#include <QListWidget>
+#include <QMessageBox>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+TagNameProblem validateTagName(const QString &tag)
+{
+ const QString trimmed = tag.trimmed();
+
+ if (trimmed.isEmpty())
+ return TagNameProblem::Empty;
+
+ // notmuch's CLI reads "-tag" as an instruction to remove that tag, so a tag
+ // actually named "-inbox" is a trap the user cannot easily undo later.
+ if (trimmed.startsWith(QLatin1Char('-')))
+ return TagNameProblem::LeadingDash;
+
+ for (const QChar c : trimmed) {
+ // Checked before the general control-character test, since space is not
+ // a control character but splits a tag just as effectively.
+ if (c.isSpace())
+ return TagNameProblem::ContainsSpace;
+ if (c.category() == QChar::Other_Control)
+ return TagNameProblem::ControlChar;
+ }
+
+ return TagNameProblem::Ok;
+}
+
+QString tagNameProblemText(TagNameProblem problem, const QString &tag)
+{
+ switch (problem) {
+ case TagNameProblem::Ok:
+ return {};
+ case TagNameProblem::Empty:
+ return QCoreApplication::translate("TagDialog",
+ "A tag name cannot be empty.");
+ case TagNameProblem::LeadingDash:
+ return QCoreApplication::translate(
+ "TagDialog",
+ "'%1' cannot start with '-': notmuch reads a leading dash as an "
+ "instruction to remove a tag.").arg(tag);
+ case TagNameProblem::ContainsSpace:
+ return QCoreApplication::translate(
+ "TagDialog", "'%1' cannot contain spaces.").arg(tag);
+ case TagNameProblem::ControlChar:
+ return QCoreApplication::translate(
+ "TagDialog", "'%1' contains a character that cannot be typed "
+ "back.").arg(tag);
+ }
+ return {};
+}
+
+namespace {
+
+/// Splits a line edit's contents into tag names, dropping empties.
+///
+/// Comma-separated, so several tags can be applied in one pass. A tag name
+/// cannot contain a comma once validateTagName() has run, which is what makes
+/// this unambiguous.
+QStringList splitTags(const QString &text)
+{
+ QStringList tags;
+ const QStringList parts = text.split(QLatin1Char(','), Qt::SkipEmptyParts);
+ for (const QString &part : parts) {
+ const QString trimmed = part.trimmed();
+ if (!trimmed.isEmpty())
+ tags.append(trimmed);
+ }
+ return tags;
+}
+
+} // namespace
+
+TagDialog::TagDialog(const QStringList &knownTags,
+ const QHash<QString, int> &currentTags,
+ int threadCount,
+ QWidget *parent)
+ : QDialog(parent), m_threadCount(threadCount)
+{
+ setWindowTitle(tr("Edit tags"));
+
+ auto *layout = new QVBoxLayout(this);
+
+ auto *heading = new QLabel(tr("%n selected thread(s)", "", threadCount),
+ this);
+ layout->addWidget(heading);
+
+ auto *form = new QFormLayout;
+
+ m_addEdit = new QLineEdit(this);
+ m_addEdit->setPlaceholderText(tr("tag, or several separated by commas"));
+ m_removeEdit = new QLineEdit(this);
+ m_removeEdit->setPlaceholderText(tr("tag, or several separated by commas"));
+
+ // Completion is a guard against typos, never a whitelist: a tag absent from
+ // this list is exactly what the dialog exists to create, so the completer
+ // suggests and does not constrain.
+ for (QLineEdit *edit : { m_addEdit, m_removeEdit }) {
+ auto *completer = new QCompleter(knownTags, edit);
+ completer->setCaseSensitivity(Qt::CaseInsensitive);
+ // Hierarchies are the reason this matters: typing "amazon" should find
+ // "shopping/amazon".
+ completer->setFilterMode(Qt::MatchContains);
+ edit->setCompleter(completer);
+ }
+
+ form->addRow(tr("Add:"), m_addEdit);
+ form->addRow(tr("Remove:"), m_removeEdit);
+ layout->addLayout(form);
+
+ // The tags already on the selection, so removing one does not require
+ // remembering its name. Tri-state, because with several threads selected a
+ // tag can be on some and not others.
+ m_currentList = new QListWidget(this);
+ QStringList sorted = currentTags.keys();
+ sorted.sort();
+ for (const QString &tag : sorted) {
+ const int count = currentTags.value(tag);
+
+ auto *item = new QListWidgetItem(tag, m_currentList);
+ if (count >= threadCount) {
+ item->setCheckState(Qt::Checked);
+ m_fullyTagged.append(tag);
+ } else {
+ item->setCheckState(Qt::PartiallyChecked);
+ // Say what "partial" means in numbers, rather than leaving the user
+ // to infer it from a shaded box.
+ item->setText(tr("%1 (on %2 of %3)")
+ .arg(tag).arg(count).arg(threadCount));
+ item->setData(Qt::UserRole, tag);
+ }
+ if (item->data(Qt::UserRole).isNull())
+ item->setData(Qt::UserRole, tag);
+ }
+
+ if (m_currentList->count() > 0) {
+ layout->addWidget(new QLabel(tr("Tags on the selection:"), this));
+ layout->addWidget(m_currentList);
+ } else {
+ m_currentList->hide();
+ }
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok
+ | QDialogButtonBox::Cancel,
+ this);
+ buttons->button(QDialogButtonBox::Ok)->setText(tr("Apply"));
+ connect(buttons, &QDialogButtonBox::accepted, this, &TagDialog::accept);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(buttons);
+
+ m_addEdit->setFocus();
+}
+
+void TagDialog::accept()
+{
+ QStringList add = splitTags(m_addEdit->text());
+ QStringList remove = splitTags(m_removeEdit->text());
+
+ // Validate before applying anything: a partial change is worse than none,
+ // since the user cannot tell which half landed.
+ for (const QStringList &list : { add, remove }) {
+ for (const QString &tag : list) {
+ const TagNameProblem problem = validateTagName(tag);
+ if (problem != TagNameProblem::Ok) {
+ QMessageBox::warning(this, tr("Invalid tag"),
+ tagNameProblemText(problem, tag));
+ return; // Stay open, with the text still there to fix.
+ }
+ }
+ }
+
+ // Then the checkbox list. An item whose state the user did not touch must
+ // change nothing, which is why PartiallyChecked is skipped entirely: it is
+ // the "leave this alone" state for a tag that is on some threads only.
+ for (int row = 0; row < m_currentList->count(); ++row) {
+ QListWidgetItem *item = m_currentList->item(row);
+ const QString tag = item->data(Qt::UserRole).toString();
+
+ switch (item->checkState()) {
+ case Qt::Unchecked:
+ // Was on at least one thread, since it is in this list at all.
+ if (!remove.contains(tag))
+ remove.append(tag);
+ break;
+ case Qt::Checked:
+ // Only meaningful when it was NOT already on every thread; a tag
+ // already everywhere and left checked is not a change.
+ if (!add.contains(tag) && !m_fullyTagged.contains(tag))
+ add.append(tag);
+ break;
+ case Qt::PartiallyChecked:
+ break; // Untouched: do nothing, deliberately.
+ }
+ }
+
+ m_add = add;
+ m_remove = remove;
+ QDialog::accept();
+}
+
+QStringList TagDialog::tagsToAdd() const
+{
+ return m_add;
+}
+
+QStringList TagDialog::tagsToRemove() const
+{
+ return m_remove;
+}