From 00c029a819eca601e9ac58e5c236d667505ac566 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 11 Aug 2026 10:56:40 +0200 Subject: feat(config): let the date format on a card be configured Adds [general] date_format, a QDateTime pattern for the date a thread card shows. Absent or empty means the system locale's short format, which is what every other application on the desktop uses and stays the default. The format reaches the LAYOUT, not only the painter. CardLayout::compute() reserves the date's width from widestDateSample(), so a pattern that arrived only at the drawText call would be elided into a rect sized for the old format, which is the clipping the bold-font fault already produced once. It rides on CardLayout::Input and defaults to an empty string, leaving every existing call site unchanged. Confirmed by mutation: making the width ignore the format fails the test. widestDateSample() memoised its result in a static, which would have sized every format after the first from whichever arrived first. It is a plain call now, costing one QLocale lookup per row, the same as formatting the date. Validation rejects only a pattern whose output is CONSTANT, found by formatting two different instants and comparing. QDateTime::toString() treats nearly every letter as a field, so "banana" formats as "bpmnpmnpm" and "hello" as "22ello": nonsense, but they vary with the instant, and a check claiming to find "no date field" cannot reject them. What harms the user is the pattern that prints the same text on every card, and that is what is refused, with the value named in the message. The model supplies the pattern through DateFormatRole for the same reason it supplies the tag colours: it is the one object here holding config, and a delegate reading config itself would be a second source of truth. Backlog item 62. --- tests/test_cardlayout.cpp | 46 +++++++++++++++++++++++++++++++++++++++++++ tests/test_config.cpp | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) (limited to 'tests') diff --git a/tests/test_cardlayout.cpp b/tests/test_cardlayout.cpp index fdc18bf..bce6a0f 100644 --- a/tests/test_cardlayout.cpp +++ b/tests/test_cardlayout.cpp @@ -40,6 +40,7 @@ private slots: void replyCardCarriesNoAccentBar(); void theDateFitsWhenTheCardIsBold(); void theDateFollowsTheSystemLocale(); + void aConfiguredDateFormatIsUsedAndReservedFor(); }; namespace { @@ -343,6 +344,51 @@ void TestCardLayout::theDateFollowsTheSystemLocale() "formatting of a date"); } +void TestCardLayout::aConfiguredDateFormatIsUsedAndReservedFor() +{ + const QDateTime when(QDate(2025, 8, 10), QTime(6, 26)); + + // A pattern deliberately much longer than any locale's short format, so + // the width claim below cannot pass by accident on a locale whose own + // dates happen to be wide enough already. + const QString format = QStringLiteral("dddd d MMMM yyyy 'at' hh:mm:ss"); + + QCOMPARE(CardLayout::formatDate(when, format), + QLocale::system().toString(when, format)); + QVERIFY2(CardLayout::formatDate(when, format) + != CardLayout::formatDate(when), + "a configured format produced the same string as the system one, " + "so the parameter is being ignored"); + + // An empty format is what an absent or rejected config key gives, and it + // must mean the system format rather than an empty date. + QCOMPARE(CardLayout::formatDate(when, QString()), + CardLayout::formatDate(when)); + + // The load-bearing half: the reserved width has to follow the SAME format, + // or a long pattern is elided into a rect sized for a short one. This is + // the fault that a static, format-independent widest-date sample produces. + QFont font; + const int h = CardLayout::heightFor(font); + CardLayout::Input in = threadInput(); + in.dateFormat = format; + const CardLayout card = CardLayout::compute(in, QRect(0, 0, 900, h), font); + QFont bold = font; + bold.setBold(true); + QVERIFY2(card.dateRect.width() + >= QFontMetrics(bold).horizontalAdvance( + CardLayout::formatDate(when, format)), + "the reserved date width is narrower than the configured format's " + "own output"); + + // And it is genuinely wider than the default's, which proves the width + // moved with the format rather than a generous constant covering both. + const CardLayout plain = + CardLayout::compute(threadInput(), QRect(0, 0, 900, h), font); + QVERIFY2(card.dateRect.width() > plain.dateRect.width(), + "a longer date format reserved no more width than the default"); +} + void TestCardLayout::theDateFitsWhenTheCardIsBold() { // An UNREAD card draws BOLD, and bold is wider. The layout is computed from diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 7e0d6fb..b9321e0 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -52,6 +52,9 @@ private slots: void markReadDelayDefaultsToTwoSeconds(); void markReadDelayIsActuallyRead(); void markReadDelayAcceptsZeroAndNegative(); + void dateFormatDefaultsToEmpty(); + void dateFormatIsActuallyRead(); + void dateFormatWithoutAFieldIsRejectedAndReported(); void markReadDelayRejectsGarbage(); void syncOnExitDefaultsToAsk(); void syncOnExitReadsAllThreeValues(); @@ -570,6 +573,53 @@ void TestConfig::markReadDelayAcceptsZeroAndNegative() QVERIFY(never.problems().isEmpty()); } +void TestConfig::dateFormatDefaultsToEmpty() +{ + // Empty is what tells CardLayout to use the system's short format, which is + // the shipped behaviour and must survive this key existing. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral("[general]\n"))); + QVERIFY(config.dateFormat().isEmpty()); + QVERIFY(config.problems().isEmpty()); +} + +void TestConfig::dateFormatIsActuallyRead() +{ + // A pattern that is not the default, which is what proves the key is read + // at all: a "general/date_format" lookup matches nothing and would still + // pass a test that only checked the empty default. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral("[general]\n" + "date_format=yyyy-MM-dd\n"))); + QCOMPARE(config.dateFormat(), QStringLiteral("yyyy-MM-dd")); + QVERIFY(config.problems().isEmpty()); +} + +void TestConfig::dateFormatWithoutAFieldIsRejectedAndReported() +{ + // The specific trap: QDateTime::toString() with a pattern carrying no date + // or time field returns something fixed rather than failing, so this would + // print the same string on every card and look like a rendering fault + // rather than a config one. + // + // "xyz" and not a friendlier-looking word, because almost every letter is + // a field character: "banana" formats as "bpmnpmnpm" (a is AM/PM, n is the + // minute) and "hello" as "22ello" (h is the hour). Those are nonsense but + // they do vary with the instant, so they are not what this rejects and the + // check would fail against them. What it catches is a pattern whose output + // is CONSTANT, which is the case that silently shows one date forever. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral("[general]\n" + "date_format=xyz\n"))); + QVERIFY2(config.dateFormat().isEmpty(), + "a pattern with no date field was accepted"); + QCOMPARE(config.problems().size(), 1); + QVERIFY(config.problems().first().contains(QStringLiteral("xyz"))); +} + void TestConfig::markReadDelayRejectsGarbage() { // Absent is silent, but present-and-unparseable means the user asked for -- cgit v1.2.3