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/tagdialog.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/tagdialog.cpp')
| -rw-r--r-- | src/tagdialog.cpp | 237 |
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> ¤tTags, + 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; +} |
