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
+ columns : 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
+ columns : 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 .columns = columns
108
129
109
130
self .tz = tz_override or tzlocal ()
110
131
self .now = datetime .now ().replace (tzinfo = self .tz )
@@ -123,48 +144,95 @@ 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" ,
141
- )
169
+ table = self .format_rows (columns , todos )
170
+ if self .columns :
171
+ table = self .columns_aligned_rows (columns , table )
142
172
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 )
173
+ table = self .style_rows (columns , table )
174
+ return "\n " .join (table )
147
175
148
- recurring = "⟳" if todo .is_recurring else ""
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 ])
149
181
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" )
182
+ def columns_aligned_rows (
183
+ self ,
184
+ columns : dict [str , Column ],
185
+ rows : Iterable [tuple [Todo , list [str ]]],
186
+ ) -> Iterable [tuple [Todo , list [str ]]]:
187
+ rows = list (rows ) # materialize the iterator
188
+ max_lengths = [0 for _ in columns ]
189
+ for _ , cols in rows :
190
+ for i , col in enumerate (cols ):
191
+ max_lengths [i ] = max (max_lengths [i ], len (col ))
192
+
193
+ for todo , cols in rows :
194
+ formatted = []
195
+ for i , (col , conf ) in enumerate (zip (cols , columns .values ())):
196
+ if conf .align_direction == "right" :
197
+ formatted .append (col .rjust (max_lengths [i ]))
198
+ elif i < len (cols ) - 1 :
199
+ formatted .append (col .ljust (max_lengths [i ]))
200
+ else :
201
+ # if last column is left-aligned, don't add spaces
202
+ formatted .append (col )
203
+
204
+ yield todo , formatted
205
+
206
+ def style_rows (
207
+ self ,
208
+ columns : dict [str , Column ],
209
+ rows : Iterable [tuple [Todo , list [str ]]],
210
+ ) -> Iterable [str ]:
211
+ for todo , cols in rows :
212
+ yield " " .join (
213
+ conf .style (todo , col ) if conf .style else col
214
+ for col , conf in zip (cols , columns .values ())
215
+ )
155
216
156
- summary = f"{ todo .summary } { self .format_database (todo .list )} { percent } "
217
+ def format_report (self , todo : Todo , hide_list : bool = False ) -> str :
218
+ percent = todo .percent_complete or ""
219
+ if percent :
220
+ percent = f" ({ percent } %)"
157
221
158
- # TODO: add spaces on the left based on max todos "
222
+ categories = " [" + ", " . join ( todo . categories ) + "]" if todo . categories else " "
159
223
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
- )
224
+ recurring = "⟳" if todo .is_recurring else ""
166
225
167
- return "\n " .join (table )
226
+ if hide_list :
227
+ summary = f"{ todo .summary } { percent } "
228
+ else :
229
+ if not todo .list :
230
+ raise ValueError ("Cannot format todo without a list" )
231
+
232
+ summary = f"{ todo .summary } { self .format_database (todo .list )} { percent } "
233
+
234
+ # TODO: add spaces on the left based on max todos"
235
+ return f"{ recurring } { summary } { categories } "
168
236
169
237
def _due_colour (self , todo : Todo ) -> str :
170
238
now = self .now if isinstance (todo .due , datetime ) else self .now .date ()
0 commit comments