Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/man/taskrc.5.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 22 additions & 7 deletions src/columns/ColDescription.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
#include <utf8.h>
#include <util.h>

#include <algorithm>

////////////////////////////////////////////////////////////////////////////////
ColumnDescription::ColumnDescription() {
_name = "description";
Expand Down Expand Up @@ -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"));
}

////////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -138,17 +140,30 @@ void ColumnDescription::render(std::vector<std::string>& lines, Task& task, int
// <date> <anno>
// ...
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<std::string> 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
Expand Down
1 change: 1 addition & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions test/annotate.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
#
###############################################################################

import json
import re
import sys
import os
import unittest
Expand Down Expand Up @@ -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()
Expand Down
141 changes: 141 additions & 0 deletions test/col_description_test.cpp
Original file line number Diff line number Diff line change
@@ -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>
// cmake.h include header must come first

#include <Column.h>
#include <Context.h>
#include <Task.h>
#include <shared.h>
#include <test.h>
#include <utf8.h>

#include <cstdlib>
#include <ctime>
#include <memory>

extern std::string configurationDefaults;

namespace {
struct AnnotationCase {
std::string name;
int width;
int indent;
std::string description;
std::vector<std::string> annotations;
std::vector<std::string> expected;
std::string dateformat = "D";
};
} // namespace

////////////////////////////////////////////////////////////////////////////////
int TEST_NAME(int, char**) {
// #3914: Test column widths directly, independently of report width allocation.
const std::vector<AnnotationCase> 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(Column::factory("description", "rrr"));
Task task;
task.set("description", item.description);
std::map<std::string, std::string> annotations;
int timestamp = 1757332800;
for (const auto& annotation : item.annotations)
annotations["annotation_" + std::to_string(timestamp++)] = annotation;
task.setAnnotations(annotations);

Color color;
std::vector<std::string> lines;
column->render(lines, task, item.width, color);
bool fits = true;
for (auto& line : lines) {
fits = fits && static_cast<int>(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;
}
2 changes: 1 addition & 1 deletion test/export.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the motivation for this change?

@gregbell gregbell Sep 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When CI ran, i get these errors from faketime on arch:

https://github.com/GothenburgBitFactory/taskwarrior/actions/runs/34319293173/job/102363309252#step:4:190
https://github.com/GothenburgBitFactory/taskwarrior/actions/runs/34319293173/job/102363309252#step:4:315

I'm not SUPER familiar with libfaketime. I saw this in the docs https://github.com/wolfcw/libfaketime/blob/v0.9.13/README#L348 and it doesn't say anything about an "s" modifier. So.. not entirely sure if it was removed or what, but this did get it passing with the newer build.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, I wonder if we're using a "floating" version of that library. Anyway, thanks for the details!

# 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")
Expand Down
2 changes: 1 addition & 1 deletion test/hooks.on-modify.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
17 changes: 13 additions & 4 deletions test/hyphenate.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
2 changes: 1 addition & 1 deletion test/import.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading