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.h | |
| 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.h')
| -rw-r--r-- | src/tagdialog.h | 102 |
1 files changed, 102 insertions, 0 deletions
diff --git a/src/tagdialog.h b/src/tagdialog.h new file mode 100644 index 0000000..40eb089 --- /dev/null +++ b/src/tagdialog.h @@ -0,0 +1,102 @@ +/* + * 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. + */ + +#pragma once + +#include <QDialog> +#include <QStringList> + +class QLineEdit; +class QListWidget; + +/// Why a tag name was refused, or Ok when it was not. +/// +/// A reason rather than a bool: a rejected tag has to say what was wrong with +/// it, since silently dropping one leaves the user believing they tagged +/// something they did not. +enum class TagNameProblem { + Ok, + Empty, ///< Nothing, or only whitespace. + LeadingDash, ///< notmuch reads a leading '-' as "remove this tag". + ContainsSpace, ///< Splits into two tags, or fails the write outright. + ControlChar, ///< Not typeable, and unreadable once stored. +}; + +/// Whether `tag` is safe to hand to notmuch. +/// +/// A free function so the rules can be tested without a widget. notmuch itself +/// accepts a great deal, so this is deliberately narrow: it rejects only what +/// produces a failed write or a tag the user cannot see they created. +TagNameProblem validateTagName(const QString &tag); + +/// The message for a rejected tag, ready to show. Empty for Ok. +QString tagNameProblemText(TagNameProblem problem, const QString &tag); + +/// Adds and removes tags across the selected threads. +/// +/// One dialog rather than separate add and remove actions: the natural +/// operation is "make these threads look like this", and filing something under +/// a new tag while removing inbox is one thought, not two. +/// +/// Pure UI. It contacts no worker and holds no database handle; it is handed +/// the vocabulary and the current state, and returns two lists. That is what +/// lets it be unit-tested without a notmuch database. +class TagDialog : public QDialog +{ + Q_OBJECT +public: + /// `knownTags` is the completion vocabulary, usually every tag in the + /// database. `currentTags` maps a tag to how many of the selected threads + /// carry it, which is what drives the tri-state checkboxes. + TagDialog(const QStringList &knownTags, + const QHash<QString, int> ¤tTags, + int threadCount, + QWidget *parent = nullptr); + + /// Tags to add. Empty when the user asked for nothing. + QStringList tagsToAdd() const; + + /// Tags to remove. + QStringList tagsToRemove() const; + + /// Reads both line edits and the checkbox list into the two lists, + /// reporting the first invalid name rather than applying a partial change. + /// + /// Public because QDialog::accept() is: a test drives it directly rather + /// than clicking a button, since which button carries the AcceptRole is + /// not what this class is for. + void accept() override; + +private: + + QStringList m_add; + QStringList m_remove; + + /// Tags that were already on EVERY selected thread when the dialog opened. + /// + /// Needed to tell "the user checked this box" apart from "this box was + /// checked all along": the first is an instruction, the second is not a + /// change and must not be sent as one. + QStringList m_fullyTagged; + + int m_threadCount = 0; + + QLineEdit *m_addEdit = nullptr; + QLineEdit *m_removeEdit = nullptr; + QListWidget *m_currentList = nullptr; +}; |
