summaryrefslogtreecommitdiffstats
path: root/src/tagdialog.cpp
blob: 75e6b03b9e83afd2d3d48958108b4c88f4cd8b92 (plain)
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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
/*
 * 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 <QAbstractItemView>
#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;
}

/// The tag the cursor sits in, which is what completion should match against.
///
/// The field holds a comma-separated list, so the last comma before the cursor
/// bounds the token. Leading space is dropped so "unread, fl" completes on "fl"
/// rather than on " fl", which would match nothing.
QString currentToken(const QLineEdit *edit)
{
    const QString text = edit->text();
    const int cursor = qBound(0, edit->cursorPosition(), int(text.size()));

    const int start = text.lastIndexOf(QLatin1Char(','), qMax(0, cursor - 1)) + 1;
    return text.mid(start, cursor - start).trimmed();
}

/// Overwrites the token under the cursor with `value`, leaving the rest of the
/// list alone, and puts the caret after what was inserted.
void replaceCurrentToken(QLineEdit *edit, const QString &value)
{
    const QString text = edit->text();
    const int cursor = qBound(0, edit->cursorPosition(), int(text.size()));

    const int start = text.lastIndexOf(QLatin1Char(','), qMax(0, cursor - 1)) + 1;

    // Keep the separator's spacing as the user typed it: replacing from `start`
    // would eat the space after the comma and give "unread,flagged".
    int tokenStart = start;
    while (tokenStart < cursor && text.at(tokenStart).isSpace())
        ++tokenStart;

    QString updated = text;
    updated.replace(tokenStart, cursor - tokenStart, value);

    edit->setText(updated);
    edit->setCursorPosition(tokenStart + value.size());
}

} // 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);

        // setWidget, NOT QLineEdit::setCompleter. These fields hold a
        // comma-separated LIST, and setCompleter makes the line edit drive
        // completion, overwriting the prefix with the widget's ENTIRE text on
        // every keystroke. Once the field reads "unread, fl" that whole string
        // is matched against the tag names, nothing matches, and completion
        // silently stops working after the first tag. Setting the prefix from a
        // textEdited handler does not help: the line edit sets it again
        // afterwards.
        //
        // setWidget keeps the popup anchored without ceding control of the
        // prefix, which then becomes ours to drive per token. Exactly the fix
        // QueryCompleter needed in 01ba356; the trap belongs to
        // QLineEdit::setCompleter, not to either class.
        completer->setWidget(edit);

        connect(edit, &QLineEdit::textEdited, this, [edit, completer]() {
            const QString token = currentToken(edit);
            completer->setCompletionPrefix(token);
            if (token.isEmpty() || completer->completionCount() == 0) {
                completer->popup()->hide();
                return;
            }
            completer->complete();
        });

        // Accepting a candidate has to replace the token under the cursor
        // rather than the whole field, or taking "flagged" would discard every
        // tag already typed.
        connect(completer, QOverload<const QString &>::of(&QCompleter::activated),
                this, [edit](const QString &value) {
            replaceCurrentToken(edit, value);
        });
    }

    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;
}