Skip to content

Commit 6b976c9

Browse files
committed
Add column-aligned output support
1 parent 8815879 commit 6b976c9

File tree

4 files changed

+129
-41
lines changed

4 files changed

+129
-41
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ v4.6.0
1111
* The default value for ``path`` is now ``~/calendars/*``. Previously this
1212
value was required. This change is non-breaking; all existing valid
1313
configurations define this value.
14+
* Add a new `columns` option to enable column-aligned output.
1415

1516
v4.5.0
1617
------

docs/source/usage.rst

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ intentions is to also provide a fully `TUI`_-based interface).
77
The default action is ``list``, which outputs all tasks for all calendars, each
88
with a semi-permanent unique id::
99

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

1616
The columns, in order, are:
1717

todoman/cli.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import Callable
1313
from typing import Literal
1414
from typing import ParamSpec
15+
from typing import TextIO
1516
from typing import TypeVar
1617

1718
import click
@@ -256,13 +257,15 @@ class AppContext:
256257
config: dict # TODO: better typing
257258
db: Database
258259
formatter_class: type[formatters.Formatter]
260+
columns: bool
259261

260262
@cached_property
261263
def ui_formatter(self) -> formatters.Formatter:
262264
return formatters.DefaultFormatter(
263265
self.config["date_format"],
264266
self.config["time_format"],
265267
self.config["dt_separator"],
268+
self.columns,
266269
)
267270

268271
@cached_property
@@ -271,6 +274,7 @@ def formatter(self) -> formatters.Formatter:
271274
self.config["date_format"],
272275
self.config["time_format"],
273276
self.config["dt_separator"],
277+
self.columns,
274278
)
275279

276280

@@ -300,6 +304,16 @@ def formatter(self) -> formatters.Formatter:
300304
"regardless."
301305
),
302306
)
307+
@click.option(
308+
"--columns",
309+
default=None,
310+
type=click.Choice(["always", "auto", "never"]),
311+
help=(
312+
"By default todoman will disable column-aligned output entirely (value "
313+
"`never`). Set to `auto` to enable column-aligned output if stdout is a TTY, "
314+
"or `always` to enable it regardless."
315+
),
316+
)
303317
@click.option(
304318
"--porcelain",
305319
is_flag=True,
@@ -328,10 +342,11 @@ def formatter(self) -> formatters.Formatter:
328342
@catch_errors
329343
def cli(
330344
click_ctx: click.Context,
331-
colour: Literal["always"] | Literal["auto"] | Literal["never"],
345+
columns: Literal["always", "auto", "never"] | None,
346+
colour: Literal["always", "auto", "never"] | None,
332347
porcelain: bool,
333-
humanize: bool,
334-
config: str,
348+
humanize: bool | None,
349+
config: str | None,
335350
) -> None:
336351
ctx = click_ctx.ensure_object(AppContext)
337352
try:
@@ -354,6 +369,11 @@ def cli(
354369
else:
355370
ctx.formatter_class = formatters.DefaultFormatter
356371

372+
if columns == "auto":
373+
ctx.columns = isinstance(sys.stdout, TextIO) and sys.stdout.isatty()
374+
else:
375+
ctx.columns = columns == "always"
376+
357377
colour = colour or ctx.config["color"]
358378
if colour == "always":
359379
click_ctx.color = True

todoman/formatters.py

Lines changed: 100 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from datetime import timezone
1212
from datetime import tzinfo
1313
from time import mktime
14+
from typing import Callable
15+
from typing import Literal
1416

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

3840

41+
class Column:
42+
format: Callable[[Todo], str]
43+
style: Callable[[Todo, str], str] | None
44+
align_direction: Literal["left", "right"] = "left"
45+
46+
def __init__(
47+
self,
48+
format: Callable[[Todo], str],
49+
style: Callable[[Todo, str], str] | None = None,
50+
align_direction: Literal["left", "right"] = "left",
51+
) -> None:
52+
self.format = format
53+
self.style = style
54+
self.align_direction = align_direction
55+
56+
3957
class Formatter(ABC):
4058
@abstractmethod
4159
def __init__(
4260
self,
4361
date_format: str = "%Y-%m-%d",
4462
time_format: str = "%H:%M",
4563
dt_separator: str = " ",
64+
align: bool = False,
4665
) -> None:
4766
"""Create a new formatter instance."""
4867

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

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

6180
@abstractmethod
6281
def parse_priority(self, priority: str | None) -> int | None:
@@ -97,6 +116,7 @@ def __init__(
97116
date_format: str = "%Y-%m-%d",
98117
time_format: str = "%H:%M",
99118
dt_separator: str = " ",
119+
align: bool = False,
100120
tz_override: tzinfo | None = None,
101121
) -> None:
102122
self.date_format = date_format
@@ -105,6 +125,7 @@ def __init__(
105125
self.datetime_format = dt_separator.join(
106126
filter(bool, (date_format, time_format))
107127
)
128+
self.align = align
108129

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

133-
if todo.categories:
134-
categories = " [" + ", ".join(todo.categories) + "]"
135-
else:
136-
categories = ""
148+
columns = {
149+
"completed": Column(
150+
format=lambda todo: "[X]" if todo.is_completed else "[ ]"
151+
),
152+
"id": Column(lambda todo: str(todo.id), align_direction="right"),
153+
"priority": Column(
154+
format=lambda todo: self.format_priority_compact(todo.priority),
155+
style=lambda todo, value: click.style(value, fg="magenta"),
156+
align_direction="right",
157+
),
158+
"due": Column(
159+
format=lambda todo: str(
160+
self.format_datetime(todo.due) or "(no due date)"
161+
),
162+
style=lambda todo, value: click.style(value, fg=c)
163+
if (c := self._due_colour(todo))
164+
else value,
165+
),
166+
"report": Column(format=self.format_report),
167+
}
168+
169+
table = self.format_rows(columns, todos)
170+
if self.align:
171+
table = self.align_rows(columns, table)
137172

138-
priority = click.style(
139-
self.format_priority_compact(todo.priority),
140-
fg="magenta",
173+
table = self.style_rows(columns, table)
174+
return "\n".join(table)
175+
176+
def format_rows(
177+
self, columns: dict[str, Column], todos: Iterable[Todo]
178+
) -> Iterable[tuple[Todo, list[str]]]:
179+
for todo in todos:
180+
yield (todo, [columns[col].format(todo) for col in columns])
181+
182+
def align_rows(
183+
self,
184+
columns: dict[str, Column],
185+
rows: Iterable[tuple[Todo, list[str]]],
186+
) -> Iterable[tuple[Todo, list[str]]]:
187+
max_lengths = [0 for _ in columns]
188+
rows = list(rows) # materialize the iterator
189+
for _, cols in rows:
190+
for i, col in enumerate(cols):
191+
if len(col) > max_lengths[i]:
192+
max_lengths[i] = len(col)
193+
194+
for todo, cols in rows:
195+
yield (
196+
todo,
197+
[
198+
col.ljust(max_lengths[i])
199+
if conf.align_direction == "left"
200+
else col.rjust(max_lengths[i])
201+
for i, (col, conf) in enumerate(zip(cols, columns.values()))
202+
],
141203
)
142204

143-
due = self.format_datetime(todo.due) or "(no due date)"
144-
due_colour = self._due_colour(todo)
145-
if due_colour:
146-
due = click.style(str(due), fg=due_colour)
205+
def style_rows(
206+
self,
207+
columns: dict[str, Column],
208+
rows: Iterable[tuple[Todo, list[str]]],
209+
) -> Iterable[str]:
210+
for todo, cols in rows:
211+
yield " ".join(
212+
conf.style(todo, col) if conf.style else col
213+
for col, conf in zip(cols, columns.values())
214+
)
147215

148-
recurring = "⟳" if todo.is_recurring else ""
216+
def format_report(self, todo: Todo, hide_list: bool = False) -> str:
217+
percent = todo.percent_complete or ""
218+
if percent:
219+
percent = f" ({percent}%)"
149220

150-
if hide_list:
151-
summary = f"{todo.summary} {percent}"
152-
else:
153-
if not todo.list:
154-
raise ValueError("Cannot format todo without a list")
221+
categories = " [" + ", ".join(todo.categories) + "]" if todo.categories else ""
155222

156-
summary = f"{todo.summary} {self.format_database(todo.list)}{percent}"
223+
recurring = "⟳" if todo.is_recurring else ""
157224

158-
# TODO: add spaces on the left based on max todos"
225+
if hide_list:
226+
summary = f"{todo.summary} {percent}"
227+
else:
228+
if not todo.list:
229+
raise ValueError("Cannot format todo without a list")
159230

160-
# FIXME: double space when no priority
161-
# split into parts to satisfy linter line too long
162-
table.append(
163-
f"[{completed}] {todo.id} {priority} {due} "
164-
f"{recurring}{summary}{categories}"
165-
)
231+
summary = f"{todo.summary} {self.format_database(todo.list)}{percent}"
166232

167-
return "\n".join(table)
233+
# TODO: add spaces on the left based on max todos"
234+
return f"{recurring}{summary}{categories}"
168235

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

0 commit comments

Comments
 (0)