argent-grid 0.2.0 → 0.3.0

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 (55) hide show
  1. package/AGENTS.md +70 -27
  2. package/e2e/advanced.spec.ts +1 -1
  3. package/e2e/benchmark.spec.ts +7 -7
  4. package/e2e/cell-renderers.spec.ts +152 -0
  5. package/e2e/debug-streaming.spec.ts +31 -0
  6. package/e2e/dnd.spec.ts +73 -0
  7. package/e2e/screenshots.spec.ts +1 -1
  8. package/e2e/visual.spec.ts +30 -9
  9. package/e2e/visual.spec.ts-snapshots/checkbox-renderer-mixed.png +0 -0
  10. package/e2e/visual.spec.ts-snapshots/debug.png +0 -0
  11. package/e2e/visual.spec.ts-snapshots/grid-column-group-headers.png +0 -0
  12. package/e2e/visual.spec.ts-snapshots/grid-default.png +0 -0
  13. package/e2e/visual.spec.ts-snapshots/grid-empty-state.png +0 -0
  14. package/e2e/visual.spec.ts-snapshots/grid-filter-popup.png +0 -0
  15. package/e2e/visual.spec.ts-snapshots/grid-scroll-borders.png +0 -0
  16. package/e2e/visual.spec.ts-snapshots/grid-sidebar-buttons.png +0 -0
  17. package/e2e/visual.spec.ts-snapshots/grid-text-filter.png +0 -0
  18. package/e2e/visual.spec.ts-snapshots/grid-with-selection.png +0 -0
  19. package/e2e/visual.spec.ts-snapshots/rating-renderer-varied.png +0 -0
  20. package/package.json +5 -5
  21. package/plan.md +30 -34
  22. package/src/lib/components/argent-grid.component.css +258 -549
  23. package/src/lib/components/argent-grid.component.html +272 -306
  24. package/src/lib/components/argent-grid.component.ts +585 -135
  25. package/src/lib/components/argent-grid.regressions.spec.ts +301 -0
  26. package/src/lib/components/argent-grid.selection.spec.ts +2 -2
  27. package/src/lib/components/set-filter/set-filter.component.spec.ts +191 -0
  28. package/src/lib/components/set-filter/set-filter.component.ts +7 -2
  29. package/src/lib/rendering/canvas-renderer.spec.ts +148 -1
  30. package/src/lib/rendering/canvas-renderer.ts +177 -286
  31. package/src/lib/rendering/render/cells.ts +122 -5
  32. package/src/lib/rendering/render/column-utils.ts +27 -5
  33. package/src/lib/rendering/render/hit-test.ts +6 -11
  34. package/src/lib/rendering/render/index.ts +15 -6
  35. package/src/lib/rendering/render/lines.ts +12 -6
  36. package/src/lib/rendering/render/primitives.ts +269 -7
  37. package/src/lib/rendering/render/types.ts +2 -1
  38. package/src/lib/rendering/render/walk.ts +39 -19
  39. package/src/lib/services/grid.service.spec.ts +76 -0
  40. package/src/lib/services/grid.service.ts +451 -114
  41. package/src/lib/themes/theme-quartz.ts +2 -2
  42. package/src/lib/types/ag-grid-types.ts +500 -0
  43. package/src/stories/Advanced.stories.ts +78 -17
  44. package/src/stories/ArgentGrid.stories.ts +50 -26
  45. package/src/stories/Benchmark.stories.ts +17 -15
  46. package/src/stories/CellRenderers.stories.ts +205 -31
  47. package/src/stories/Filtering.stories.ts +56 -16
  48. package/src/stories/Grouping.stories.ts +86 -13
  49. package/src/stories/Streaming.stories.ts +57 -0
  50. package/src/stories/Theming.stories.ts +23 -10
  51. package/src/stories/Tooltips.stories.ts +381 -0
  52. package/src/stories/benchmark-wrapper.component.ts +69 -29
  53. package/src/stories/story-utils.ts +88 -0
  54. package/src/stories/streaming-wrapper.component.ts +441 -0
  55. package/tsconfig.json +1 -0
@@ -1,41 +1,36 @@
1
1
  import { Column, GridApi, IRowNode } from '../types/ag-grid-types';
2
-
2
+ import { LiveDataHandler } from './live-data-handler';
3
3
  // Import new rendering modules from the index
4
4
  import {
5
- // Blitting
6
- BlitState,
7
5
  ColumnPrepResult,
8
- calculateBlit,
9
6
  // Theme
10
7
  DEFAULT_THEME,
11
- drawCheckbox,
8
+ drawCell,
12
9
  drawColumnLines,
13
- drawGroupIndicator,
14
10
  drawRangeSelectionBorder,
15
11
  // Lines
16
12
  drawRowLines,
17
- drawSparkline,
18
13
  // Types
19
14
  GridTheme,
15
+ getCellValue,
20
16
  getCenterColumnOffset,
21
17
  getColumnAtX,
22
18
  getColumnDef,
23
- getColumnX,
24
19
  getFontFromTheme,
25
20
  getFormattedValue,
26
21
  getPinnedWidths,
22
+ getPositionedColumns,
27
23
  getRowAtY,
28
24
  getValueByPath,
29
25
  getVisibleRowRange,
26
+ isColumnVisible,
30
27
  mergeTheme,
28
+ PositionedColumn,
31
29
  performHitTest,
32
30
  prepColumn,
33
- // Cells
34
- truncateText,
35
31
  walkRows,
36
32
  } from './render';
37
33
  import { DamageTracker } from './utils/damage-tracker';
38
- import { LiveDataHandler } from './live-data-handler';
39
34
 
40
35
  /**
41
36
  * CanvasRenderer - High-performance canvas rendering engine for ArgentGrid
@@ -55,7 +50,6 @@ export class CanvasRenderer<TData = any> {
55
50
  private canvas: HTMLCanvasElement;
56
51
  private ctx: CanvasRenderingContext2D;
57
52
  private gridApi: GridApi<TData>;
58
- private rowHeight: number;
59
53
  private scrollTop = 0;
60
54
  private scrollLeft = 0;
61
55
 
@@ -67,10 +61,13 @@ export class CanvasRenderer<TData = any> {
67
61
  }
68
62
 
69
63
  private animationFrameId: number | null = null;
70
- private renderPending = false;
64
+ // When a render is already in-flight and another is requested, coalesce it here
65
+ // so it fires immediately after the current frame completes rather than being dropped.
66
+ private nextRenderPending = false;
71
67
  private rowBuffer = 5;
72
68
  private viewportHeight = 0;
73
69
  private viewportWidth = 0;
70
+ private scrollbarWidth = 0;
74
71
 
75
72
  // Theme system
76
73
  private theme: GridTheme;
@@ -84,25 +81,18 @@ export class CanvasRenderer<TData = any> {
84
81
  // Damage tracking
85
82
  private damageTracker = new DamageTracker();
86
83
 
87
- // Blitting state
88
- private blitState = new BlitState();
89
-
90
84
  // Live data handling
91
85
  private liveDataHandler: LiveDataHandler<TData>;
92
86
 
93
87
  // Column prep results cache
94
88
  private columnPreps: Map<string, ColumnPrepResult<TData>> = new Map();
95
89
 
96
- /**
97
- * Column positions cache for O(1) lookup
98
- */
99
- private columnPositions: Map<string, number> = new Map();
100
-
101
90
  // Event listener references for cleanup
102
91
  private scrollListener?: (e: Event) => void;
103
92
  private resizeListener?: () => void;
104
93
  private mousedownListener?: (e: MouseEvent) => void;
105
94
  private mousemoveListener?: (e: MouseEvent) => void;
95
+ private mouseleaveListener?: (e: MouseEvent) => void;
106
96
  private clickListener?: (e: MouseEvent) => void;
107
97
  private dblclickListener?: (e: MouseEvent) => void;
108
98
  private mouseupListener?: (e: MouseEvent) => void;
@@ -123,7 +113,6 @@ export class CanvasRenderer<TData = any> {
123
113
  this.canvas = canvas;
124
114
  this.ctx = canvas.getContext('2d')!;
125
115
  this.gridApi = gridApi;
126
- this.rowHeight = rowHeight;
127
116
  this.theme = mergeTheme(DEFAULT_THEME, { rowHeight }, theme || {});
128
117
  this.liveDataHandler = new LiveDataHandler(gridApi);
129
118
 
@@ -135,7 +124,7 @@ export class CanvasRenderer<TData = any> {
135
124
  * Update the theme
136
125
  */
137
126
  setTheme(theme: Partial<GridTheme>): void {
138
- this.theme = mergeTheme(DEFAULT_THEME, { rowHeight: this.rowHeight }, theme);
127
+ this.theme = mergeTheme(DEFAULT_THEME, { rowHeight: this.theme.rowHeight }, theme);
139
128
  this.damageTracker.markAllDirty();
140
129
  this.scheduleRender();
141
130
  }
@@ -205,7 +194,7 @@ export class CanvasRenderer<TData = any> {
205
194
  * @performance O(1) - Constant time, regardless of total row count
206
195
  */
207
196
  getRowAtY(y: number): number {
208
- return getRowAtY(y, this.rowHeight, this.scrollTop);
197
+ return getRowAtY(y, this.theme.rowHeight, this.scrollTop);
209
198
  }
210
199
 
211
200
  /**
@@ -277,6 +266,10 @@ export class CanvasRenderer<TData = any> {
277
266
 
278
267
  this.canvas.addEventListener('mousedown', this.mousedownListener);
279
268
  this.canvas.addEventListener('mousemove', this.mousemoveListener);
269
+ this.mouseleaveListener = () => {
270
+ this.canvas.style.cursor = '';
271
+ };
272
+ this.canvas.addEventListener('mouseleave', this.mouseleaveListener);
280
273
  this.canvas.addEventListener('click', this.clickListener);
281
274
  this.canvas.addEventListener('dblclick', this.dblclickListener);
282
275
  this.canvas.addEventListener('mouseup', this.mouseupListener);
@@ -293,38 +286,23 @@ export class CanvasRenderer<TData = any> {
293
286
  const container = this.canvas.parentElement;
294
287
  if (!container) return;
295
288
 
296
- const _oldScrollTop = this.scrollTop;
297
- const _oldScrollLeft = this.scrollLeft;
298
-
299
289
  this.scrollTop = container.scrollTop;
300
290
  this.scrollLeft = container.scrollLeft;
301
-
302
- // Update blit state
303
- const lastScroll = this.blitState.updateScroll(this.scrollLeft, this.scrollTop);
304
-
305
- // Check if we should blit
306
- const { left, right } = getPinnedWidths(this.getVisibleColumns());
307
- const blitResult = calculateBlit(
308
- { x: this.scrollLeft, y: this.scrollTop },
309
- lastScroll,
310
- { width: this.viewportWidth, height: this.viewportHeight },
311
- { left, right }
312
- );
313
-
314
- if (blitResult.canBlit && this.blitState.hasLastFrame()) {
315
- // Blitting is possible - the render will copy from last frame
316
- this.damageTracker.markAllDirty(); // For now, still do full redraw but with blit
317
- } else {
318
- // Full redraw needed
319
- this.damageTracker.markAllDirty();
320
- }
321
-
291
+ this.damageTracker.markAllDirty();
322
292
  this.scheduleRender();
323
293
  }
324
294
 
325
- setViewportDimensions(width: number, height: number): void {
295
+ setViewportDimensions(width: number, height: number, scrollbarWidth: number = 0): void {
296
+ if (
297
+ Math.abs(this.viewportWidth - width) < 1 &&
298
+ Math.abs(this.viewportHeight - height) < 1 &&
299
+ this.scrollbarWidth === scrollbarWidth
300
+ ) {
301
+ return;
302
+ }
326
303
  this.viewportWidth = width;
327
304
  this.viewportHeight = height;
305
+ this.scrollbarWidth = scrollbarWidth;
328
306
  this.damageTracker.markAllDirty();
329
307
  this.updateCanvasSize();
330
308
  }
@@ -335,21 +313,13 @@ export class CanvasRenderer<TData = any> {
335
313
  const width = this.viewportWidth || this.canvas.clientWidth;
336
314
  const height = this.viewportHeight || this.canvas.clientHeight || 600;
337
315
 
316
+ // Set pixel buffer dimensions only. CSS sizing is handled by the stylesheet
317
+ // (width: 100%; height: 100%) so we never touch canvas.style.width/height here.
318
+ // Modifying canvas style dimensions would change layout, re-fire the
319
+ // ResizeObserver and create an infinite grow loop.
338
320
  this.canvas.width = width * dpr;
339
321
  this.canvas.height = height * dpr;
340
- this.canvas.style.width = `${width}px`;
341
- this.canvas.style.height = `${height}px`;
342
-
343
- if (this.ctx) {
344
- if (typeof this.ctx.setTransform === 'function') {
345
- this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
346
- } else {
347
- (this.ctx as any).scale(dpr, dpr);
348
- }
349
- }
350
-
351
- // Reset blit state on resize
352
- this.blitState.reset();
322
+ this.ctx?.setTransform(dpr, 0, 0, dpr, 0, 0);
353
323
  this.scheduleRender();
354
324
  }
355
325
 
@@ -358,10 +328,7 @@ export class CanvasRenderer<TData = any> {
358
328
  if (!container) return;
359
329
 
360
330
  const rect = container.getBoundingClientRect();
361
- this.viewportWidth = rect.width;
362
- this.viewportHeight = rect.height;
363
-
364
- this.updateCanvasSize();
331
+ this.setViewportDimensions(rect.width, rect.height);
365
332
  }
366
333
 
367
334
  render(): void {
@@ -369,14 +336,28 @@ export class CanvasRenderer<TData = any> {
369
336
  this.scheduleRender();
370
337
  }
371
338
 
339
+ /**
340
+ * Schedule a render on the next animation frame.
341
+ * Coalesces: if a frame is already in-flight, marks a follow-up so the next
342
+ * frame fires immediately after (no renders dropped, no pile-up).
343
+ * No-op if nothing is dirty.
344
+ */
372
345
  private scheduleRender(): void {
373
- if (this.renderPending || this.animationFrameId !== null) return;
346
+ if (!this.damageTracker.hasDamage()) return;
347
+
348
+ if (this.animationFrameId !== null) {
349
+ this.nextRenderPending = true;
350
+ return;
351
+ }
374
352
 
375
- this.renderPending = true;
376
353
  this.animationFrameId = requestAnimationFrame(() => {
377
354
  this.doRender();
378
- this.renderPending = false;
379
355
  this.animationFrameId = null;
356
+
357
+ if (this.nextRenderPending) {
358
+ this.nextRenderPending = false;
359
+ this.scheduleRender();
360
+ }
380
361
  });
381
362
  }
382
363
 
@@ -385,41 +366,28 @@ export class CanvasRenderer<TData = any> {
385
366
  }
386
367
 
387
368
  private getVisibleColumns(): Column[] {
388
- return this.gridApi.getAllColumns().filter((col) => col.visible);
369
+ return this.gridApi.getAllColumns().filter((col) => isColumnVisible(col));
389
370
  }
390
371
 
391
- /**
392
- * Prepare columns for rendering
393
- *
394
- * Caches column definitions and X positions for efficient cell rendering.
395
- * This is called once per render frame before rendering visible rows.
396
- *
397
- * Performance optimizations:
398
- * 1. Column definition caching - Avoids repeated getColumnDef() calls
399
- * 2. Column position caching - Enables O(1) column X lookup instead of O(n)
400
- *
401
- * @see columnPreps - Cached column definitions
402
- * @see columnPositions - Cached column X positions
403
- */
372
+ /** Build the per-column prep cache once per frame before rendering visible rows. */
404
373
  private prepareColumns(): void {
405
- const columns = this.getVisibleColumns();
406
374
  this.columnPreps.clear();
407
- this.columnPositions.clear();
408
-
409
- // Cache column definitions and X positions in a single pass
410
- let x = 0;
411
- for (const column of columns) {
412
- const colDef = getColumnDef(column, this.gridApi);
413
- this.columnPreps.set(column.colId, prepColumn(this.ctx, column, colDef, this.theme));
414
- this.columnPositions.set(column.colId, x);
415
- x += Math.floor(column.width);
375
+ for (const column of this.getVisibleColumns()) {
376
+ this.columnPreps.set(
377
+ column.colId,
378
+ prepColumn(this.ctx, column, getColumnDef(column, this.gridApi), this.theme)
379
+ );
416
380
  }
417
381
  }
418
382
 
419
383
  private doRender(): void {
384
+ // Skip paint entirely if nothing has been marked dirty.
385
+ if (!this.damageTracker.hasDamage()) return;
386
+
420
387
  const startTime = performance.now();
421
388
  const width = this.viewportWidth || this.canvas.clientWidth;
422
389
  const height = this.viewportHeight || this.canvas.clientHeight;
390
+ const availableWidth = width - this.scrollbarWidth;
423
391
 
424
392
  // Clear canvas
425
393
  this.ctx.clearRect(0, 0, width, height);
@@ -440,12 +408,22 @@ export class CanvasRenderer<TData = any> {
440
408
  const { startRow, endRow } = getVisibleRowRange(
441
409
  this.scrollTop,
442
410
  height,
443
- this.rowHeight,
411
+ this.theme.rowHeight,
444
412
  totalRows,
445
413
  this.rowBuffer,
446
414
  this.gridApi
447
415
  );
448
416
 
417
+ // Log state periodically (not every frame to avoid flood)
418
+ if (Math.random() < 0.01) {
419
+ console.log('[ArgentGrid] doRender', {
420
+ viewport: { width, height },
421
+ rows: { total: totalRows, start: startRow, end: endRow },
422
+ scroll: { top: this.scrollTop, left: this.scrollLeft },
423
+ columns: allVisibleColumns.length,
424
+ });
425
+ }
426
+
449
427
  // Prepare columns (sets font, caches colDef)
450
428
  this.prepareColumns();
451
429
 
@@ -453,28 +431,34 @@ export class CanvasRenderer<TData = any> {
453
431
  this.ctx.font = getFontFromTheme(this.theme);
454
432
  this.ctx.textBaseline = 'middle';
455
433
 
434
+ const positionedColumns = getPositionedColumns(
435
+ allVisibleColumns,
436
+ this.scrollLeft,
437
+ width,
438
+ leftWidth,
439
+ rightWidth,
440
+ availableWidth
441
+ );
442
+
456
443
  // Render all visible rows
457
444
  walkRows(
458
445
  startRow,
459
446
  endRow,
460
447
  this.scrollTop,
461
- this.rowHeight,
448
+ this.theme.rowHeight,
462
449
  (rowIndex) => this.gridApi.getDisplayedRowAtIndex(rowIndex),
463
450
  (rowIndex, y, _rowHeight, rowNode) => {
464
451
  if (!rowNode) return;
465
- this.renderRow(rowIndex, y, rowNode, allVisibleColumns, width, leftWidth, rightWidth);
452
+ this.renderRow(rowIndex, y, rowNode, positionedColumns);
466
453
  },
467
454
  this.gridApi
468
455
  );
469
456
 
470
457
  // Draw grid lines
471
- this.drawGridLines(allVisibleColumns, startRow, endRow, width, height, leftWidth, rightWidth);
458
+ this.drawGridLines(positionedColumns, startRow, endRow, width, height, leftWidth, rightWidth);
472
459
 
473
460
  // Draw range selections
474
- this.drawRangeSelections(allVisibleColumns, leftWidth, rightWidth, width);
475
-
476
- // Store current frame for blitting
477
- this.blitState.setLastCanvas(this.canvas);
461
+ this.drawRangeSelections(positionedColumns, leftWidth, rightWidth, width);
478
462
 
479
463
  // Clear damage
480
464
  this.damageTracker.clear();
@@ -483,7 +467,7 @@ export class CanvasRenderer<TData = any> {
483
467
  }
484
468
 
485
469
  private drawRangeSelections(
486
- allVisibleColumns: Column[],
470
+ positionedColumns: PositionedColumn[],
487
471
  leftPinnedWidth: number,
488
472
  rightPinnedWidth: number,
489
473
  viewportWidth: number
@@ -493,30 +477,19 @@ export class CanvasRenderer<TData = any> {
493
477
 
494
478
  for (const range of ranges) {
495
479
  // Calculate Y boundaries
496
- const startY = range.startRow * this.rowHeight - this.scrollTop;
497
- const endY = (range.endRow + 1) * this.rowHeight - this.scrollTop;
498
-
499
- // Calculate X boundaries
500
- const startColIdx = allVisibleColumns.findIndex((c) => c.colId === range.startColumn);
501
- const endColIdx = allVisibleColumns.findIndex((c) => c.colId === range.endColumn);
502
-
503
- if (startColIdx === -1 || endColIdx === -1) continue;
480
+ const startY = range.startRow * this.theme.rowHeight - this.scrollTop;
481
+ const endY = (range.endRow + 1) * this.theme.rowHeight - this.scrollTop;
504
482
 
505
483
  let minX = Infinity;
506
484
  let maxX = -Infinity;
507
485
 
508
486
  // Calculate the total bounding box of all columns in the range
509
487
  range.columns.forEach((col) => {
510
- const xPos = getColumnX(
511
- col,
512
- this.columnPositions,
513
- this.scrollLeft,
514
- leftPinnedWidth,
515
- rightPinnedWidth,
516
- viewportWidth
517
- );
518
- minX = Math.min(minX, xPos);
519
- maxX = Math.max(maxX, xPos + col.width);
488
+ const pc = positionedColumns.find((p) => p.column.colId === col.colId);
489
+ if (pc) {
490
+ minX = Math.min(minX, pc.x);
491
+ maxX = Math.max(maxX, pc.x + pc.width);
492
+ }
520
493
  });
521
494
 
522
495
  if (minX === Infinity) continue;
@@ -542,18 +515,15 @@ export class CanvasRenderer<TData = any> {
542
515
  rowIndex: number,
543
516
  y: number,
544
517
  rowNode: IRowNode<TData>,
545
- allVisibleColumns: Column[],
546
- viewportWidth: number,
547
- leftWidth: number,
548
- rightWidth: number
518
+ positionedColumns: PositionedColumn[]
549
519
  ): void {
550
520
  if (rowNode.detail) {
551
- this.renderDetailRow(rowIndex, y, rowNode, viewportWidth);
521
+ this.renderDetailRow(rowIndex, y, rowNode, this.viewportWidth);
552
522
  return;
553
523
  }
554
524
 
555
525
  const isEvenRow = rowIndex % 2 === 0;
556
- const rowHeight = rowNode.rowHeight || this.rowHeight;
526
+ const rowHeight = rowNode.rowHeight || this.theme.rowHeight;
557
527
 
558
528
  // Draw row background
559
529
  let bgColor = isEvenRow ? this.theme.bgCellEven : this.theme.bgCell;
@@ -562,61 +532,13 @@ export class CanvasRenderer<TData = any> {
562
532
  }
563
533
 
564
534
  this.ctx.fillStyle = bgColor;
565
- this.ctx.fillRect(0, Math.floor(y), viewportWidth, rowHeight);
566
-
567
- // Render left pinned columns
568
- const leftPinned = allVisibleColumns.filter((c) => c.pinned === 'left');
569
- this.renderColumns(
570
- leftPinned,
571
- 0,
572
- false,
573
- rowNode,
574
- y,
575
- viewportWidth,
576
- leftWidth,
577
- rightWidth,
578
- allVisibleColumns
579
- );
535
+ // Fill background for the entire available width
536
+ this.ctx.fillRect(0, Math.floor(y), this.viewportWidth - this.scrollbarWidth, rowHeight);
580
537
 
581
- // Render center columns (with clipping)
582
- const centerColumns = allVisibleColumns.filter((c) => !c.pinned);
583
- if (centerColumns.length > 0) {
584
- this.ctx.save();
585
- this.ctx.beginPath();
586
- this.ctx.rect(
587
- Math.floor(leftWidth),
588
- Math.floor(y),
589
- Math.floor(viewportWidth - leftWidth - rightWidth),
590
- rowHeight
591
- );
592
- this.ctx.clip();
593
- this.renderColumns(
594
- centerColumns,
595
- leftWidth,
596
- true,
597
- rowNode,
598
- y,
599
- viewportWidth,
600
- leftWidth,
601
- rightWidth,
602
- allVisibleColumns
603
- );
604
- this.ctx.restore();
538
+ // Render columns using pre-calculated positions
539
+ for (const pc of positionedColumns) {
540
+ this.renderCell(pc.column, pc.x, y, pc.width, rowNode, positionedColumns);
605
541
  }
606
-
607
- // Render right pinned columns
608
- const rightPinned = allVisibleColumns.filter((c) => c.pinned === 'right');
609
- this.renderColumns(
610
- rightPinned,
611
- viewportWidth - rightWidth,
612
- false,
613
- rowNode,
614
- y,
615
- viewportWidth,
616
- leftWidth,
617
- rightWidth,
618
- allVisibleColumns
619
- );
620
542
  }
621
543
 
622
544
  private renderDetailRow(
@@ -644,112 +566,48 @@ export class CanvasRenderer<TData = any> {
644
566
  this.ctx.font = getFontFromTheme(this.theme);
645
567
  }
646
568
 
647
- private renderColumns(
648
- columns: Column[],
649
- startX: number,
650
- isScrollable: boolean,
651
- rowNode: IRowNode<TData>,
652
- y: number,
653
- viewportWidth: number,
654
- leftWidth: number,
655
- rightWidth: number,
656
- allVisibleColumns: Column[]
657
- ): void {
658
- let x = startX;
659
-
660
- for (const col of columns) {
661
- const cellX = isScrollable ? x - this.scrollLeft : x;
662
- const cellWidth = col.width;
663
-
664
- // Skip if outside viewport (for center columns)
665
- if (isScrollable && (cellX + cellWidth < leftWidth || cellX > viewportWidth - rightWidth)) {
666
- x += cellWidth;
667
- continue;
668
- }
669
-
670
- this.renderCell(col, cellX, y, cellWidth, rowNode, allVisibleColumns);
671
- x += cellWidth;
672
- }
673
- }
674
-
675
569
  private renderCell(
676
570
  column: Column,
677
571
  x: number,
678
572
  y: number,
679
573
  width: number,
680
574
  rowNode: IRowNode<TData>,
681
- allVisibleColumns: Column[]
575
+ positionedColumns: PositionedColumn[]
682
576
  ): void {
683
577
  const prep = this.columnPreps.get(column.colId);
684
578
  if (!prep) return;
685
579
 
686
- const cellValue = column.field ? getValueByPath(rowNode.data, column.field) : undefined;
687
-
688
- let textX = x + this.theme.cellPadding;
689
-
690
- const isSelectionColumn = column.colId === 'ag-Grid-SelectionColumn';
691
-
692
- // Check for checkbox selection
693
- if (isSelectionColumn) {
694
- const checkboxSize = 14;
695
- const checkboxY = Math.floor(y + (this.rowHeight - checkboxSize) / 2);
696
- const checkboxX = Math.floor(x + (width - checkboxSize) / 2);
697
-
698
- drawCheckbox(this.ctx, checkboxX, checkboxY, checkboxSize, rowNode.selected, this.theme);
699
- return; // Dedicated column only shows checkbox
700
- }
701
-
702
- // Check for sparkline
703
- if (prep.colDef?.sparklineOptions) {
704
- drawSparkline(this.ctx, cellValue, x, y, width, this.rowHeight, prep.colDef.sparklineOptions);
705
- return;
706
- }
707
-
580
+ const value = getCellValue(column, prep.colDef, rowNode, this.gridApi);
708
581
  const formattedValue = getFormattedValue(
709
- cellValue,
582
+ value,
710
583
  prep.colDef,
711
584
  rowNode.data,
712
585
  rowNode,
713
586
  this.gridApi
714
587
  );
715
588
 
716
- if (!formattedValue) return;
717
-
718
- this.ctx.fillStyle = this.theme.textCell;
719
-
720
- // Handle group indentation
721
- const isAutoGroupCol = column.colId === 'ag-Grid-AutoColumn';
722
- const isFirstColIfNoAutoGroup =
723
- !allVisibleColumns.some((c) => c.colId === 'ag-Grid-AutoColumn') &&
724
- column === allVisibleColumns[0];
725
-
726
- if (
727
- (isAutoGroupCol || isFirstColIfNoAutoGroup) &&
728
- (rowNode.group || rowNode.master || rowNode.level > 0)
729
- ) {
730
- const indent = rowNode.level * this.theme.groupIndentWidth;
731
- textX += indent;
732
-
733
- // Draw expand/collapse indicator
734
- if (rowNode.group || rowNode.master) {
735
- drawGroupIndicator(this.ctx, textX, y, this.rowHeight, rowNode.expanded, this.theme);
736
- textX += this.theme.groupIndicatorSize + 3;
737
- }
738
- }
739
-
740
- const truncatedText = truncateText(
741
- this.ctx,
589
+ drawCell(this.ctx, prep, {
590
+ ctx: this.ctx,
591
+ theme: this.theme,
592
+ column,
593
+ colDef: prep.colDef,
594
+ rowNode,
595
+ rowIndex: rowNode.displayedRowIndex,
596
+ x,
597
+ y,
598
+ width,
599
+ height: rowNode.rowHeight || this.theme.rowHeight,
600
+ value,
742
601
  formattedValue,
743
- width - (textX - x) - this.theme.cellPadding
744
- );
745
-
746
- if (truncatedText) {
747
- this.ctx.fillText(truncatedText, Math.floor(textX), Math.floor(y + this.rowHeight / 2));
748
- }
602
+ isSelected: rowNode.selected,
603
+ isHovered: false, // TODO: Implement hover
604
+ isEvenRow: rowNode.displayedRowIndex % 2 === 0,
605
+ api: this.gridApi,
606
+ });
749
607
  }
750
608
 
751
609
  private drawGridLines(
752
- columns: Column[],
610
+ positionedColumns: PositionedColumn[],
753
611
  startRow: number,
754
612
  endRow: number,
755
613
  viewportWidth: number,
@@ -762,9 +620,9 @@ export class CanvasRenderer<TData = any> {
762
620
  this.ctx,
763
621
  startRow,
764
622
  endRow,
765
- this.rowHeight,
623
+ this.theme.rowHeight,
766
624
  this.scrollTop,
767
- viewportWidth,
625
+ viewportWidth - this.scrollbarWidth,
768
626
  this.theme,
769
627
  this.gridApi
770
628
  );
@@ -772,7 +630,7 @@ export class CanvasRenderer<TData = any> {
772
630
  // Draw vertical column lines
773
631
  drawColumnLines(
774
632
  this.ctx,
775
- columns,
633
+ this.getVisibleColumns(),
776
634
  this.scrollLeft,
777
635
  this.scrollTop,
778
636
  viewportWidth,
@@ -782,8 +640,9 @@ export class CanvasRenderer<TData = any> {
782
640
  this.theme,
783
641
  startRow,
784
642
  endRow,
785
- this.rowHeight,
786
- this.gridApi
643
+ this.theme.rowHeight,
644
+ this.gridApi,
645
+ viewportWidth - this.scrollbarWidth
787
646
  );
788
647
  }
789
648
 
@@ -796,11 +655,12 @@ export class CanvasRenderer<TData = any> {
796
655
  const { rowIndex, columnIndex } = performHitTest(
797
656
  event.clientX - rect.left,
798
657
  event.clientY - rect.top,
799
- this.rowHeight,
658
+ this.theme.rowHeight,
800
659
  this.scrollTop,
801
660
  this.scrollLeft,
802
661
  this.viewportWidth,
803
- this.getVisibleColumns()
662
+ this.getVisibleColumns(),
663
+ this.viewportWidth - this.scrollbarWidth
804
664
  );
805
665
  const columns = this.getVisibleColumns();
806
666
  const colId = columnIndex !== -1 ? columns[columnIndex].colId : null;
@@ -819,15 +679,20 @@ export class CanvasRenderer<TData = any> {
819
679
  const { rowIndex, columnIndex } = performHitTest(
820
680
  event.clientX - rect.left,
821
681
  event.clientY - rect.top,
822
- this.rowHeight,
682
+ this.theme.rowHeight,
823
683
  this.scrollTop,
824
684
  this.scrollLeft,
825
685
  this.viewportWidth,
826
- this.getVisibleColumns()
686
+ this.getVisibleColumns(),
687
+ this.viewportWidth - this.scrollbarWidth
827
688
  );
828
689
  const columns = this.getVisibleColumns();
829
690
  const colId = columnIndex !== -1 ? columns[columnIndex].colId : null;
830
691
 
692
+ // Update cursor: pointer for button cells, default otherwise
693
+ const hoveredColDef = colId ? this.columnPreps.get(colId)?.colDef : null;
694
+ this.canvas.style.cursor = hoveredColDef?.buttonOptions ? 'pointer' : '';
695
+
831
696
  if (this.onMouseMove) {
832
697
  this.onMouseMove(event, rowIndex, colId);
833
698
  }
@@ -839,11 +704,12 @@ export class CanvasRenderer<TData = any> {
839
704
  const { rowIndex, columnIndex } = performHitTest(
840
705
  event.clientX - rect.left,
841
706
  event.clientY - rect.top,
842
- this.rowHeight,
707
+ this.theme.rowHeight,
843
708
  this.scrollTop,
844
709
  this.scrollLeft,
845
710
  this.viewportWidth,
846
- this.getVisibleColumns()
711
+ this.getVisibleColumns(),
712
+ this.viewportWidth - this.scrollbarWidth
847
713
  );
848
714
  const columns = this.getVisibleColumns();
849
715
  const colId = columnIndex !== -1 ? columns[columnIndex].colId : null;
@@ -858,23 +724,45 @@ export class CanvasRenderer<TData = any> {
858
724
  const { rowIndex, columnIndex } = performHitTest(
859
725
  event.clientX - rect.left,
860
726
  event.clientY - rect.top,
861
- this.rowHeight,
727
+ this.theme.rowHeight,
862
728
  this.scrollTop,
863
729
  this.scrollLeft,
864
730
  this.viewportWidth,
865
- this.getVisibleColumns()
731
+ this.getVisibleColumns(),
732
+ this.viewportWidth - this.scrollbarWidth
866
733
  );
867
734
  const rowNode = this.gridApi.getDisplayedRowAtIndex(rowIndex);
868
735
  if (!rowNode) return;
869
736
 
870
- // Handle selection column
737
+ // Handle selection column or explicit checkbox renderer
871
738
  const columns = this.getVisibleColumns();
872
739
  const clickedCol = columnIndex !== -1 ? columns[columnIndex] : null;
873
- if (clickedCol?.colId === 'ag-Grid-SelectionColumn') {
740
+ const clickedColDef = clickedCol ? this.columnPreps.get(clickedCol.colId)?.colDef : null;
741
+
742
+ if (
743
+ clickedCol?.colId === 'ag-Grid-SelectionColumn' ||
744
+ clickedColDef?.cellRenderer === 'checkbox'
745
+ ) {
874
746
  rowNode.setSelected(!rowNode.selected);
875
747
  return;
876
748
  }
877
749
 
750
+ // Handle button cell — fire onClick and stop propagation to row click
751
+ if (clickedCol) {
752
+ const colDef = this.columnPreps.get(clickedCol.colId)?.colDef;
753
+ if (colDef?.buttonOptions?.onClick) {
754
+ colDef.buttonOptions.onClick({
755
+ value: clickedCol.field ? getValueByPath(rowNode.data, clickedCol.field) : undefined,
756
+ data: rowNode.data,
757
+ node: rowNode,
758
+ colDef,
759
+ api: this.gridApi,
760
+ event,
761
+ });
762
+ return;
763
+ }
764
+ }
765
+
878
766
  // Handle expand/collapse
879
767
  if ((rowNode.group || rowNode.master) && columnIndex !== -1) {
880
768
  const columns = this.getVisibleColumns();
@@ -930,11 +818,12 @@ export class CanvasRenderer<TData = any> {
930
818
  const { rowIndex, columnIndex } = performHitTest(
931
819
  event.clientX - rect.left,
932
820
  event.clientY - rect.top,
933
- this.rowHeight,
821
+ this.theme.rowHeight,
934
822
  this.scrollTop,
935
823
  this.scrollLeft,
936
824
  this.viewportWidth,
937
- this.getVisibleColumns()
825
+ this.getVisibleColumns(),
826
+ this.viewportWidth - this.scrollbarWidth
938
827
  );
939
828
  if (columnIndex === -1) return;
940
829
 
@@ -954,7 +843,7 @@ export class CanvasRenderer<TData = any> {
954
843
  return performHitTest(
955
844
  event.clientX - rect.left,
956
845
  event.clientY - rect.top,
957
- this.rowHeight,
846
+ this.theme.rowHeight,
958
847
  this.scrollTop,
959
848
  this.scrollLeft,
960
849
  this.viewportWidth,
@@ -970,7 +859,7 @@ export class CanvasRenderer<TData = any> {
970
859
  const container = this.canvas.parentElement;
971
860
  if (!container) return;
972
861
 
973
- const targetPosition = rowIndex * this.rowHeight;
862
+ const targetPosition = rowIndex * this.theme.rowHeight;
974
863
  container.scrollTop = targetPosition;
975
864
  this.scrollTop = targetPosition;
976
865
  this.damageTracker.markAllDirty();
@@ -1042,7 +931,7 @@ export class CanvasRenderer<TData = any> {
1042
931
  getRowAtPosition(y: number): number {
1043
932
  const scrollTop = this.scrollTop || 0;
1044
933
  const rowY = y + scrollTop;
1045
- return Math.floor(rowY / this.rowHeight);
934
+ return Math.floor(rowY / this.theme.rowHeight);
1046
935
  }
1047
936
 
1048
937
  destroy(): void {
@@ -1060,6 +949,8 @@ export class CanvasRenderer<TData = any> {
1060
949
  this.canvas.removeEventListener('mousedown', this.mousedownListener);
1061
950
  if (this.mousemoveListener)
1062
951
  this.canvas.removeEventListener('mousemove', this.mousemoveListener);
952
+ if (this.mouseleaveListener)
953
+ this.canvas.removeEventListener('mouseleave', this.mouseleaveListener);
1063
954
  if (this.clickListener) this.canvas.removeEventListener('click', this.clickListener);
1064
955
  if (this.dblclickListener) this.canvas.removeEventListener('dblclick', this.dblclickListener);
1065
956
  if (this.mouseupListener) this.canvas.removeEventListener('mouseup', this.mouseupListener);
@@ -1068,8 +959,8 @@ export class CanvasRenderer<TData = any> {
1068
959
  window.removeEventListener('resize', this.resizeListener);
1069
960
  }
1070
961
 
1071
- this.renderPending = false;
1072
- this.blitState.reset();
962
+ this.nextRenderPending = false;
963
+ this.animationFrameId = null;
1073
964
  this.damageTracker.reset();
1074
965
  }
1075
966
  }