aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 20:43:37 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:54:08 +0200
commit3a3220471d2b10811e77fe942a6c58f53df316b0 (patch)
tree517722ddb6051a393c532c5b5a5e30ec4f5adcc3
parentf6f11e5142911f0c4a57b08d6425d1cf99187627 (diff)
downloadqtmaildir-3a3220471d2b10811e77fe942a6c58f53df316b0.tar.gz
qtmaildir-3a3220471d2b10811e77fe942a6c58f53df316b0.zip
feat(completion): complete both bounds of a date range
Each side of '..' is an independent value against the same model. Entries that are themselves ranges are withheld once a range exists, since date:1week....today is malformed. The token is read to its full extent rather than truncated at the caret: the separator deciding which bound is being edited can sit to the right of the caret. Stems stay caret-bounded so matching never uses untyped text.
-rw-r--r--src/querycompleter.cpp63
-rw-r--r--src/querycompleter.h7
-rw-r--r--tests/test_querycompleter.cpp42
3 files changed, 105 insertions, 7 deletions
diff --git a/src/querycompleter.cpp b/src/querycompleter.cpp
index 45ef581..6bdd494 100644
--- a/src/querycompleter.cpp
+++ b/src/querycompleter.cpp
@@ -46,6 +46,22 @@ int tokenStart(const QString &text, int cursor)
return start;
}
+/// End of the token the cursor sits in, using the same boundary characters as
+/// tokenStart plus ')'. The token must extend past the cursor: a range
+/// separator to the right of the cursor decides which bound is being edited,
+/// so truncating the token at the cursor would hide it.
+int tokenEnd(const QString &text, int cursor)
+{
+ int end = cursor;
+ while (end < text.size()) {
+ const QChar c = text.at(end);
+ if (c.isSpace() || c == QLatin1Char('(') || c == QLatin1Char(')'))
+ break;
+ ++end;
+ }
+ return end;
+}
+
} // namespace
CompletionContext completionContext(const QString &text, int cursor)
@@ -59,21 +75,54 @@ CompletionContext completionContext(const QString &text, int cursor)
return ctx; // kind stays None
const int start = tokenStart(text, cursor);
- const QString token = text.mid(start, cursor - start);
+ const int end = tokenEnd(text, cursor);
+ const QString token = text.mid(start, end - start);
+
+ // Everything the user has typed up to the caret. Candidates are matched
+ // against this, never against text still sitting to the right of it.
+ const QString typed = text.mid(start, cursor - start);
const int colon = token.indexOf(QLatin1Char(':'));
- if (colon < 0) {
+ if (colon < 0 || cursor <= start + colon) {
+ // No prefix yet, or the caret is still inside the keyword itself.
ctx.kind = CompletionContext::Prefix;
- ctx.stem = token;
+ ctx.stem = typed;
ctx.replaceFrom = start;
- ctx.replaceLength = token.size();
+ ctx.replaceLength = typed.size();
return ctx;
}
ctx.kind = CompletionContext::Value;
ctx.prefix = token.left(colon).toLower();
- ctx.stem = token.mid(colon + 1);
- ctx.replaceFrom = start + colon + 1;
- ctx.replaceLength = ctx.stem.size();
+
+ const QString value = token.mid(colon + 1);
+ const int valueStart = start + colon + 1;
+
+ // A range is two independent values. Complete whichever side the cursor
+ // is in, leaving the other untouched.
+ const int separator = value.indexOf(QStringLiteral(".."));
+ if (separator < 0) {
+ ctx.stem = text.mid(valueStart, cursor - valueStart);
+ ctx.replaceFrom = valueStart;
+ ctx.replaceLength = ctx.stem.size();
+ return ctx;
+ }
+
+ ctx.allowRangeEntries = false;
+
+ const int cursorInValue = cursor - valueStart;
+ if (cursorInValue <= separator) {
+ // stem uses the cursor offset while replaceLength covers the whole
+ // side: matching runs on what has been typed so far, but accepting
+ // replaces the entire bound, so completing mid-word leaves no tail.
+ ctx.stem = value.left(cursorInValue);
+ ctx.replaceFrom = valueStart;
+ ctx.replaceLength = separator;
+ } else {
+ const int upperStart = separator + 2;
+ ctx.stem = value.mid(upperStart, cursorInValue - upperStart);
+ ctx.replaceFrom = valueStart + upperStart;
+ ctx.replaceLength = value.size() - upperStart;
+ }
return ctx;
}
diff --git a/src/querycompleter.h b/src/querycompleter.h
index d9635c0..e2a983b 100644
--- a/src/querycompleter.h
+++ b/src/querycompleter.h
@@ -44,6 +44,13 @@ struct CompletionContext
/// being completed, so accepting never disturbs neighbouring text.
int replaceFrom = 0;
int replaceLength = 0;
+
+ /// Whether candidates that are themselves ranges may be offered.
+ ///
+ /// The relative date entries ("1week..") are complete open-ended ranges.
+ /// Offering one inside an existing range yields date:1week....today, which
+ /// is malformed, so they are withheld once a range is underway.
+ bool allowRangeEntries = true;
};
/// Decides what the cursor position implies about completion.
diff --git a/tests/test_querycompleter.cpp b/tests/test_querycompleter.cpp
index c86edac..1d06f4b 100644
--- a/tests/test_querycompleter.cpp
+++ b/tests/test_querycompleter.cpp
@@ -35,6 +35,10 @@ private slots:
void emptyValueAfterColonStillCompletes();
void insideQuotesCompletesNothing();
void afterClosedQuotesCompletesAgain();
+ void rangeUpperBoundCompletes();
+ void rangeLowerBoundCompletes();
+ void bareValueAllowsRelativeEntries();
+ void rangeSuppressesRelativeEntries();
};
void TestQueryCompleter::emptyTextCompletesPrefix()
@@ -121,5 +125,43 @@ void TestQueryCompleter::afterClosedQuotesCompletesAgain()
QCOMPARE(ctx.stem, QStringLiteral("ta"));
}
+void TestQueryCompleter::rangeUpperBoundCompletes()
+{
+ const QString text = QStringLiteral("date:today..yes");
+ const CompletionContext ctx = completionContext(text, text.size());
+ QCOMPARE(ctx.kind, CompletionContext::Value);
+ QCOMPARE(ctx.prefix, QStringLiteral("date"));
+ QCOMPARE(ctx.stem, QStringLiteral("yes"));
+ // Overwrites "yes" only: "date:today.." must survive.
+ QCOMPARE(ctx.replaceFrom, 12);
+ QCOMPARE(ctx.replaceLength, 3);
+}
+
+void TestQueryCompleter::rangeLowerBoundCompletes()
+{
+ // Cursor sits at offset 8, before the "..".
+ const QString text = QStringLiteral("date:las..today");
+ const CompletionContext ctx = completionContext(text, 8);
+ QCOMPARE(ctx.kind, CompletionContext::Value);
+ QCOMPARE(ctx.stem, QStringLiteral("las"));
+ QCOMPARE(ctx.replaceFrom, 5);
+ QCOMPARE(ctx.replaceLength, 3);
+}
+
+void TestQueryCompleter::bareValueAllowsRelativeEntries()
+{
+ const QString text = QStringLiteral("date:1w");
+ const CompletionContext ctx = completionContext(text, text.size());
+ QVERIFY(ctx.allowRangeEntries);
+}
+
+void TestQueryCompleter::rangeSuppressesRelativeEntries()
+{
+ // "1week.." offered here would produce date:1week....today.
+ const QString text = QStringLiteral("date:1w..today");
+ const CompletionContext ctx = completionContext(text, 7);
+ QVERIFY(!ctx.allowRangeEntries);
+}
+
QTEST_MAIN(TestQueryCompleter)
#include "test_querycompleter.moc"