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.
Files changed (69) hide show
  1. cudag/__init__.py +334 -0
  2. cudag/annotation/__init__.py +77 -0
  3. cudag/annotation/codegen.py +648 -0
  4. cudag/annotation/config.py +545 -0
  5. cudag/annotation/loader.py +342 -0
  6. cudag/annotation/scaffold.py +121 -0
  7. cudag/annotation/transcription.py +296 -0
  8. cudag/cli/__init__.py +5 -0
  9. cudag/cli/main.py +315 -0
  10. cudag/cli/new.py +873 -0
  11. cudag/core/__init__.py +364 -0
  12. cudag/core/button.py +137 -0
  13. cudag/core/canvas.py +222 -0
  14. cudag/core/config.py +70 -0
  15. cudag/core/coords.py +233 -0
  16. cudag/core/data_grid.py +804 -0
  17. cudag/core/dataset.py +678 -0
  18. cudag/core/distribution.py +136 -0
  19. cudag/core/drawing.py +75 -0
  20. cudag/core/fonts.py +156 -0
  21. cudag/core/generator.py +163 -0
  22. cudag/core/grid.py +367 -0
  23. cudag/core/grounding_task.py +247 -0
  24. cudag/core/icon.py +207 -0
  25. cudag/core/iconlist_task.py +301 -0
  26. cudag/core/models.py +1251 -0
  27. cudag/core/random.py +130 -0
  28. cudag/core/renderer.py +190 -0
  29. cudag/core/screen.py +402 -0
  30. cudag/core/scroll_task.py +254 -0
  31. cudag/core/scrollable_grid.py +447 -0
  32. cudag/core/state.py +110 -0
  33. cudag/core/task.py +293 -0
  34. cudag/core/taskbar.py +350 -0
  35. cudag/core/text.py +212 -0
  36. cudag/core/utils.py +82 -0
  37. cudag/data/surnames.txt +5000 -0
  38. cudag/modal_apps/__init__.py +4 -0
  39. cudag/modal_apps/archive.py +103 -0
  40. cudag/modal_apps/extract.py +138 -0
  41. cudag/modal_apps/preprocess.py +529 -0
  42. cudag/modal_apps/upload.py +317 -0
  43. cudag/prompts/SYSTEM_PROMPT.txt +104 -0
  44. cudag/prompts/__init__.py +33 -0
  45. cudag/prompts/system.py +43 -0
  46. cudag/prompts/tools.py +382 -0
  47. cudag/py.typed +0 -0
  48. cudag/schemas/filesystem.json +90 -0
  49. cudag/schemas/test_record.schema.json +113 -0
  50. cudag/schemas/train_record.schema.json +90 -0
  51. cudag/server/__init__.py +21 -0
  52. cudag/server/app.py +232 -0
  53. cudag/server/services/__init__.py +9 -0
  54. cudag/server/services/generator.py +128 -0
  55. cudag/templates/scripts/archive.sh +35 -0
  56. cudag/templates/scripts/build.sh +13 -0
  57. cudag/templates/scripts/extract.sh +54 -0
  58. cudag/templates/scripts/generate.sh +116 -0
  59. cudag/templates/scripts/pre-commit.sh +44 -0
  60. cudag/templates/scripts/preprocess.sh +46 -0
  61. cudag/templates/scripts/upload.sh +63 -0
  62. cudag/templates/scripts/verify.py +428 -0
  63. cudag/validation/__init__.py +35 -0
  64. cudag/validation/validate.py +508 -0
  65. cudag-0.3.10.dist-info/METADATA +570 -0
  66. cudag-0.3.10.dist-info/RECORD +69 -0
  67. cudag-0.3.10.dist-info/WHEEL +4 -0
  68. cudag-0.3.10.dist-info/entry_points.txt +2 -0
  69. cudag-0.3.10.dist-info/licenses/LICENSE +66 -0
@@ -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)