11
11
from datetime import timezone
12
12
from datetime import tzinfo
13
13
from time import mktime
14
+ from typing import Callable
15
+ from typing import Literal
14
16
15
17
import click
16
18
import humanize
@@ -36,13 +38,30 @@ def rgb_to_ansi(colour: str | None) -> str | None:
36
38
return f"\33 [38;2;{ int (r , 16 )!s} ;{ int (g , 16 )!s} ;{ int (b , 16 )!s} m"
37
39
38
40
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
+
39
57
class Formatter (ABC ):
40
58
@abstractmethod
41
59
def __init__ (
42
60
self ,
43
61
date_format : str = "%Y-%m-%d" ,
44
62
time_format : str = "%H:%M" ,
45
63
dt_separator : str = " " ,
64
+ align : bool = False ,
46
65
) -> None :
47
66
"""Create a new formatter instance."""
48
67
@@ -56,7 +75,7 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list: bool = False) -> st
56
75
57
76
@abstractmethod
58
77
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)."""
60
79
61
80
@abstractmethod
62
81
def parse_priority (self , priority : str | None ) -> int | None :
@@ -97,6 +116,7 @@ def __init__(
97
116
date_format : str = "%Y-%m-%d" ,
98
117
time_format : str = "%H:%M" ,
99
118
dt_separator : str = " " ,
119
+ align : bool = False ,
100
120
tz_override : tzinfo | None = None ,
101
121
) -> None :
102
122
self .date_format = date_format
@@ -105,6 +125,7 @@ def __init__(
105
125
self .datetime_format = dt_separator .join (
106
126
filter (bool , (date_format , time_format ))
107
127
)
128
+ self .align = align
108
129
109
130
self .tz = tz_override or tzlocal ()
110
131
self .now = datetime .now ().replace (tzinfo = self .tz )
@@ -123,48 +144,90 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list: bool = False) -> st
123
144
# TODO: format lines fuidly and drop the table
124
145
# it can end up being more readable when too many columns are empty.
125
146
# 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 } %)"
132
147
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
+ }
137
168
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
+ ],
141
201
)
142
202
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
+ )
147
211
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 } %)"
149
216
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 ""
155
218
156
- summary = f" { todo . summary } { self . format_database ( todo .list ) } { percent } "
219
+ recurring = "⟳" if todo .is_recurring else " "
157
220
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" )
159
226
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 } "
166
228
167
- return "\n " .join (table )
229
+ # TODO: add spaces on the left based on max todos"
230
+ return f"{ recurring } { summary } { categories } "
168
231
169
232
def _due_colour (self , todo : Todo ) -> str :
170
233
now = self .now if isinstance (todo .due , datetime ) else self .now .date ()
0 commit comments