1111from datetime import timezone
1212from datetime import tzinfo
1313from time import mktime
14+ from typing import Callable
15+ from typing import Literal
1416
1517import click
1618import 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+
3957class 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_rows :
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