summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md2
-rw-r--r--src/tagdialog.cpp19
-rw-r--r--tests/test_tagdialog.cpp62
3 files changed, 80 insertions, 3 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index f10a301..0091d27 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -95,7 +95,7 @@ taking that too literally.
| 45 | Two Sync buttons, and only one of them works properly | correctness | S | **done** |
| 46 | `uiStateSurvivesARestart` fails under the offscreen platform | testing | XS | **done** |
| 47 | The query bar looks unfinished, and cannot be cleared by mouse | presentation | XS | **done** |
-| 48 | Removing a tag suggests every tag, not the thread's own | workflow | XS | open |
+| 48 | Removing a tag suggests every tag, not the thread's own | workflow | XS | **done** |
Sizes are rough: XS under an hour, S a sitting, M a session.
diff --git a/src/tagdialog.cpp b/src/tagdialog.cpp
index 75e6b03..1fb3f18 100644
--- a/src/tagdialog.cpp
+++ b/src/tagdialog.cpp
@@ -160,8 +160,23 @@ TagDialog::TagDialog(const QStringList &knownTags,
// 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);
+ //
+ // The two fields complete against different vocabularies. Add reaches the
+ // whole database, since naming a tag that does not exist yet is what it is
+ // for. Remove offers only what the selection actually carries: on a
+ // multi-thread selection that is the union with counts, not the
+ // intersection, because removing a tag two of three threads have is a
+ // meaningful thing to ask for.
+ QStringList removeCandidates = currentTags.keys();
+ removeCandidates.sort();
+
+ const QList<QPair<QLineEdit *, QStringList>> fields = {
+ { m_addEdit, knownTags },
+ { m_removeEdit, removeCandidates },
+ };
+
+ for (const auto &[edit, candidates] : fields) {
+ auto *completer = new QCompleter(candidates, edit);
completer->setCaseSensitivity(Qt::CaseInsensitive);
// Hierarchies are the reason this matters: typing "amazon" should find
// "shopping/amazon".
diff --git a/tests/test_tagdialog.cpp b/tests/test_tagdialog.cpp
index df0fece..5289599 100644
--- a/tests/test_tagdialog.cpp
+++ b/tests/test_tagdialog.cpp
@@ -45,6 +45,8 @@ private slots:
void nothingTouchedYieldsNoChange();
void completionFollowsTheTagAfterAComma();
void acceptingACandidateKeepsTheOtherTags();
+ void removeCompletesOnlyTheSelectionsOwnTags();
+ void removeStillAcceptsATagItDoesNotSuggest();
};
void TestTagDialog::validNamesAreAccepted()
@@ -319,5 +321,65 @@ void TestTagDialog::acceptingACandidateKeepsTheOtherTags()
QVERIFY(addEdit->text().contains(QStringLiteral(", ")));
}
+void TestTagDialog::removeCompletesOnlyTheSelectionsOwnTags()
+{
+ // Reported by the user: removing a tag suggested every tag in the database.
+ // Only the tags the selection already carries can be removed, and those are
+ // already in the dialog as currentTags.
+ //
+ // Typed rather than setText(), which does not drive a completer at all.
+ TagDialog dialog({ QStringLiteral("inbox"), QStringLiteral("unread"),
+ QStringLiteral("flagged"), QStringLiteral("archive") },
+ { { QStringLiteral("inbox"), 1 } }, 1);
+ dialog.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&dialog));
+
+ const QList<QLineEdit *> edits = dialog.findChildren<QLineEdit *>();
+ QVERIFY(edits.size() >= 2);
+ QLineEdit *removeEdit = edits.at(1);
+ removeEdit->setFocus();
+ QTRY_COMPARE(QApplication::focusWidget(), removeEdit);
+
+ QCompleter *completer = removeEdit->findChild<QCompleter *>();
+ QVERIFY(completer);
+
+ // "fl" matches "flagged", which the database has and the selection does not.
+ QTest::keyClicks(removeEdit, QStringLiteral("fl"));
+ QCOMPARE(completer->completionPrefix(), QStringLiteral("fl"));
+ QCOMPARE(completer->completionCount(), 0);
+
+ // A tag the selection does carry still completes.
+ removeEdit->clear();
+ QTest::keyClicks(removeEdit, QStringLiteral("inb"));
+ QCOMPARE(completer->completionPrefix(), QStringLiteral("inb"));
+ QCOMPARE(completer->completionCount(), 1);
+ QCOMPARE(completer->currentCompletion(), QStringLiteral("inbox"));
+
+ // Add is unchanged: it must still reach the whole vocabulary, since
+ // creating a tag is what that field is for.
+ QLineEdit *addEdit = edits.at(0);
+ addEdit->setFocus();
+ QTRY_COMPARE(QApplication::focusWidget(), addEdit);
+ QCompleter *addCompleter = addEdit->findChild<QCompleter *>();
+ QVERIFY(addCompleter);
+ QTest::keyClicks(addEdit, QStringLiteral("fl"));
+ QVERIFY(addCompleter->completionCount() > 0);
+}
+
+void TestTagDialog::removeStillAcceptsATagItDoesNotSuggest()
+{
+ // Completion is a suggestion, never a whitelist. Narrowing the candidates
+ // must not start validating input against them.
+ TagDialog dialog({ QStringLiteral("inbox") },
+ { { QStringLiteral("inbox"), 1 } }, 1);
+
+ const QList<QLineEdit *> edits = dialog.findChildren<QLineEdit *>();
+ QVERIFY(edits.size() >= 2);
+ edits.at(1)->setText(QStringLiteral("flagged"));
+ dialog.accept();
+
+ QVERIFY(dialog.tagsToRemove().contains(QStringLiteral("flagged")));
+}
+
QTEST_MAIN(TestTagDialog)
#include "test_tagdialog.moc"