Skip to content

Improve alignment in todo list output #576

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ v4.6.0
* The default value for ``path`` is now ``~/calendars/*``. Previously this
value was required. This change is non-breaking; all existing valid
configurations define this value.
* Add a new `columns` option and configuration entry to enable column-aligned
output.

v4.5.0
------
Expand Down
1 change: 1 addition & 0 deletions config.py.sample
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# A glob expression which matches all directories relevant.
path = "~/my_calendars/*"
columns = "auto" # never (default) / always / auto
date_format = "%Y-%m-%d"
time_format = "%H:%M"
default_list = "Personal"
Expand Down
10 changes: 5 additions & 5 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ intentions is to also provide a fully `TUI`_-based interface).
The default action is ``list``, which outputs all tasks for all calendars, each
with a semi-permanent unique id::

1 [ ] !!! 2015-04-30 Close bank account @work (0%)
2 [ ] ! Send minipimer back for warranty replacement @home (0%)
3 [X] 2015-03-29 Buy soy milk @home (100%)
4 [ ] !! Fix the iPad's screen @home (0%)
5 [ ] !! Fix the Touchpad battery @work (0%)
[ ] 1 !!! 2015-04-30 Close bank account @work
[ ] 2 ! Send minipimer back for warranty replacement @home
[X] 3 2015-03-29 Buy soy milk @home
[ ] 4 !! Fix the iPad's screen @home
[ ] 5 !! Fix the Touchpad battery @work

The columns, in order, are:

Expand Down
27 changes: 24 additions & 3 deletions todoman/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import functools
import glob
import io
import locale
import sys
from collections.abc import Iterator
Expand Down Expand Up @@ -256,13 +257,15 @@ class AppContext:
config: dict # TODO: better typing
db: Database
formatter_class: type[formatters.Formatter]
columns: bool

@cached_property
def ui_formatter(self) -> formatters.Formatter:
return formatters.DefaultFormatter(
self.config["date_format"],
self.config["time_format"],
self.config["dt_separator"],
self.columns,
)

@cached_property
Expand All @@ -271,6 +274,7 @@ def formatter(self) -> formatters.Formatter:
self.config["date_format"],
self.config["time_format"],
self.config["dt_separator"],
self.columns,
)


Expand Down Expand Up @@ -300,6 +304,16 @@ def formatter(self) -> formatters.Formatter:
"regardless."
),
)
@click.option(
"--columns",
default=None,
type=click.Choice(["always", "auto", "never"]),
help=(
"By default todoman will disable column-aligned output entirely (value "
"`never`). Set to `auto` to enable column-aligned output if stdout is a TTY, "
"or `always` to enable it regardless."
),
)
@click.option(
"--porcelain",
is_flag=True,
Expand Down Expand Up @@ -328,10 +342,11 @@ def formatter(self) -> formatters.Formatter:
@catch_errors
def cli(
click_ctx: click.Context,
colour: Literal["always"] | Literal["auto"] | Literal["never"],
colour: Literal["always", "auto", "never"] | None,
columns: Literal["always", "auto", "never"] | None,
porcelain: bool,
humanize: bool,
config: str,
humanize: bool | None,
config: str | None,
) -> None:
ctx = click_ctx.ensure_object(AppContext)
try:
Expand Down Expand Up @@ -360,6 +375,12 @@ def cli(
elif colour == "never":
click_ctx.color = False

columns = columns or ctx.config["columns"]
if columns == "auto":
ctx.columns = isinstance(sys.stdout, io.TextIOBase) and sys.stdout.isatty()
else:
ctx.columns = columns == "always"

paths = [
path
for path in glob.iglob(ctx.config["path"])
Expand Down
12 changes: 12 additions & 0 deletions todoman/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ class ConfigEntry(NamedTuple):
By default todoman will disable colored output if stdout is not a TTY (value
``auto``). Set to ``never`` to disable colored output entirely, or ``always``
to enable it regardless. This can be overridden with the ``--color`` option.
""",
validate_color_config,
),
ConfigEntry(
"columns",
str,
"never",
"""
By default todoman will disable column-aligned output entirely (value
`never`). Set to `auto` to enable column-aligned output if stdout is a TTY,
or `always` to enable it regardless. This can be overridden with the
``--columns`` option.
""",
validate_color_config,
),
Expand Down
136 changes: 102 additions & 34 deletions todoman/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from datetime import timezone
from datetime import tzinfo
from time import mktime
from typing import Callable
from typing import Literal

import click
import humanize
Expand All @@ -36,13 +38,30 @@ def rgb_to_ansi(colour: str | None) -> str | None:
return f"\33[38;2;{int(r, 16)!s};{int(g, 16)!s};{int(b, 16)!s}m"


class Column:
format: Callable[[Todo], str]
style: Callable[[Todo, str], str] | None
align_direction: Literal["left", "right"] = "left"

def __init__(
self,
format: Callable[[Todo], str],
style: Callable[[Todo, str], str] | None = None,
align_direction: Literal["left", "right"] = "left",
) -> None:
self.format = format
self.style = style
self.align_direction = align_direction


class Formatter(ABC):
@abstractmethod
def __init__(
self,
date_format: str = "%Y-%m-%d",
time_format: str = "%H:%M",
dt_separator: str = " ",
columns: bool = False,
) -> None:
"""Create a new formatter instance."""

Expand All @@ -56,7 +75,7 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list: bool = False) -> st

@abstractmethod
def simple_action(self, action: str, todo: Todo) -> str:
"""Render an action related to a todo (e.g.: compelete, undo, etc)."""
"""Render an action related to a todo (e.g.: complete, undo, etc)."""

@abstractmethod
def parse_priority(self, priority: str | None) -> int | None:
Expand Down Expand Up @@ -97,6 +116,7 @@ def __init__(
date_format: str = "%Y-%m-%d",
time_format: str = "%H:%M",
dt_separator: str = " ",
columns: bool = False,
tz_override: tzinfo | None = None,
) -> None:
self.date_format = date_format
Expand All @@ -105,6 +125,7 @@ def __init__(
self.datetime_format = dt_separator.join(
filter(bool, (date_format, time_format))
)
self.columns = columns

self.tz = tz_override or tzlocal()
self.now = datetime.now().replace(tzinfo=self.tz)
Expand All @@ -123,48 +144,95 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list: bool = False) -> st
# TODO: format lines fuidly and drop the table
# it can end up being more readable when too many columns are empty.
# show dates that are in the future in yellow (in 24hs) or grey (future)
table = []
for todo in todos:
completed = "X" if todo.is_completed else " "
percent = todo.percent_complete or ""
if percent:
percent = f" ({percent}%)"

if todo.categories:
categories = " [" + ", ".join(todo.categories) + "]"
else:
categories = ""
columns = {
"completed": Column(
format=lambda todo: "[X]" if todo.is_completed else "[ ]"
),
"id": Column(lambda todo: str(todo.id), align_direction="right"),
"priority": Column(
format=lambda todo: self.format_priority_compact(todo.priority),
style=lambda todo, value: click.style(value, fg="magenta"),
align_direction="right",
),
"due": Column(
format=lambda todo: str(
self.format_datetime(todo.due) or "(no due date)"
),
style=lambda todo, value: click.style(value, fg=c)
if (c := self._due_colour(todo))
else value,
),
"report": Column(format=self.format_report),
}

priority = click.style(
self.format_priority_compact(todo.priority),
fg="magenta",
)
table = self.format_rows(columns, todos)
if self.columns:
table = self.columns_aligned_rows(columns, table)

due = self.format_datetime(todo.due) or "(no due date)"
due_colour = self._due_colour(todo)
if due_colour:
due = click.style(str(due), fg=due_colour)
table = self.style_rows(columns, table)
return "\n".join(table)

recurring = "⟳" if todo.is_recurring else ""
def format_rows(
self, columns: dict[str, Column], todos: Iterable[Todo]
) -> Iterable[tuple[Todo, list[str]]]:
for todo in todos:
yield (todo, [columns[col].format(todo) for col in columns])

if hide_list:
summary = f"{todo.summary} {percent}"
else:
if not todo.list:
raise ValueError("Cannot format todo without a list")
def columns_aligned_rows(
self,
columns: dict[str, Column],
rows: Iterable[tuple[Todo, list[str]]],
) -> Iterable[tuple[Todo, list[str]]]:
rows = list(rows) # materialize the iterator
max_lengths = [0 for _ in columns]
for _, cols in rows:
for i, col in enumerate(cols):
max_lengths[i] = max(max_lengths[i], len(col))

for todo, cols in rows:
formatted = []
for i, (col, conf) in enumerate(zip(cols, columns.values())):
if conf.align_direction == "right":
formatted.append(col.rjust(max_lengths[i]))
elif i < len(cols) - 1:
formatted.append(col.ljust(max_lengths[i]))
else:
# if last column is left-aligned, don't add spaces
formatted.append(col)

yield todo, formatted

def style_rows(
self,
columns: dict[str, Column],
rows: Iterable[tuple[Todo, list[str]]],
) -> Iterable[str]:
for todo, cols in rows:
yield " ".join(
conf.style(todo, col) if conf.style else col
for col, conf in zip(cols, columns.values())
)

summary = f"{todo.summary} {self.format_database(todo.list)}{percent}"
def format_report(self, todo: Todo, hide_list: bool = False) -> str:
percent = todo.percent_complete or ""
if percent:
percent = f" ({percent}%)"

# TODO: add spaces on the left based on max todos"
categories = " [" + ", ".join(todo.categories) + "]" if todo.categories else ""

# FIXME: double space when no priority
# split into parts to satisfy linter line too long
table.append(
f"[{completed}] {todo.id} {priority} {due} "
f"{recurring}{summary}{categories}"
)
recurring = "⟳" if todo.is_recurring else ""

return "\n".join(table)
if hide_list:
summary = f"{todo.summary} {percent}"
else:
if not todo.list:
raise ValueError("Cannot format todo without a list")

summary = f"{todo.summary} {self.format_database(todo.list)}{percent}"

# TODO: add spaces on the left based on max todos"
Copy link
Member

Choose a reason for hiding this comment

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

What does this mean?

Copy link
Author

@yzx9 yzx9 Apr 29, 2025

Choose a reason for hiding this comment

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

IIRC, I don't modify them. I think it's invalid now?

https://github.com/pimutils/todoman/blob/main/todoman%2Fformatters.py#L158

return f"{recurring}{summary}{categories}"

def _due_colour(self, todo: Todo) -> str:
now = self.now if isinstance(todo.due, datetime) else self.now.date()
Expand Down