From 6ef6154a4a5a5dcb697617c4f0211d721a16356a Mon Sep 17 00:00:00 2001 From: Greg Bell Date: Wed, 9 Sep 2026 04:23:41 +0200 Subject: [PATCH 1/3] Preserve indentation of wrapped annotations Wrap each annotation separately and retain its indentation on continuation lines. Handle narrow columns and negative indentation, and add regression coverage. Fixes #3914 --- ChangeLog | 1 + doc/man/taskrc.5.in | 1 + src/columns/ColDescription.cpp | 29 +++++-- test/CMakeLists.txt | 1 + test/annotate.test.py | 91 +++++++++++++++++++++ test/col_description_test.cpp | 141 +++++++++++++++++++++++++++++++++ test/hyphenate.test.py | 17 +++- 7 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 test/col_description_test.cpp diff --git a/ChangeLog b/ChangeLog index 3617930c3..85c62fb32 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,7 @@ 3.5.0 - + - Wrapped annotation lines in task list reports now retain their indentation (#3914) - Hyphens are now allowed in tags, after the first character (#3961) - Sync can now use a Git remote as a backend (#4111) - Dependencies and the burndown report are now substantially faster (#4127, #4136) diff --git a/doc/man/taskrc.5.in b/doc/man/taskrc.5.in index afe84da06..e6db3abd6 100644 --- a/doc/man/taskrc.5.in +++ b/doc/man/taskrc.5.in @@ -370,6 +370,7 @@ Determines whether annotations are displayed below the description field by the .B indent.annotation=2 Controls the number of spaces to indent annotations when shown beneath the description field. The default value is "2". +Negative values are treated as zero in task list reports. .TP .B indent.report=0 diff --git a/src/columns/ColDescription.cpp b/src/columns/ColDescription.cpp index 460465cff..e14b940f2 100644 --- a/src/columns/ColDescription.cpp +++ b/src/columns/ColDescription.cpp @@ -36,6 +36,8 @@ #include #include +#include + //////////////////////////////////////////////////////////////////////////////// ColumnDescription::ColumnDescription() { _name = "description"; @@ -66,7 +68,7 @@ ColumnDescription::ColumnDescription() { _hyphenate = Context::getContext().config.getBoolean("hyphenate"); - _indent = Context::getContext().config.getInteger("indent.annotation"); + _indent = std::max(0, Context::getContext().config.getInteger("indent.annotation")); } //////////////////////////////////////////////////////////////////////////////// @@ -138,17 +140,30 @@ void ColumnDescription::render(std::vector& lines, Task& task, int // // ... if (_style == "default" || _style == "combined") { - if (task.annotation_count) { - for (const auto& i : task.getAnnotations()) { - Datetime dt(strtoll(i.first.substr(11).c_str(), nullptr, 10)); - description += '\n' + std::string(_indent, ' ') + dt.toString(_dateformat) + ' ' + i.second; - } - } + if (width <= 0) return; + + auto annotations = task.getAnnotations(); + // Keep separator newlines so trailing blank lines survive wrapping each part separately. + if (!annotations.empty()) description += '\n'; std::vector raw; wrapText(raw, description, width, _hyphenate); for (const auto& i : raw) renderStringLeft(lines, width, color, i); + + // Reports can shrink columns below their measured minimum. Leave room for text. + int indent = std::min(_indent, width - 1); + std::string prefix(indent, ' '); + auto remaining = annotations.size(); + for (const auto& i : annotations) { + Datetime dt(strtoll(i.first.substr(11).c_str(), nullptr, 10)); + std::string annotation = dt.toString(_dateformat) + ' ' + i.second; + if (--remaining) annotation += '\n'; + + raw.clear(); + wrapText(raw, annotation, width - indent, _hyphenate); + for (const auto& line : raw) renderStringLeft(lines, width, color, prefix + line); + } } // This is a description diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a38f727cb..6df2a9d97 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -13,6 +13,7 @@ include_directories (${CMAKE_SOURCE_DIR} # All C++ test files. Note that the portion before `.cpp` must be a valid, # unique C++ identifier. set(test_SRCS + col_description_test.cpp col_test.cpp dom_test.cpp eval_test.cpp diff --git a/test/annotate.test.py b/test/annotate.test.py index 210095826..8f0f02312 100755 --- a/test/annotate.test.py +++ b/test/annotate.test.py @@ -25,6 +25,8 @@ # ############################################################################### +import json +import re import sys import os import unittest @@ -164,6 +166,95 @@ def test_annotate_dateformat(self): ) +class TestAnnotationWrapping(TestCase): + def setUp(self): + self.t = Task() + self.t.env["TZ"] = "UTC" + for setting, value in { + "report.rrr.columns": "id,description", + "report.rrr.sort": "id+", + "dateformat.annotation": "Y-M-D", + "indent.annotation": "2", + "defaultwidth": "60", + "detection": "off", + "color": "off", + "verbose": "nothing", + }.items(): + self.t.config(setting, value) + + self.t( + "import -", + input=json.dumps( + [ + { + "uuid": "11111111-1111-4111-8111-111111111111", + "status": "pending", + "entry": "20250908T120000Z", + "description": "Task with annotations", + "annotations": [ + { + "entry": "20250908T120001Z", + "description": "A short annotation.", + }, + { + "entry": "20250908T120002Z", + "description": "This longer annotation should remain " + "indented when it wraps across several lines in a narrow " + "report. Each continuation belongs to the annotation above.", + }, + { + "entry": "20250908T120003Z", + "description": "Another short annotation.", + }, + ], + }, + { + "uuid": "22222222-2222-4222-8222-222222222222", + "status": "pending", + "entry": "20250908T120004Z", + "description": "Task without annotations", + }, + ] + ), + ) + + def test_wrapped_annotation_indentation(self): + """3914: Keep every wrapped annotation line indented in reports""" + self.assertWrappedAnnotationIndentation("description") + + def test_wrapped_annotation_combined(self): + """3914: Explicit combined style keeps wrapped annotations indented""" + self.assertWrappedAnnotationIndentation("description.combined") + + def test_wrapped_annotation_color_and_padding(self): + """3914: Color and report padding preserve annotation indentation""" + report = "rrr rc.defaultwidth:64 rc.indent.report:2 rc.row.padding:1" + code, plain, err = self.t(report) + code, colored, err = self.t( + report + " rc._forcecolor:on rc.color.alternate:blue" + ) + self.assertIn("\x1b[", colored) + visible = re.sub(r"\x1b\[[0-9;]*m", "", colored) + self.assertEqual( + [line.rstrip() for line in visible.splitlines()], plain.splitlines() + ) + self.assertIn("\n indented when it wraps", plain) + + def assertWrappedAnnotationIndentation(self, column): + expected = ( + " 1 Task with annotations\n" + " 2025-09-08 A short annotation.\n" + " 2025-09-08 This longer annotation should remain\n" + " indented when it wraps across several lines in a narrow\n" + " report. Each continuation belongs to the annotation\n" + " above.\n" + " 2025-09-08 Another short annotation.\n" + " 2 Task without annotations\n" + ) + code, out, err = self.t("rrr rc.report.rrr.columns:id," + column) + self.assertEqual(out, expected) + + class TestAnnotationPropagation(TestCase): def setUp(self): self.t = Task() diff --git a/test/col_description_test.cpp b/test/col_description_test.cpp new file mode 100644 index 000000000..a4b87f42a --- /dev/null +++ b/test/col_description_test.cpp @@ -0,0 +1,141 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Copyright 2006 - 2021, Tomas Babej, Paul Beckingham, Federico Hernandez. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// https://www.opensource.org/licenses/mit-license.php +// +//////////////////////////////////////////////////////////////////////////////// + +#include +// cmake.h include header must come first + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +extern std::string configurationDefaults; + +namespace { +struct AnnotationCase { + std::string name; + int width; + int indent; + std::string description; + std::vector annotations; + std::vector expected; + std::string dateformat = "D"; +}; +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +int TEST_NAME(int, char**) { + // #3914: Test column widths directly, independently of report width allocation. + const std::vector cases = { + {"default indent", 12, 2, "task", {"aa bb cc dd"}, {"task", " 08 aa bb", " cc dd"}}, + {"zero indent", 12, 0, "task", {"aa bb cc dd"}, {"task", "08 aa bb cc", "dd"}}, + {"custom indent", 12, 4, "task", {"aa bb cc dd"}, {"task", " 08 aa bb", " cc dd"}}, + {"exact fit", 16, 2, "task", {"aa bb cc dd"}, {"task", " 08 aa bb cc dd"}}, + {"one column short", 15, 2, "task", {"aa bb cc dd"}, {"task", " 08 aa bb cc", " dd"}}, + {"indent equals width", 2, 2, "t", {"a"}, {"t", " 0", " 8", " a"}}, + {"indent exceeds width", 2, 100, "t", {"a"}, {"t", " 0", " 8", " a"}}, + {"one-column width", 1, 2, "t", {"a"}, {"t", "0", "8", "a"}}, + {"nonpositive width", 0, 2, "t", {"a"}, {}}, + {"negative indent", 12, -2, "task", {"aa bb cc dd"}, {"task", "08 aa bb cc", "dd"}}, + {"description wraps", + 12, + 2, + "one two three four", + {"aa bb cc dd"}, + {"one two", "three four", " 08 aa bb", " cc dd"}}, + {"unannotated description wraps", 12, 2, "one two three four", {}, {"one two", "three four"}}, + {"explicit and boundary newlines", + 12, + 2, + "task\n", + {"aa\n\nbb\n", "cc\n"}, + {"task", "", " 08 aa", "", " bb", "", " 08 cc"}}, + {"empty description", 12, 2, "", {"aa"}, {"", " 08 aa"}}, + {"Unicode display widths", 12, 2, "task", {"åäö 界界 éé"}, {"task", " 08 åäö", " 界界 éé"}}, + {"wide glyph in one content column", 3, 2, "t", {"界"}, {"t", " 0", " 8", " ."}}, + {"annotation date format", + 18, + 2, + "task", + {"aa bb cc dd"}, + {"task", " 2025-09-08 aa bb", " cc dd"}, + "Y-M-D"}, + {"fallback date format", + 18, + 2, + "task", + {"aa bb cc dd"}, + {"task", " 2025-09-08 aa bb", " cc dd"}, + ""}, + }; + + UnitTest test(cases.size() * 2); + Context context; + Context::setContext(&context); + unsetenv("TASKDATA"); + unsetenv("TASKRC"); + setenv("TZ", "UTC", 1); + tzset(); + context.config.parse(configurationDefaults, 0, {TASK_RCDIR}); + context.config.set("dateformat", "Y-M-D"); + context.config.set("hyphenate", false); + + for (const auto& item : cases) { + context.config.set("indent.annotation", std::to_string(item.indent)); + context.config.set("dateformat.annotation", item.dateformat); + std::unique_ptr column(Column::factory("description", "rrr")); + Task task; + task.set("description", item.description); + std::map annotations; + int timestamp = 1757332800; + for (const auto& annotation : item.annotations) + annotations["annotation_" + std::to_string(timestamp++)] = annotation; + task.setAnnotations(annotations); + + Color color; + std::vector lines; + column->render(lines, task, item.width, color); + bool fits = true; + for (auto& line : lines) { + fits = fits && static_cast(utf8_width(line)) <= item.width; + line = rtrim(line); + } + test.ok(fits, item.name + ": fits column width"); + test.ok(lines == item.expected, item.name + ": rendered lines"); + if (lines != item.expected) { + test.diag("Expected:\n" + join("\n", item.expected)); + test.diag("Actual:\n" + join("\n", lines)); + } + } + + return 0; +} diff --git a/test/hyphenate.test.py b/test/hyphenate.test.py index c2c7466a8..72cb8ea40 100755 --- a/test/hyphenate.test.py +++ b/test/hyphenate.test.py @@ -74,13 +74,22 @@ def test_hyphenation(self): # List with rc.hyphenate=on. code, out, err = self.t("rc.defaultwidth:40 rc.hyphenate:on unittest") - self.assertIn("vwx-\n", out) - self.assertIn("tuv-\n", out) + # #3914: Continuations reserve the same annotation indent as the first line. + self.assertIn( + " abcdefghijklmnopqrstuv-\n" + " wxyzabcdefghijklmnopqr-\n" + " stuvwxyz\n", + out, + ) # List with rc.hyphenate=off. code, out, err = self.t("rc.defaultwidth:40 rc.hyphenate:off unittest") - self.assertIn("vwxy\n", out) - self.assertIn("uvwx\n", out) + self.assertIn( + " abcdefghijklmnopqrstuvw\n" + " xyzabcdefghijklmnopqrst\n" + " uvwxyz\n", + out, + ) if __name__ == "__main__": From 16c2f49b6d9827e13cfc8a09b91651d6b6e80d4a Mon Sep 17 00:00:00 2001 From: Greg Bell Date: Wed, 9 Sep 2026 08:59:07 +0200 Subject: [PATCH 2/3] Use supported second offsets in faketime tests libfaketime 0.9.13 rejects the s suffix in relative offsets. Seconds are already the default unit, so use +5 and +1 to keep these tests compatible with both older and newer versions. --- test/export.test.py | 2 +- test/hooks.on-modify.test.py | 2 +- test/import.test.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/export.test.py b/test/export.test.py index 777bcf227..bfc1a8e76 100755 --- a/test/export.test.py +++ b/test/export.test.py @@ -108,7 +108,7 @@ def test_export_start(self): def test_export_end(self): self.t("1 start") - self.t.faketime("+5s") + self.t.faketime("+5") # After a task is "done" or "deleted", it does not have an ID by which # to filter it anymore. Add a tag to work around this. self.t("1 done +workaround") diff --git a/test/hooks.on-modify.test.py b/test/hooks.on-modify.test.py index 9d9a615f2..b5fbf5386 100755 --- a/test/hooks.on-modify.test.py +++ b/test/hooks.on-modify.test.py @@ -165,7 +165,7 @@ def test_onmodify_revert_changes(self): code, out, err = self.t("add foo") before = self.t.export() - self.t.faketime("+5s") + self.t.faketime("+5") code, out, err = self.t("1 modify bar") after = self.t.export() diff --git a/test/import.test.py b/test/import.test.py index 1c709c23d..cf194e97c 100755 --- a/test/import.test.py +++ b/test/import.test.py @@ -224,7 +224,7 @@ def test_import_same_task_twice(self): ) self.t("import", input=_data) code, out1, err = self.t("export") - self.t.faketime("+1s") + self.t.faketime("+1") self.t("import", input=_data) code, out2, err = self.t("export") self.assertEqual(out1, out2) From ffb159978589238ba5af8b631e29997ccb00ca7b Mon Sep 17 00:00:00 2001 From: Greg Bell Date: Thu, 10 Sep 2026 09:30:22 +0200 Subject: [PATCH 3/3] Remove changelog entry Leave the release changelog to be generated from Git history. --- ChangeLog | 1 - 1 file changed, 1 deletion(-) diff --git a/ChangeLog b/ChangeLog index 85c62fb32..3617930c3 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,7 +2,6 @@ 3.5.0 - - - Wrapped annotation lines in task list reports now retain their indentation (#3914) - Hyphens are now allowed in tags, after the first character (#3961) - Sync can now use a Git remote as a backend (#4111) - Dependencies and the burndown report are now substantially faster (#4127, #4136)