cudag 0.3.10__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.
- cudag/__init__.py +334 -0
- cudag/annotation/__init__.py +77 -0
- cudag/annotation/codegen.py +648 -0
- cudag/annotation/config.py +545 -0
- cudag/annotation/loader.py +342 -0
- cudag/annotation/scaffold.py +121 -0
- cudag/annotation/transcription.py +296 -0
- cudag/cli/__init__.py +5 -0
- cudag/cli/main.py +315 -0
- cudag/cli/new.py +873 -0
- cudag/core/__init__.py +364 -0
- cudag/core/button.py +137 -0
- cudag/core/canvas.py +222 -0
- cudag/core/config.py +70 -0
- cudag/core/coords.py +233 -0
- cudag/core/data_grid.py +804 -0
- cudag/core/dataset.py +678 -0
- cudag/core/distribution.py +136 -0
- cudag/core/drawing.py +75 -0
- cudag/core/fonts.py +156 -0
- cudag/core/generator.py +163 -0
- cudag/core/grid.py +367 -0
- cudag/core/grounding_task.py +247 -0
- cudag/core/icon.py +207 -0
- cudag/core/iconlist_task.py +301 -0
- cudag/core/models.py +1251 -0
- cudag/core/random.py +130 -0
- cudag/core/renderer.py +190 -0
- cudag/core/screen.py +402 -0
- cudag/core/scroll_task.py +254 -0
- cudag/core/scrollable_grid.py +447 -0
- cudag/core/state.py +110 -0
- cudag/core/task.py +293 -0
- cudag/core/taskbar.py +350 -0
- cudag/core/text.py +212 -0
- cudag/core/utils.py +82 -0
- cudag/data/surnames.txt +5000 -0
- cudag/modal_apps/__init__.py +4 -0
- cudag/modal_apps/archive.py +103 -0
- cudag/modal_apps/extract.py +138 -0
- cudag/modal_apps/preprocess.py +529 -0
- cudag/modal_apps/upload.py +317 -0
- cudag/prompts/SYSTEM_PROMPT.txt +104 -0
- cudag/prompts/__init__.py +33 -0
- cudag/prompts/system.py +43 -0
- cudag/prompts/tools.py +382 -0
- cudag/py.typed +0 -0
- cudag/schemas/filesystem.json +90 -0
- cudag/schemas/test_record.schema.json +113 -0
- cudag/schemas/train_record.schema.json +90 -0
- cudag/server/__init__.py +21 -0
- cudag/server/app.py +232 -0
- cudag/server/services/__init__.py +9 -0
- cudag/server/services/generator.py +128 -0
- cudag/templates/scripts/archive.sh +35 -0
- cudag/templates/scripts/build.sh +13 -0
- cudag/templates/scripts/extract.sh +54 -0
- cudag/templates/scripts/generate.sh +116 -0
- cudag/templates/scripts/pre-commit.sh +44 -0
- cudag/templates/scripts/preprocess.sh +46 -0
- cudag/templates/scripts/upload.sh +63 -0
- cudag/templates/scripts/verify.py +428 -0
- cudag/validation/__init__.py +35 -0
- cudag/validation/validate.py +508 -0
- cudag-0.3.10.dist-info/METADATA +570 -0
- cudag-0.3.10.dist-info/RECORD +69 -0
- cudag-0.3.10.dist-info/WHEEL +4 -0
- cudag-0.3.10.dist-info/entry_points.txt +2 -0
- cudag-0.3.10.dist-info/licenses/LICENSE +66 -0
cudag/core/data_grid.py
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
# Copyright (c) 2025 Tylt LLC. All rights reserved.
|
|
2
|
+
# CONFIDENTIAL AND PROPRIETARY. Unauthorized use, copying, or distribution
|
|
3
|
+
# is strictly prohibited. For licensing inquiries: hello@claimhawk.app
|
|
4
|
+
|
|
5
|
+
"""Composable data grid system for annotation-driven rendering.
|
|
6
|
+
|
|
7
|
+
Provides composition-based grid classes that respect annotation properties:
|
|
8
|
+
- Grid: Base class with text wrapping and annotation-driven column widths
|
|
9
|
+
- ScrollableGrid: Adds overflow windowing and scroll behavior
|
|
10
|
+
- SelectableRowGrid: Adds row/cell selection behavior
|
|
11
|
+
|
|
12
|
+
These classes are designed to work with annotation.json properties:
|
|
13
|
+
- firstRowHeader: Header row from base image (don't render)
|
|
14
|
+
- lastColScroll: Last column reserved for vertical scrollbar
|
|
15
|
+
- lastRowScroll: Last row reserved for horizontal scrollbar
|
|
16
|
+
- scrollable: Whether grid supports scrolling
|
|
17
|
+
- selectable: Whether grid supports row selection
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Sequence
|
|
24
|
+
|
|
25
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
LINE_HEIGHT_DEFAULT = 14
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def wrap_text(
|
|
32
|
+
text: str,
|
|
33
|
+
font: ImageFont.FreeTypeFont,
|
|
34
|
+
max_width: int,
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
"""Wrap text to fit within max_width, returning list of lines.
|
|
37
|
+
|
|
38
|
+
Handles multi-line input (splits on newlines) and word wrapping.
|
|
39
|
+
"""
|
|
40
|
+
if not text:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
lines: list[str] = []
|
|
44
|
+
for paragraph in str(text).split("\n"):
|
|
45
|
+
if not paragraph:
|
|
46
|
+
lines.append("")
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
words = paragraph.split(" ")
|
|
50
|
+
current_line = ""
|
|
51
|
+
|
|
52
|
+
for word in words:
|
|
53
|
+
test_line = f"{current_line} {word}".strip()
|
|
54
|
+
bbox = font.getbbox(test_line)
|
|
55
|
+
width = bbox[2] - bbox[0]
|
|
56
|
+
|
|
57
|
+
if width <= max_width:
|
|
58
|
+
current_line = test_line
|
|
59
|
+
else:
|
|
60
|
+
if current_line:
|
|
61
|
+
lines.append(current_line)
|
|
62
|
+
current_line = word
|
|
63
|
+
|
|
64
|
+
if current_line:
|
|
65
|
+
lines.append(current_line)
|
|
66
|
+
|
|
67
|
+
return lines if lines else [""]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ColumnDef:
|
|
72
|
+
"""Column definition for data grid."""
|
|
73
|
+
|
|
74
|
+
id: str
|
|
75
|
+
"""Column identifier (matches row dict keys)."""
|
|
76
|
+
|
|
77
|
+
label: str
|
|
78
|
+
"""Display label for header."""
|
|
79
|
+
|
|
80
|
+
width_pct: float
|
|
81
|
+
"""Column width as percentage (0.0-1.0) of grid width."""
|
|
82
|
+
|
|
83
|
+
align: str = "left"
|
|
84
|
+
"""Text alignment: 'left', 'right', or 'center'."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class GridGeometry:
|
|
89
|
+
"""Geometry from annotation bbox and properties."""
|
|
90
|
+
|
|
91
|
+
x: int
|
|
92
|
+
"""X position of grid top-left."""
|
|
93
|
+
|
|
94
|
+
y: int
|
|
95
|
+
"""Y position of grid top-left."""
|
|
96
|
+
|
|
97
|
+
width: int
|
|
98
|
+
"""Total width of grid area."""
|
|
99
|
+
|
|
100
|
+
height: int
|
|
101
|
+
"""Total height of grid area."""
|
|
102
|
+
|
|
103
|
+
row_heights: list[float] = field(default_factory=list)
|
|
104
|
+
"""Row heights as percentages (from annotation)."""
|
|
105
|
+
|
|
106
|
+
col_widths: list[float] = field(default_factory=list)
|
|
107
|
+
"""Column widths as percentages (from annotation)."""
|
|
108
|
+
|
|
109
|
+
first_row_header: bool = False
|
|
110
|
+
"""If True, first row is header from base image (don't render content)."""
|
|
111
|
+
|
|
112
|
+
last_col_scroll: bool = False
|
|
113
|
+
"""If True, last column is reserved for vertical scrollbar."""
|
|
114
|
+
|
|
115
|
+
last_row_scroll: bool = False
|
|
116
|
+
"""If True, last row is reserved for horizontal scrollbar."""
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def header_height(self) -> int:
|
|
120
|
+
"""Height of header row in pixels."""
|
|
121
|
+
if not self.first_row_header or not self.row_heights:
|
|
122
|
+
return 0
|
|
123
|
+
return int(self.height * self.row_heights[0])
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def scroll_col_width(self) -> int:
|
|
127
|
+
"""Width of scroll column in pixels."""
|
|
128
|
+
if not self.last_col_scroll or not self.col_widths:
|
|
129
|
+
return 0
|
|
130
|
+
return int(self.width * self.col_widths[-1])
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def scroll_row_height(self) -> int:
|
|
134
|
+
"""Height of scroll row in pixels.
|
|
135
|
+
|
|
136
|
+
If last_row_scroll is True and there are more than 2 row_heights,
|
|
137
|
+
use the last row_height. Otherwise use a fixed 15px for the scrollbar.
|
|
138
|
+
"""
|
|
139
|
+
if not self.last_row_scroll:
|
|
140
|
+
return 0
|
|
141
|
+
# Only use row_heights[-1] if there are 3+ entries (header, content, scroll)
|
|
142
|
+
if len(self.row_heights) > 2:
|
|
143
|
+
return int(self.height * self.row_heights[-1])
|
|
144
|
+
# Otherwise use a fixed scrollbar height
|
|
145
|
+
return 15
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def content_x(self) -> int:
|
|
149
|
+
"""X start of content area."""
|
|
150
|
+
return self.x
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def content_y(self) -> int:
|
|
154
|
+
"""Y start of content area (after header)."""
|
|
155
|
+
return self.y + self.header_height
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def content_width(self) -> int:
|
|
159
|
+
"""Width available for data columns (excluding scroll column)."""
|
|
160
|
+
return self.width - self.scroll_col_width
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def content_height(self) -> int:
|
|
164
|
+
"""Height available for data rows (excluding header and scroll row)."""
|
|
165
|
+
return self.height - self.header_height - self.scroll_row_height
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def data_col_count(self) -> int:
|
|
169
|
+
"""Number of data columns (excluding scroll column)."""
|
|
170
|
+
count = len(self.col_widths)
|
|
171
|
+
if self.last_col_scroll:
|
|
172
|
+
count -= 1
|
|
173
|
+
return max(0, count)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def bounds(self) -> tuple[int, int, int, int]:
|
|
177
|
+
"""Full grid bounds as (x, y, width, height)."""
|
|
178
|
+
return (self.x, self.y, self.width, self.height)
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def content_bounds(self) -> tuple[int, int, int, int]:
|
|
182
|
+
"""Content area bounds as (x, y, width, height)."""
|
|
183
|
+
return (self.content_x, self.content_y, self.content_width, self.content_height)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def center(self) -> tuple[int, int]:
|
|
187
|
+
"""Center point of the content area."""
|
|
188
|
+
return (
|
|
189
|
+
self.content_x + self.content_width // 2,
|
|
190
|
+
self.content_y + self.content_height // 2,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class RowLayout:
|
|
196
|
+
"""Layout information for a rendered row."""
|
|
197
|
+
|
|
198
|
+
height: int
|
|
199
|
+
"""Calculated height based on wrapped content."""
|
|
200
|
+
|
|
201
|
+
wrapped_cells: list[list[str]]
|
|
202
|
+
"""Wrapped text lines per column."""
|
|
203
|
+
|
|
204
|
+
data: dict[str, Any]
|
|
205
|
+
"""Original row data."""
|
|
206
|
+
|
|
207
|
+
y_offset: int = 0
|
|
208
|
+
"""Y offset from content start."""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Grid:
|
|
212
|
+
"""Base grid class with text wrapping and annotation-driven columns.
|
|
213
|
+
|
|
214
|
+
Handles:
|
|
215
|
+
- Row sizing based on content (text wrapping)
|
|
216
|
+
- Column widths from annotation percentages
|
|
217
|
+
- First row header (preserved from base image)
|
|
218
|
+
|
|
219
|
+
Usage:
|
|
220
|
+
geometry = GridGeometry(
|
|
221
|
+
x=10, y=100, width=800, height=400,
|
|
222
|
+
col_widths=[0.15, 0.2, 0.3, 0.35],
|
|
223
|
+
row_heights=[0.05, 0.95],
|
|
224
|
+
first_row_header=True,
|
|
225
|
+
)
|
|
226
|
+
columns = [
|
|
227
|
+
ColumnDef(id="date", label="Date", width_pct=0.15),
|
|
228
|
+
ColumnDef(id="name", label="Name", width_pct=0.2),
|
|
229
|
+
...
|
|
230
|
+
]
|
|
231
|
+
grid = Grid(geometry, columns, font)
|
|
232
|
+
rows = grid.render_rows(data)
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
geometry: GridGeometry,
|
|
238
|
+
columns: Sequence[ColumnDef],
|
|
239
|
+
font: ImageFont.FreeTypeFont,
|
|
240
|
+
cell_padding: int = 2,
|
|
241
|
+
line_height: int = LINE_HEIGHT_DEFAULT,
|
|
242
|
+
text_color: tuple[int, int, int] = (0, 0, 0),
|
|
243
|
+
):
|
|
244
|
+
"""Initialize grid.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
geometry: Grid positioning and sizing from annotation.
|
|
248
|
+
columns: Column definitions (should match annotation col_widths).
|
|
249
|
+
font: Font for rendering text.
|
|
250
|
+
cell_padding: Padding inside each cell.
|
|
251
|
+
line_height: Height per line of text.
|
|
252
|
+
text_color: Color for text rendering.
|
|
253
|
+
"""
|
|
254
|
+
self.geometry = geometry
|
|
255
|
+
self.columns = list(columns)[:geometry.data_col_count]
|
|
256
|
+
self.font = font
|
|
257
|
+
self.cell_padding = cell_padding
|
|
258
|
+
self.line_height = line_height
|
|
259
|
+
self.text_color = text_color
|
|
260
|
+
|
|
261
|
+
# Compute column widths in pixels
|
|
262
|
+
self._col_widths_px = self._compute_column_widths()
|
|
263
|
+
|
|
264
|
+
def _compute_column_widths(self) -> list[int]:
|
|
265
|
+
"""Compute column widths in pixels from percentages."""
|
|
266
|
+
widths: list[int] = []
|
|
267
|
+
for i in range(self.geometry.data_col_count):
|
|
268
|
+
if i < len(self.geometry.col_widths):
|
|
269
|
+
widths.append(int(self.geometry.width * self.geometry.col_widths[i]))
|
|
270
|
+
elif i < len(self.columns):
|
|
271
|
+
widths.append(int(self.geometry.width * self.columns[i].width_pct))
|
|
272
|
+
else:
|
|
273
|
+
widths.append(50) # fallback
|
|
274
|
+
return widths
|
|
275
|
+
|
|
276
|
+
def _compute_row_layout(self, row_data: dict[str, Any]) -> RowLayout:
|
|
277
|
+
"""Compute layout for a single row with text wrapping."""
|
|
278
|
+
wrapped_cells: list[list[str]] = []
|
|
279
|
+
max_lines = 1
|
|
280
|
+
|
|
281
|
+
for col_idx, col in enumerate(self.columns):
|
|
282
|
+
if col_idx >= len(self._col_widths_px):
|
|
283
|
+
wrapped_cells.append([])
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
value = str(row_data.get(col.id, ""))
|
|
287
|
+
col_width = self._col_widths_px[col_idx] - self.cell_padding * 2
|
|
288
|
+
lines = wrap_text(value, self.font, max(col_width, 10))
|
|
289
|
+
wrapped_cells.append(lines)
|
|
290
|
+
max_lines = max(max_lines, len(lines))
|
|
291
|
+
|
|
292
|
+
height = max_lines * self.line_height + self.cell_padding
|
|
293
|
+
return RowLayout(height=height, wrapped_cells=wrapped_cells, data=row_data)
|
|
294
|
+
|
|
295
|
+
def compute_layouts(self, rows: Sequence[dict[str, Any]]) -> list[RowLayout]:
|
|
296
|
+
"""Compute layouts for all rows.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
rows: List of row data dicts.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
List of RowLayout with heights and wrapped text.
|
|
303
|
+
"""
|
|
304
|
+
layouts: list[RowLayout] = []
|
|
305
|
+
y_offset = 0
|
|
306
|
+
|
|
307
|
+
for row_data in rows:
|
|
308
|
+
layout = self._compute_row_layout(row_data)
|
|
309
|
+
layout.y_offset = y_offset
|
|
310
|
+
layouts.append(layout)
|
|
311
|
+
y_offset += layout.height
|
|
312
|
+
|
|
313
|
+
return layouts
|
|
314
|
+
|
|
315
|
+
def total_content_height(self, layouts: Sequence[RowLayout]) -> int:
|
|
316
|
+
"""Get total height of all rows."""
|
|
317
|
+
if not layouts:
|
|
318
|
+
return 0
|
|
319
|
+
return sum(layout.height for layout in layouts)
|
|
320
|
+
|
|
321
|
+
def render_rows(
|
|
322
|
+
self,
|
|
323
|
+
draw: ImageDraw.ImageDraw,
|
|
324
|
+
layouts: Sequence[RowLayout],
|
|
325
|
+
start_y: int | None = None,
|
|
326
|
+
max_y: int | None = None,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Render rows to an image draw context.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
draw: PIL ImageDraw context.
|
|
332
|
+
layouts: Pre-computed row layouts.
|
|
333
|
+
start_y: Y position to start rendering (default: content_y).
|
|
334
|
+
max_y: Maximum Y position (default: bottom of content area).
|
|
335
|
+
"""
|
|
336
|
+
if start_y is None:
|
|
337
|
+
start_y = self.geometry.content_y
|
|
338
|
+
if max_y is None:
|
|
339
|
+
max_y = self.geometry.y + self.geometry.height - self.geometry.scroll_row_height
|
|
340
|
+
|
|
341
|
+
y = start_y
|
|
342
|
+
for layout in layouts:
|
|
343
|
+
if y >= max_y:
|
|
344
|
+
break
|
|
345
|
+
|
|
346
|
+
x = self.geometry.content_x
|
|
347
|
+
for col_idx, col in enumerate(self.columns):
|
|
348
|
+
if col_idx >= len(layout.wrapped_cells):
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
lines = layout.wrapped_cells[col_idx]
|
|
352
|
+
line_y = y
|
|
353
|
+
|
|
354
|
+
for line in lines:
|
|
355
|
+
if line_y >= max_y:
|
|
356
|
+
break
|
|
357
|
+
draw.text(
|
|
358
|
+
(x + self.cell_padding, line_y),
|
|
359
|
+
line,
|
|
360
|
+
font=self.font,
|
|
361
|
+
fill=self.text_color,
|
|
362
|
+
)
|
|
363
|
+
line_y += self.line_height
|
|
364
|
+
|
|
365
|
+
if col_idx < len(self._col_widths_px):
|
|
366
|
+
x += self._col_widths_px[col_idx]
|
|
367
|
+
|
|
368
|
+
y += layout.height
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass
|
|
372
|
+
class ScrollState:
|
|
373
|
+
"""Scroll position state."""
|
|
374
|
+
|
|
375
|
+
offset: int = 0
|
|
376
|
+
"""Pixel offset from top of content."""
|
|
377
|
+
|
|
378
|
+
more_above: bool = False
|
|
379
|
+
"""Whether more content exists above viewport."""
|
|
380
|
+
|
|
381
|
+
more_below: bool = True
|
|
382
|
+
"""Whether more content exists below viewport."""
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class ScrollableGrid(Grid):
|
|
386
|
+
"""Grid with scrolling/overflow support.
|
|
387
|
+
|
|
388
|
+
Extends Grid with:
|
|
389
|
+
- Overflow windowing (only render visible rows)
|
|
390
|
+
- Scroll position tracking
|
|
391
|
+
- Vertical scrollbar in last column
|
|
392
|
+
- Horizontal scrollbar in last row (future)
|
|
393
|
+
|
|
394
|
+
Usage:
|
|
395
|
+
grid = ScrollableGrid(geometry, columns, font)
|
|
396
|
+
layouts = grid.compute_layouts(all_rows)
|
|
397
|
+
visible = grid.get_visible_layouts(layouts, scroll_state)
|
|
398
|
+
grid.render_rows(draw, visible)
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(
|
|
402
|
+
self,
|
|
403
|
+
geometry: GridGeometry,
|
|
404
|
+
columns: Sequence[ColumnDef],
|
|
405
|
+
font: ImageFont.FreeTypeFont,
|
|
406
|
+
cell_padding: int = 2,
|
|
407
|
+
line_height: int = LINE_HEIGHT_DEFAULT,
|
|
408
|
+
text_color: tuple[int, int, int] = (0, 0, 0),
|
|
409
|
+
scrollbar_track_color: tuple[int, int, int] = (240, 240, 240),
|
|
410
|
+
scrollbar_thumb_color: tuple[int, int, int] = (180, 180, 180),
|
|
411
|
+
):
|
|
412
|
+
"""Initialize scrollable grid with scrollbar colors."""
|
|
413
|
+
super().__init__(geometry, columns, font, cell_padding, line_height, text_color)
|
|
414
|
+
self.scrollbar_track_color = scrollbar_track_color
|
|
415
|
+
self.scrollbar_thumb_color = scrollbar_thumb_color
|
|
416
|
+
|
|
417
|
+
def render_scrollbar(
|
|
418
|
+
self,
|
|
419
|
+
draw: ImageDraw.ImageDraw,
|
|
420
|
+
total_content_height: int,
|
|
421
|
+
scroll_state: ScrollState,
|
|
422
|
+
) -> None:
|
|
423
|
+
"""Render vertical scrollbar in the last column.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
draw: PIL ImageDraw context.
|
|
427
|
+
total_content_height: Total height of all content.
|
|
428
|
+
scroll_state: Current scroll position.
|
|
429
|
+
"""
|
|
430
|
+
if not self.geometry.last_col_scroll:
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
geom = self.geometry
|
|
434
|
+
scroll_col_width = geom.scroll_col_width
|
|
435
|
+
if scroll_col_width <= 0:
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
# Scrollbar position (in the last column, content area only)
|
|
439
|
+
track_x = geom.x + geom.width - scroll_col_width
|
|
440
|
+
track_y = geom.content_y
|
|
441
|
+
track_height = geom.content_height
|
|
442
|
+
track_width = scroll_col_width
|
|
443
|
+
|
|
444
|
+
# Draw track background
|
|
445
|
+
draw.rectangle(
|
|
446
|
+
[(track_x, track_y), (track_x + track_width, track_y + track_height)],
|
|
447
|
+
fill=self.scrollbar_track_color,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Calculate thumb size and position
|
|
451
|
+
viewport_height = geom.content_height
|
|
452
|
+
if total_content_height <= viewport_height or total_content_height <= 0:
|
|
453
|
+
# No scrolling needed - don't show thumb, just track
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
# Thumb height proportional to visible ratio
|
|
457
|
+
thumb_height = max(20, int(track_height * (viewport_height / total_content_height)))
|
|
458
|
+
|
|
459
|
+
# Thumb position based on scroll offset
|
|
460
|
+
max_offset = total_content_height - viewport_height
|
|
461
|
+
scroll_ratio = scroll_state.offset / max_offset if max_offset > 0 else 0
|
|
462
|
+
thumb_travel = track_height - thumb_height
|
|
463
|
+
thumb_y = track_y + int(scroll_ratio * thumb_travel)
|
|
464
|
+
|
|
465
|
+
# Draw thumb (centered in track)
|
|
466
|
+
thumb_width = max(4, scroll_col_width - 4)
|
|
467
|
+
thumb_x = track_x + (scroll_col_width - thumb_width) // 2
|
|
468
|
+
|
|
469
|
+
draw.rectangle(
|
|
470
|
+
[(thumb_x, thumb_y), (thumb_x + thumb_width, thumb_y + thumb_height)],
|
|
471
|
+
fill=self.scrollbar_thumb_color,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def render_horizontal_scrollbar(
|
|
475
|
+
self,
|
|
476
|
+
draw: ImageDraw.ImageDraw,
|
|
477
|
+
total_content_width: int | None = None,
|
|
478
|
+
scroll_offset_x: int = 0,
|
|
479
|
+
) -> None:
|
|
480
|
+
"""Render horizontal scrollbar in the last row.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
draw: PIL ImageDraw context.
|
|
484
|
+
total_content_width: Total width of all content (None = no thumb).
|
|
485
|
+
scroll_offset_x: Current horizontal scroll offset.
|
|
486
|
+
"""
|
|
487
|
+
if not self.geometry.last_row_scroll:
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
geom = self.geometry
|
|
491
|
+
scroll_row_height = geom.scroll_row_height
|
|
492
|
+
if scroll_row_height <= 0:
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
# Scrollbar position (in the last row, full width minus scroll column)
|
|
496
|
+
track_x = geom.x
|
|
497
|
+
track_y = geom.y + geom.height - scroll_row_height
|
|
498
|
+
track_width = geom.content_width
|
|
499
|
+
track_height = scroll_row_height
|
|
500
|
+
|
|
501
|
+
# Draw track background
|
|
502
|
+
draw.rectangle(
|
|
503
|
+
[(track_x, track_y), (track_x + track_width, track_y + track_height)],
|
|
504
|
+
fill=self.scrollbar_track_color,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Calculate thumb if we have content width info
|
|
508
|
+
viewport_width = geom.content_width
|
|
509
|
+
if total_content_width is None or total_content_width <= viewport_width:
|
|
510
|
+
# No horizontal scrolling needed - just show track
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
# Thumb width proportional to visible ratio
|
|
514
|
+
thumb_width = max(20, int(track_width * (viewport_width / total_content_width)))
|
|
515
|
+
|
|
516
|
+
# Thumb position based on scroll offset
|
|
517
|
+
max_offset = total_content_width - viewport_width
|
|
518
|
+
scroll_ratio = scroll_offset_x / max_offset if max_offset > 0 else 0
|
|
519
|
+
thumb_travel = track_width - thumb_width
|
|
520
|
+
thumb_x = track_x + int(scroll_ratio * thumb_travel)
|
|
521
|
+
|
|
522
|
+
# Draw thumb (centered in track)
|
|
523
|
+
thumb_height = max(4, scroll_row_height - 4)
|
|
524
|
+
thumb_y = track_y + (scroll_row_height - thumb_height) // 2
|
|
525
|
+
|
|
526
|
+
draw.rectangle(
|
|
527
|
+
[(thumb_x, thumb_y), (thumb_x + thumb_width, thumb_y + thumb_height)],
|
|
528
|
+
fill=self.scrollbar_thumb_color,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
def get_visible_layouts(
|
|
532
|
+
self,
|
|
533
|
+
layouts: Sequence[RowLayout],
|
|
534
|
+
scroll_state: ScrollState,
|
|
535
|
+
) -> list[RowLayout]:
|
|
536
|
+
"""Get layouts visible in current viewport.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
layouts: All row layouts.
|
|
540
|
+
scroll_state: Current scroll position.
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
List of visible RowLayout objects.
|
|
544
|
+
"""
|
|
545
|
+
if not layouts:
|
|
546
|
+
return []
|
|
547
|
+
|
|
548
|
+
visible: list[RowLayout] = []
|
|
549
|
+
viewport_top = scroll_state.offset
|
|
550
|
+
viewport_bottom = viewport_top + self.geometry.content_height
|
|
551
|
+
|
|
552
|
+
for layout in layouts:
|
|
553
|
+
row_top = layout.y_offset
|
|
554
|
+
row_bottom = row_top + layout.height
|
|
555
|
+
|
|
556
|
+
# Row is visible if it overlaps viewport
|
|
557
|
+
if row_bottom > viewport_top and row_top < viewport_bottom:
|
|
558
|
+
visible.append(layout)
|
|
559
|
+
|
|
560
|
+
return visible
|
|
561
|
+
|
|
562
|
+
def render_visible(
|
|
563
|
+
self,
|
|
564
|
+
draw: ImageDraw.ImageDraw,
|
|
565
|
+
layouts: Sequence[RowLayout],
|
|
566
|
+
scroll_state: ScrollState,
|
|
567
|
+
) -> None:
|
|
568
|
+
"""Render only visible rows with scroll offset applied.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
draw: PIL ImageDraw context.
|
|
572
|
+
layouts: All row layouts.
|
|
573
|
+
scroll_state: Current scroll position.
|
|
574
|
+
"""
|
|
575
|
+
visible = self.get_visible_layouts(layouts, scroll_state)
|
|
576
|
+
if not visible:
|
|
577
|
+
return
|
|
578
|
+
|
|
579
|
+
max_y = self.geometry.y + self.geometry.height - self.geometry.scroll_row_height
|
|
580
|
+
|
|
581
|
+
for layout in visible:
|
|
582
|
+
# Calculate actual Y position with scroll offset
|
|
583
|
+
y = self.geometry.content_y + layout.y_offset - scroll_state.offset
|
|
584
|
+
if y >= max_y:
|
|
585
|
+
break
|
|
586
|
+
|
|
587
|
+
x = self.geometry.content_x
|
|
588
|
+
for col_idx, col in enumerate(self.columns):
|
|
589
|
+
if col_idx >= len(layout.wrapped_cells):
|
|
590
|
+
break
|
|
591
|
+
|
|
592
|
+
lines = layout.wrapped_cells[col_idx]
|
|
593
|
+
line_y = y
|
|
594
|
+
|
|
595
|
+
for line in lines:
|
|
596
|
+
if line_y >= max_y:
|
|
597
|
+
break
|
|
598
|
+
if line_y >= self.geometry.content_y: # Don't render above header
|
|
599
|
+
draw.text(
|
|
600
|
+
(x + self.cell_padding, line_y),
|
|
601
|
+
line,
|
|
602
|
+
font=self.font,
|
|
603
|
+
fill=self.text_color,
|
|
604
|
+
)
|
|
605
|
+
line_y += self.line_height
|
|
606
|
+
|
|
607
|
+
if col_idx < len(self._col_widths_px):
|
|
608
|
+
x += self._col_widths_px[col_idx]
|
|
609
|
+
|
|
610
|
+
# Render vertical scrollbar
|
|
611
|
+
total_height = self.total_content_height(layouts)
|
|
612
|
+
self.render_scrollbar(draw, total_height, scroll_state)
|
|
613
|
+
|
|
614
|
+
# Render horizontal scrollbar (just track, no thumb since we don't track horizontal scroll)
|
|
615
|
+
self.render_horizontal_scrollbar(draw)
|
|
616
|
+
|
|
617
|
+
def compute_scroll_state(
|
|
618
|
+
self,
|
|
619
|
+
layouts: Sequence[RowLayout],
|
|
620
|
+
offset: int = 0,
|
|
621
|
+
) -> ScrollState:
|
|
622
|
+
"""Compute scroll state for given offset.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
layouts: All row layouts.
|
|
626
|
+
offset: Desired scroll offset.
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
ScrollState with adjusted offset and flags.
|
|
630
|
+
"""
|
|
631
|
+
total_height = self.total_content_height(layouts)
|
|
632
|
+
viewport_height = self.geometry.content_height
|
|
633
|
+
max_offset = max(0, total_height - viewport_height)
|
|
634
|
+
|
|
635
|
+
# Clamp offset
|
|
636
|
+
offset = max(0, min(offset, max_offset))
|
|
637
|
+
|
|
638
|
+
return ScrollState(
|
|
639
|
+
offset=offset,
|
|
640
|
+
more_above=offset > 0,
|
|
641
|
+
more_below=offset < max_offset,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
@dataclass
|
|
646
|
+
class SelectionState:
|
|
647
|
+
"""Selection state for rows/cells."""
|
|
648
|
+
|
|
649
|
+
selected_row: int | None = None
|
|
650
|
+
"""Currently selected row index (None = no selection)."""
|
|
651
|
+
|
|
652
|
+
selected_cell: tuple[int, int] | None = None
|
|
653
|
+
"""Currently selected cell as (row, col) (None = no selection)."""
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
class SelectableRowGrid(ScrollableGrid):
|
|
657
|
+
"""Grid with row and cell selection support.
|
|
658
|
+
|
|
659
|
+
Extends ScrollableGrid with:
|
|
660
|
+
- Row selection highlighting
|
|
661
|
+
- Cell selection highlighting
|
|
662
|
+
- Selection state tracking
|
|
663
|
+
|
|
664
|
+
Usage:
|
|
665
|
+
grid = SelectableRowGrid(geometry, columns, font)
|
|
666
|
+
grid.render_visible(draw, layouts, scroll_state, selection_state)
|
|
667
|
+
"""
|
|
668
|
+
|
|
669
|
+
def __init__(
|
|
670
|
+
self,
|
|
671
|
+
geometry: GridGeometry,
|
|
672
|
+
columns: Sequence[ColumnDef],
|
|
673
|
+
font: ImageFont.FreeTypeFont,
|
|
674
|
+
cell_padding: int = 2,
|
|
675
|
+
line_height: int = LINE_HEIGHT_DEFAULT,
|
|
676
|
+
text_color: tuple[int, int, int] = (0, 0, 0),
|
|
677
|
+
selection_color: tuple[int, int, int] = (200, 220, 255),
|
|
678
|
+
):
|
|
679
|
+
"""Initialize selectable grid.
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
selection_color: Background color for selected rows.
|
|
683
|
+
"""
|
|
684
|
+
super().__init__(
|
|
685
|
+
geometry, columns, font, cell_padding, line_height, text_color
|
|
686
|
+
)
|
|
687
|
+
self.selection_color = selection_color
|
|
688
|
+
|
|
689
|
+
def render_with_selection(
|
|
690
|
+
self,
|
|
691
|
+
draw: ImageDraw.ImageDraw,
|
|
692
|
+
layouts: Sequence[RowLayout],
|
|
693
|
+
scroll_state: ScrollState,
|
|
694
|
+
selection: SelectionState | None = None,
|
|
695
|
+
) -> None:
|
|
696
|
+
"""Render visible rows with selection highlighting.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
draw: PIL ImageDraw context.
|
|
700
|
+
layouts: All row layouts.
|
|
701
|
+
scroll_state: Current scroll position.
|
|
702
|
+
selection: Current selection state.
|
|
703
|
+
"""
|
|
704
|
+
visible = self.get_visible_layouts(layouts, scroll_state)
|
|
705
|
+
if not visible:
|
|
706
|
+
return
|
|
707
|
+
|
|
708
|
+
max_y = self.geometry.y + self.geometry.height - self.geometry.scroll_row_height
|
|
709
|
+
|
|
710
|
+
for row_idx, layout in enumerate(visible):
|
|
711
|
+
# Calculate actual Y position with scroll offset
|
|
712
|
+
y = self.geometry.content_y + layout.y_offset - scroll_state.offset
|
|
713
|
+
if y >= max_y:
|
|
714
|
+
break
|
|
715
|
+
|
|
716
|
+
# Draw selection background if selected
|
|
717
|
+
if selection and selection.selected_row == row_idx:
|
|
718
|
+
draw.rectangle(
|
|
719
|
+
[
|
|
720
|
+
(self.geometry.content_x, max(y, self.geometry.content_y)),
|
|
721
|
+
(self.geometry.content_x + self.geometry.content_width, min(y + layout.height, max_y)),
|
|
722
|
+
],
|
|
723
|
+
fill=self.selection_color,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# Render row text
|
|
727
|
+
x = self.geometry.content_x
|
|
728
|
+
for col_idx, col in enumerate(self.columns):
|
|
729
|
+
if col_idx >= len(layout.wrapped_cells):
|
|
730
|
+
break
|
|
731
|
+
|
|
732
|
+
lines = layout.wrapped_cells[col_idx]
|
|
733
|
+
line_y = y
|
|
734
|
+
|
|
735
|
+
for line in lines:
|
|
736
|
+
if line_y >= max_y:
|
|
737
|
+
break
|
|
738
|
+
if line_y >= self.geometry.content_y:
|
|
739
|
+
draw.text(
|
|
740
|
+
(x + self.cell_padding, line_y),
|
|
741
|
+
line,
|
|
742
|
+
font=self.font,
|
|
743
|
+
fill=self.text_color,
|
|
744
|
+
)
|
|
745
|
+
line_y += self.line_height
|
|
746
|
+
|
|
747
|
+
if col_idx < len(self._col_widths_px):
|
|
748
|
+
x += self._col_widths_px[col_idx]
|
|
749
|
+
|
|
750
|
+
def get_row_bounds(
|
|
751
|
+
self,
|
|
752
|
+
row_idx: int,
|
|
753
|
+
layouts: Sequence[RowLayout],
|
|
754
|
+
scroll_state: ScrollState,
|
|
755
|
+
) -> tuple[int, int, int, int] | None:
|
|
756
|
+
"""Get bounds of a row in screen coordinates.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
row_idx: Row index.
|
|
760
|
+
layouts: All row layouts.
|
|
761
|
+
scroll_state: Current scroll position.
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
(x, y, width, height) or None if not visible.
|
|
765
|
+
"""
|
|
766
|
+
if row_idx < 0 or row_idx >= len(layouts):
|
|
767
|
+
return None
|
|
768
|
+
|
|
769
|
+
layout = layouts[row_idx]
|
|
770
|
+
y = self.geometry.content_y + layout.y_offset - scroll_state.offset
|
|
771
|
+
|
|
772
|
+
# Check if visible
|
|
773
|
+
max_y = self.geometry.y + self.geometry.height - self.geometry.scroll_row_height
|
|
774
|
+
if y + layout.height <= self.geometry.content_y or y >= max_y:
|
|
775
|
+
return None
|
|
776
|
+
|
|
777
|
+
return (
|
|
778
|
+
self.geometry.content_x,
|
|
779
|
+
max(y, self.geometry.content_y),
|
|
780
|
+
self.geometry.content_width,
|
|
781
|
+
layout.height,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
def get_row_center(
|
|
785
|
+
self,
|
|
786
|
+
row_idx: int,
|
|
787
|
+
layouts: Sequence[RowLayout],
|
|
788
|
+
scroll_state: ScrollState,
|
|
789
|
+
) -> tuple[int, int] | None:
|
|
790
|
+
"""Get center point of a row.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
row_idx: Row index.
|
|
794
|
+
layouts: All row layouts.
|
|
795
|
+
scroll_state: Current scroll position.
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
(x, y) center or None if not visible.
|
|
799
|
+
"""
|
|
800
|
+
bounds = self.get_row_bounds(row_idx, layouts, scroll_state)
|
|
801
|
+
if bounds is None:
|
|
802
|
+
return None
|
|
803
|
+
x, y, w, h = bounds
|
|
804
|
+
return (x + w // 2, y + h // 2)
|