git-ember 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitember/render.py ADDED
@@ -0,0 +1,293 @@
1
+ import calendar
2
+ import datetime as dt
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from githeat.colors import RESET, LIGHT_GRAY, ColorScheme
6
+
7
+
8
+ DEFAULT_THRESHOLDS = (0, 1, 3, 6, 10)
9
+
10
+
11
+ def calculate_thresholds(counts: Dict[dt.date, int], mode: str = "auto") -> tuple:
12
+ """Calculate intensity thresholds based on scaling mode.
13
+
14
+ Args:
15
+ counts: Dict mapping date to commit count.
16
+ mode: Scaling mode - "auto" or "adaptive".
17
+
18
+ Returns:
19
+ Tuple of threshold values.
20
+ """
21
+ if mode == "auto":
22
+ return (
23
+ 0,
24
+ max(1, int(max_commits * 0.2)),
25
+ max(1, int(max_commits * 0.4)),
26
+ max(1, int(max_commits * 0.6)),
27
+ max(1, int(max_commits * 0.8)),
28
+ )
29
+
30
+ if mode == "adaptive":
31
+ sorted_values = sorted(values)
32
+ n = len(sorted_values)
33
+ return (
34
+ 0,
35
+ sorted_values[int(n * 0.50)] if n > 0 else 1,
36
+ sorted_values[int(n * 0.75)] if n > 0 else 3,
37
+ sorted_values[int(n * 0.90)] if n > 0 else 6,
38
+ sorted_values[int(n * 0.95)] if n > 0 else 10,
39
+ )
40
+
41
+ return DEFAULT_THRESHOLDS
42
+
43
+
44
+ def choose_level(count: int, thresholds: tuple = None) -> int:
45
+ """Map commit count to intensity level 0-4.
46
+
47
+ Args:
48
+ count: Number of commits.
49
+ thresholds: Tuple of threshold values for each level.
50
+
51
+ Returns:
52
+ Intensity level (0-4), where 0 = no commits, 4 = highest activity.
53
+ """
54
+ if thresholds is None:
55
+ thresholds = DEFAULT_THRESHOLDS
56
+ if count == 0:
57
+ return 0
58
+ if count <= thresholds[1]:
59
+ return 1
60
+ if count <= thresholds[2]:
61
+ return 2
62
+ if count <= thresholds[3]:
63
+ return 3
64
+ return 4
65
+
66
+
67
+ def build_date_range(start: dt.date, end: dt.date) -> List[dt.date]:
68
+ """Generate list of all dates from start to end inclusive.
69
+
70
+ Args:
71
+ start: Start date (inclusive).
72
+ end: End date (inclusive).
73
+
74
+ Returns:
75
+ List of dates in range.
76
+ """
77
+ days: List[dt.date] = []
78
+ cur = start
79
+ while cur <= end:
80
+ days.append(cur)
81
+ cur += dt.timedelta(days=1)
82
+ return days
83
+
84
+
85
+ def get_month_labels(
86
+ start_date: dt.date, end_date: dt.date, week_start: str
87
+ ) -> Dict[int, str]:
88
+ """Get month abbreviations positioned at their week boundaries.
89
+
90
+ Args:
91
+ start_date: Start of date range.
92
+ end_date: End of date range.
93
+ week_start: "monday" or "sunday".
94
+
95
+ Returns:
96
+ Dict mapping week index to 3-letter month abbreviation.
97
+ """
98
+ months = {}
99
+ all_days = build_date_range(start_date, end_date)
100
+
101
+ if week_start == "monday":
102
+ offset = start_date.weekday()
103
+ else:
104
+ offset = (start_date.weekday() + 1) % 7
105
+ grid_start = start_date - dt.timedelta(days=offset)
106
+
107
+ for day in all_days:
108
+ idx = (day - grid_start).days
109
+ week = idx // 7
110
+ if day.day == 1:
111
+ months[week] = calendar.month_abbr[day.month]
112
+
113
+ return months
114
+
115
+
116
+ def render_grid(
117
+ start_date: dt.date,
118
+ end_date: dt.date,
119
+ counts: Dict[dt.date, int],
120
+ color_scheme: ColorScheme,
121
+ week_start: str,
122
+ border_char: str = "=",
123
+ ascii_mode: bool = False,
124
+ thresholds: tuple = None,
125
+ ) -> str:
126
+ """Render heatmap in grid style (horizontal squares).
127
+
128
+ Args:
129
+ start_date: Start of date range.
130
+ end_date: End of date range.
131
+ counts: Dict mapping date to commit count.
132
+ color_scheme: Color scheme for intensity levels.
133
+ week_start: "monday" or "sunday".
134
+ border_char: Character for horizontal borders.
135
+ ascii_mode: Use ASCII characters instead of Unicode blocks.
136
+ thresholds: Custom intensity thresholds (default: (0,1,3,6,10)).
137
+
138
+ Returns:
139
+ Formatted string representation of heatmap.
140
+ """
141
+ if thresholds is None:
142
+ thresholds = DEFAULT_THRESHOLDS
143
+ all_days = build_date_range(start_date, end_date)
144
+
145
+ if ascii_mode:
146
+ block_chars = [" ", "..", "::", "==", "##"]
147
+ else:
148
+ block_chars = [" ", "░░", "▒▒", "▓▓", "██"]
149
+
150
+ if week_start == "monday":
151
+ offset = start_date.weekday()
152
+ else:
153
+ offset = (start_date.weekday() + 1) % 7
154
+ grid_start = start_date - dt.timedelta(days=offset)
155
+
156
+ total_days = (end_date - grid_start).days + 1
157
+ num_weeks = (total_days + 6) // 7
158
+
159
+ grid: List[List[int]] = [[0 for _ in range(num_weeks)] for _ in range(7)]
160
+ for day in all_days:
161
+ idx = (day - grid_start).days
162
+ week = idx // 7
163
+ weekday = day.weekday()
164
+ count = counts.get(day, 0)
165
+ grid[weekday][week] = choose_level(count, thresholds)
166
+
167
+ label_days = {0: "Mon ", 2: "Wed ", 4: "Fri "}
168
+ if week_start == "sunday":
169
+ order = [6, 0, 1, 2, 3, 4, 5]
170
+ else:
171
+ order = [0, 1, 2, 3, 4, 5, 6]
172
+
173
+ month_labels = get_month_labels(start_date, end_date, week_start)
174
+
175
+ lines = []
176
+
177
+ month_line = " " * 4
178
+ for w in range(num_weeks):
179
+ month_line += month_labels.get(w, " ")
180
+ lines.append(month_line)
181
+ lines.append(border_char * len(month_line))
182
+
183
+ for weekday in order:
184
+ label = label_days.get(weekday, " ")
185
+ cells = []
186
+ for week in range(num_weeks):
187
+ level = grid[weekday][week]
188
+ color = color_scheme.get(level)
189
+ cell_char = block_chars[level]
190
+ cells.append(f"{color}{cell_char}{RESET}")
191
+ lines.append(f"{label} " + "".join(cells))
192
+
193
+ lines.append(border_char * len(month_line))
194
+
195
+ return "\n".join(lines)
196
+
197
+
198
+ def render_branch_tree(
199
+ commits: List[Dict[str, Any]],
200
+ default_branch: Optional[str] = None,
201
+ selected_branch: Optional[str] = None,
202
+ color_scheme: Optional["ColorScheme"] = None,
203
+ ascii_mode: bool = False,
204
+ compact: bool = False,
205
+ ) -> str:
206
+ """Render horizontal branch tree visualization.
207
+
208
+ Args:
209
+ commits: List of commit dicts from get_branch_tree.
210
+ default_branch: Name of default branch (e.g., "main").
211
+ selected_branch: Branch specified by user (for highlighting).
212
+ color_scheme: Color scheme for branch name highlighting.
213
+ ascii_mode: Use ASCII characters instead of Unicode.
214
+
215
+ Returns:
216
+ Formatted string with branch tree visualization.
217
+ """
218
+ if not commits:
219
+ return ""
220
+
221
+ node_char = "*" if ascii_mode else "•"
222
+
223
+ lines = ["Branch Tree:"]
224
+ branch_colors: Dict[str, str] = {}
225
+ color_index = 2
226
+
227
+ def get_branch_color(branch: str) -> str:
228
+ nonlocal color_index
229
+
230
+ if not color_scheme:
231
+ return ""
232
+
233
+ if selected_branch and branch == selected_branch:
234
+ return color_scheme.get(4)
235
+
236
+ if branch not in branch_colors:
237
+ branch_colors[branch] = color_scheme.get(color_index)
238
+ color_index += 1
239
+ if color_index > 4: # Wrap back to 2, keeping 0–1 reserved
240
+ color_index = 2
241
+
242
+ return branch_colors[branch]
243
+
244
+ max_hash_len = 7
245
+ max_branch_width = 0
246
+ for commit in commits:
247
+ branches = commit.get("branches", [])
248
+ if branches:
249
+ branch_str = ", ".join(branches)
250
+ max_branch_width = max(max_branch_width, len(branch_str))
251
+
252
+ has_branches = max_branch_width > 0
253
+ if has_branches:
254
+ separator_width = 1 + max_hash_len + 1 + 20 + 1 + max_branch_width + 1
255
+ else:
256
+ separator_width = 1 + max_hash_len + 1 + 35 + 1
257
+
258
+ for commit in commits:
259
+ graph_line = commit.get("graph_line", "*")
260
+
261
+ if node_char != "*":
262
+ graph_line = graph_line.replace("*", node_char)
263
+
264
+ hash7 = commit["hash"]
265
+ full_message = commit["message"]
266
+ max_msg_len = 20 if compact else 35
267
+ if len(full_message) > max_msg_len:
268
+ message = full_message[: max_msg_len - 3] + "..."
269
+ else:
270
+ message = full_message + " " * (max_msg_len - len(full_message))
271
+ branches = commit.get("branches", [])
272
+
273
+ is_connector = not hash7 and "|" in graph_line
274
+
275
+ branch_part = ""
276
+ if branches:
277
+ branch_part = " " + ", ".join(branches)
278
+
279
+ if is_connector:
280
+ lines.append(f"{graph_line}{' ' * separator_width}")
281
+ else:
282
+ sep_len = 5 if compact else 20
283
+ sep_dashes = LIGHT_GRAY + "-" * sep_len + RESET
284
+ branch_label = (
285
+ LIGHT_GRAY + branch_part.strip() + RESET if branch_part else ""
286
+ )
287
+ separator = f" {sep_dashes} {branch_label}" if branch_part else ""
288
+ commit_color = color_scheme.get(3) if color_scheme else ""
289
+ lines.append(
290
+ f"{graph_line} {commit_color}{hash7}{RESET} {message}{separator}"
291
+ )
292
+
293
+ return "\n".join(lines)