Skip to content

Commit 2cfbc6d

Browse files
committed
Align output columns
1 parent 8815879 commit 2cfbc6d

File tree

3 files changed

+131
-34
lines changed

3 files changed

+131
-34
lines changed

todoman/cli.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,15 @@ class AppContext:
256256
config: dict # TODO: better typing
257257
db: Database
258258
formatter_class: type[formatters.Formatter]
259+
align: bool
259260

260261
@cached_property
261262
def ui_formatter(self) -> formatters.Formatter:
262263
return formatters.DefaultFormatter(
263264
self.config["date_format"],
264265
self.config["time_format"],
265266
self.config["dt_separator"],
267+
self.align,
266268
)
267269

268270
@cached_property
@@ -271,6 +273,7 @@ def formatter(self) -> formatters.Formatter:
271273
self.config["date_format"],
272274
self.config["time_format"],
273275
self.config["dt_separator"],
276+
self.align,
274277
)
275278

276279

@@ -287,6 +290,17 @@ def formatter(self) -> formatters.Formatter:
287290

288291
@click.group(invoke_without_command=True)
289292
@click_log.simple_verbosity_option()
293+
@click.option(
294+
"--align",
295+
default=None,
296+
type=click.Choice(["always", "auto", "never"]),
297+
help=(
298+
"By default todoman will disable aligned output if stdout "
299+
"is not a TTY (value `auto`). Set to `never` to disable "
300+
"aligned output entirely, or `always` to enable it "
301+
"regardless."
302+
),
303+
)
290304
@click.option(
291305
"--colour",
292306
"--color",
@@ -328,7 +342,8 @@ 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+
align: Literal["always", "auto", "never"],
346+
colour: Literal["always", "auto", "never"],
332347
porcelain: bool,
333348
humanize: bool,
334349
config: str,
@@ -354,6 +369,14 @@ def cli(
354369
else:
355370
ctx.formatter_class = formatters.DefaultFormatter
356371

372+
align = align or ctx.config["align"]
373+
if align == "always":
374+
ctx.align = True
375+
elif align == "never":
376+
ctx.align = False
377+
else:
378+
ctx.align = bool(sys.stdout.isatty())
379+
357380
colour = colour or ctx.config["color"]
358381
if colour == "always":
359382
click_ctx.color = True

todoman/configuration.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ class ConfigEntry(NamedTuple):
7474
files) will be treated as a list.""",
7575
expand_path,
7676
),
77+
ConfigEntry(
78+
"align",
79+
str,
80+
"auto",
81+
"""
82+
By default todoman will disable aligned output if stdout is not a TTY (value
83+
``auto``). Set to ``never`` to disable aligned output entirely, or ``always``
84+
to enable it regardless. This can be overridden with the ``--color`` option.
85+
""",
86+
validate_color_config,
87+
),
7788
ConfigEntry(
7889
"color",
7990
str,

todoman/formatters.py

Lines changed: 96 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,90 @@ 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+
}
137168

138-
priority = click.style(
139-
self.format_priority_compact(todo.priority),
140-
fg="magenta",
169+
table = self.format_rows(columns, todos)
170+
if self.align:
171+
table = self.align_rows(columns, table)
172+
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, columns: dict[str, Column], rows: Iterable[tuple[Todo, list[str]]]
184+
) -> Iterable[tuple[Todo, list[str]]]:
185+
max_lengths = [0 for _ in columns]
186+
rows = list(rows) # materialize the iterator
187+
for _, cols in rows:
188+
for i, col in enumerate(cols):
189+
if len(col) > max_lengths[i]:
190+
max_lengths[i] = len(col)
191+
192+
for todo, cols in rows:
193+
yield (
194+
todo,
195+
[
196+
col.ljust(max_lengths[i])
197+
if conf.align_direction == "left"
198+
else col.rjust(max_lengths[i])
199+
for i, (col, conf) in enumerate(zip(cols, columns.values()))
200+
],
141201
)
142202

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)
203+
def style_rows(
204+
self, columns: dict[str, Column], rows: Iterable[tuple[Todo, list[str]]]
205+
) -> Iterable[str]:
206+
for todo, cols in rows:
207+
yield " ".join(
208+
conf.style(todo, col) if conf.style else col
209+
for col, conf in zip(cols, columns.values())
210+
)
147211

148-
recurring = "⟳" if todo.is_recurring else ""
212+
def format_report(self, todo: Todo, hide_list: bool = False) -> str:
213+
percent = todo.percent_complete or ""
214+
if percent:
215+
percent = f" ({percent}%)"
149216

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")
217+
categories = " [" + ", ".join(todo.categories) + "]" if todo.categories else ""
155218

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

158-
# TODO: add spaces on the left based on max todos"
221+
if hide_list:
222+
summary = f"{todo.summary} {percent}"
223+
else:
224+
if not todo.list:
225+
raise ValueError("Cannot format todo without a list")
159226

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-
)
227+
summary = f"{todo.summary} {self.format_database(todo.list)}{percent}"
166228

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

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

0 commit comments

Comments
 (0)