1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
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;
}
|