virtual-tree-canvas 0.6.0 → 0.7.1

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.
@@ -1,3 +1,7 @@
1
+ import { drawCheckbox } from './checkbox.js';
2
+ import { treeCellLayout } from '../core/tree-cell-layout.js';
3
+ import { formatInspectorValue } from '../inspector/editor-resolver.js';
4
+ import { inspectorPaneLayout } from '../inspector/pane-layout.js';
1
5
  import { numericLayout } from '../inspector/numeric-layout.js';
2
6
  import { IconRegistry } from '../core/icon-registry.js';
3
7
 
@@ -16,10 +20,14 @@ export class TreeRowRenderer {
16
20
  this.stopIconListener = this.iconRegistry.onChange?.(() => this.#scheduleIconRender()) ?? null;
17
21
  }
18
22
 
19
- /** @param {HTMLCanvasElement} canvas */
23
+ /**
24
+ * @param {HTMLCanvasElement} canvas
25
+ */
20
26
  initialize(canvas) {
21
27
  this.canvas = canvas;
22
- this.ctx = canvas.getContext('2d', { alpha: false });
28
+ this.ctx = canvas.getContext('2d', {
29
+ alpha: false
30
+ });
23
31
  if (!this.ctx) throw new Error('Canvas2D context is not available');
24
32
  }
25
33
 
@@ -27,7 +35,9 @@ export class TreeRowRenderer {
27
35
  this.scene = scene;
28
36
  }
29
37
 
30
- setInvalidationHandler(invalidate) { this.invalidate = invalidate; }
38
+ setInvalidationHandler(invalidate) {
39
+ this.invalidate = invalidate;
40
+ }
31
41
 
32
42
  destroy() {
33
43
  if (this.iconRenderFrame !== null) {
@@ -51,7 +61,7 @@ export class TreeRowRenderer {
51
61
  if (scene?.rows) this.scene = scene;
52
62
  if (!this.canvas || !this.ctx || !this.scene) return;
53
63
  const { viewport, theme } = this.scene;
54
- const dpr = Math.max(1, window.devicePixelRatio || 1);
64
+ const dpr = Math.max(1, this.canvas.ownerDocument?.defaultView?.devicePixelRatio || 1);
55
65
  const viewportWidth = Math.max(0, viewport.viewportWidth);
56
66
  const viewportHeight = Math.max(0, viewport.viewportHeight);
57
67
  if (viewportWidth <= 0 || viewportHeight <= 0) return;
@@ -83,7 +93,8 @@ export class TreeRowRenderer {
83
93
  if (this.invalidate) return this.invalidate();
84
94
  if (this.iconRenderFrame !== null) return;
85
95
  const view = this.canvas.ownerDocument?.defaultView ?? globalThis;
86
- const schedule = view.requestAnimationFrame?.bind(view) ?? (callback => setTimeout(callback, 0));
96
+ const schedule =
97
+ view.requestAnimationFrame?.bind(view) ?? ((callback) => setTimeout(callback, 0));
87
98
  this.iconRenderFrame = schedule(() => {
88
99
  this.iconRenderFrame = null;
89
100
  this.render();
@@ -99,7 +110,12 @@ export class TreeRowRenderer {
99
110
  if (key === this.preparedIconThemeKey) return;
100
111
  this.preparedIconThemeKey = key;
101
112
  for (const [icon, color] of iconColors) {
102
- this.iconRegistry.prepare?.({ icons: [icon], size: 15, color, pixelRatio });
113
+ this.iconRegistry.prepare?.({
114
+ icons: [icon],
115
+ size: 15,
116
+ color,
117
+ pixelRatio
118
+ });
103
119
  }
104
120
  }
105
121
 
@@ -119,14 +135,29 @@ export class TreeRowRenderer {
119
135
  ctx.textBaseline = 'middle';
120
136
 
121
137
  for (const column of columns) {
122
- if (headerFilter && column === columns.find(item => item.kind !== 'rowOrder')) {
138
+ if (
139
+ headerFilter &&
140
+ column === columns.find((item) => item.kind !== 'rowOrder' && item.id !== '__vtc_actions')
141
+ ) {
123
142
  this.#drawHeaderFilter(ctx, column, viewport, theme, filterQuery);
124
143
  } else {
125
144
  ctx.fillStyle = colors.textMuted;
126
- drawTruncatedText(ctx, column.label, column.x + 10, viewport.headerHeight / 2, column.width - 20);
145
+ drawTruncatedText(
146
+ ctx,
147
+ column.label,
148
+ column.x + 10,
149
+ viewport.headerHeight / 2,
150
+ column.width - 20
151
+ );
127
152
  }
128
153
  if (sort?.columnId === column.id && sort.direction) {
129
- this.#drawSortIndicator(ctx, column.x + column.width - 16, viewport.headerHeight / 2, sort.direction, theme);
154
+ this.#drawSortIndicator(
155
+ ctx,
156
+ column.x + column.width - 16,
157
+ viewport.headerHeight / 2,
158
+ sort.direction,
159
+ theme
160
+ );
130
161
  }
131
162
  ctx.strokeStyle = colors.border;
132
163
  ctx.beginPath();
@@ -147,7 +178,8 @@ export class TreeRowRenderer {
147
178
  const colors = theme.colors;
148
179
  const x = column.x + 8;
149
180
  const y = 5;
150
- const visibleRight = viewport.scrollX + (viewport.contentViewportWidth ?? viewport.viewportWidth);
181
+ const visibleRight =
182
+ viewport.scrollX + (viewport.contentViewportWidth ?? viewport.viewportWidth);
151
183
  const visibleWidth = Math.max(1, Math.min(column.x + column.width, visibleRight) - column.x);
152
184
  const width = Math.max(40, visibleWidth - 16);
153
185
  const height = Math.max(18, viewport.headerHeight - 10);
@@ -157,7 +189,13 @@ export class TreeRowRenderer {
157
189
  ctx.strokeStyle = colors.border;
158
190
  ctx.stroke();
159
191
  ctx.fillStyle = filterQuery ? colors.text : colors.textMuted;
160
- drawTruncatedText(ctx, filterQuery || 'Filter rows', x + 8, viewport.headerHeight / 2, width - 16);
192
+ drawTruncatedText(
193
+ ctx,
194
+ filterQuery || 'Filter rows',
195
+ x + 8,
196
+ viewport.headerHeight / 2,
197
+ width - 16
198
+ );
161
199
  }
162
200
 
163
201
  #drawSortIndicator(ctx, x, y, direction, theme) {
@@ -198,7 +236,10 @@ export class TreeRowRenderer {
198
236
  const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
199
237
  const height = Math.min(
200
238
  viewport.rowViewportHeight,
201
- stickyRows.reduce((bottom, row, index) => Math.max(bottom, (row.stickyY ?? index * row.height) + row.height), 0)
239
+ stickyRows.reduce(
240
+ (bottom, row, index) => Math.max(bottom, (row.stickyY ?? index * row.height) + row.height),
241
+ 0
242
+ )
202
243
  );
203
244
 
204
245
  ctx.save();
@@ -208,10 +249,14 @@ export class TreeRowRenderer {
208
249
  ctx.translate(-viewport.scrollX, viewport.headerHeight);
209
250
  for (let i = 0; i < stickyRows.length; i++) {
210
251
  const row = stickyRows[i];
211
- this.#drawRow(ctx, { ...row, y: row.stickyY ?? i * row.height });
252
+ this.#drawRow(ctx, {
253
+ ...row,
254
+ y: row.stickyY ?? i * row.height
255
+ });
212
256
  }
213
257
  ctx.restore();
214
258
 
259
+ if (!stickyRows.some((row) => row.stickyY > row.y - viewport.scrollY)) return;
215
260
  const bottom = viewport.headerHeight + height;
216
261
  ctx.strokeStyle = theme.colors.border;
217
262
  ctx.beginPath();
@@ -221,7 +266,20 @@ export class TreeRowRenderer {
221
266
  }
222
267
 
223
268
  #drawRow(ctx, row) {
224
- const { columns, nodes, dynamicState, selection, hoverNodeId, hoverPart, activeNodeId, activePart, focusNodeId, searchMatches, theme, viewport } = this.scene;
269
+ const {
270
+ columns,
271
+ nodes,
272
+ dynamicState,
273
+ selection,
274
+ hoverNodeId,
275
+ hoverPart,
276
+ activeNodeId,
277
+ activePart,
278
+ focusNodeId,
279
+ searchMatches,
280
+ theme,
281
+ viewport
282
+ } = this.scene;
225
283
  const node = nodes[row.nodeIndex];
226
284
  const state = dynamicState.get(row.nodeId) ?? {};
227
285
  const style = resolveNodeStyle(theme, node, state);
@@ -234,13 +292,25 @@ export class TreeRowRenderer {
234
292
  const visibleX = viewport.scrollX;
235
293
  const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
236
294
 
237
- ctx.fillStyle = selected ? colors.rowSelected : highlighted ? colors.rowHighlighted : hovered ? colors.rowHover : colors.row;
295
+ const background = selected
296
+ ? colors.rowSelected
297
+ : highlighted
298
+ ? colors.rowHighlighted
299
+ : hovered
300
+ ? colors.rowHover
301
+ : colors.row;
302
+ ctx.fillStyle = background;
238
303
  ctx.fillRect(visibleX, y, visibleWidth, row.height);
239
304
 
240
305
  this.#drawIndentGuides(ctx, row, colors);
241
306
 
242
307
  for (const column of columns) {
243
- const rect = { x: column.x, y, width: column.width, height: row.height };
308
+ const rect = {
309
+ x: column.x,
310
+ y,
311
+ width: column.width,
312
+ height: row.height
313
+ };
244
314
  this.#drawCell(ctx, {
245
315
  node,
246
316
  state,
@@ -252,7 +322,7 @@ export class TreeRowRenderer {
252
322
  selected,
253
323
  hovered,
254
324
  hoverPart: hoverNodeId === row.nodeId ? hoverPart : null,
255
- activePart: activeNodeId === row.nodeId ? activePart : null,
325
+ activePart: activeNodeId === row.nodeId ? activePart : null
256
326
  });
257
327
  ctx.strokeStyle = colors.border;
258
328
  ctx.beginPath();
@@ -261,6 +331,17 @@ export class TreeRowRenderer {
261
331
  ctx.stroke();
262
332
  }
263
333
 
334
+ const actions = columns.find((column) => column.id === '__vtc_actions');
335
+ if (actions) {
336
+ ctx.fillStyle = background;
337
+ ctx.fillRect(
338
+ visibleX + Math.max(0, visibleWidth - actions.width),
339
+ y,
340
+ actions.width,
341
+ row.height
342
+ );
343
+ }
344
+
264
345
  ctx.strokeStyle = colors.border;
265
346
  ctx.beginPath();
266
347
  ctx.moveTo(visibleX, y + row.height + 0.5);
@@ -285,13 +366,15 @@ export class TreeRowRenderer {
285
366
  else if (cell.column.kind === 'inspectorPane') this.#drawInspectorPaneCell(ctx, cell);
286
367
  else if (cell.column.kind === 'inspectorValue') this.#drawInspectorValueCell(ctx, cell);
287
368
  else if (cell.column.kind === 'inspectorType') this.#drawInspectorTypeCell(ctx, cell);
288
- else if (cell.column.kind === 'inspectorDescription') this.#drawInspectorDescriptionCell(ctx, cell);
369
+ else if (cell.column.kind === 'inspectorDescription')
370
+ this.#drawInspectorDescriptionCell(ctx, cell);
289
371
  else if (cell.column.kind === 'status') this.#drawStatusCell(ctx, cell);
290
372
  else if (cell.column.kind === 'progress') this.#drawProgressCell(ctx, cell);
291
373
  else this.#drawTextCell(ctx, cell);
292
374
  }
293
375
 
294
376
  #drawRowOrder(ctx, { node, row, rect, theme }) {
377
+ if (node.reorderable === false) return;
295
378
  const siblings = this.scene.childrenByParent?.get(node.parentId ?? null) ?? [];
296
379
  const enabled = this.scene.canReorderRows && siblings.length > 1;
297
380
  const cy = rect.y + row.height / 2;
@@ -299,9 +382,16 @@ export class TreeRowRenderer {
299
382
  ctx.strokeStyle = theme.colors.textMuted;
300
383
  ctx.fillStyle = theme.colors.textMuted;
301
384
  ctx.lineWidth = 1.5;
302
- ctx.globalAlpha = enabled ? 1 : 0.3;
303
- for (const dx of [10, 14]) for (const dy of [-4, 0, 4]) ctx.fillRect(rect.x + dx, cy + dy, 1.5, 1.5);
304
- for (const [center, direction, available] of [[36, -1, siblings[0] !== node.id], [60, 1, siblings.at(-1) !== node.id]]) {
385
+ ctx.globalAlpha =
386
+ enabled || this.scene.rowDrag?.(node, this.scene.dynamicState.get(node.id) ?? {}) != null
387
+ ? 1
388
+ : 0.3;
389
+ for (const dx of [10, 14])
390
+ for (const dy of [-4, 0, 4]) ctx.fillRect(rect.x + dx, cy + dy, 1.5, 1.5);
391
+ for (const [center, direction, available] of [
392
+ [36, -1, siblings[0] !== node.id],
393
+ [60, 1, siblings.at(-1) !== node.id]
394
+ ]) {
305
395
  ctx.globalAlpha = enabled && available ? 1 : 0.3;
306
396
  ctx.beginPath();
307
397
  ctx.moveTo(rect.x + center - 4, cy - direction * 2);
@@ -328,8 +418,13 @@ export class TreeRowRenderer {
328
418
  }
329
419
 
330
420
  #drawInspectorPaneCell(ctx, { node, row, rect, theme, style, hovered, hoverPart, activePart }) {
331
- const visibleRight = this.scene.viewport.scrollX + (this.scene.viewport.contentViewportWidth ?? this.scene.viewport.viewportWidth);
332
- rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
421
+ const visibleRight =
422
+ this.scene.viewport.scrollX +
423
+ (this.scene.viewport.contentViewportWidth ?? this.scene.viewport.viewportWidth);
424
+ rect = {
425
+ ...rect,
426
+ width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x)
427
+ };
333
428
  const data = node.data ?? {};
334
429
  const colors = theme.colors;
335
430
  const indentX = rect.x + row.depth * theme.indentWidth;
@@ -347,51 +442,109 @@ export class TreeRowRenderer {
347
442
  ctx.textBaseline = 'middle';
348
443
  ctx.textAlign = 'left';
349
444
  ctx.globalAlpha = data.disabled ? DISABLED_ALPHA : 1;
350
- ctx.fillStyle = data.valueType === 'object' || data.valueType === 'array' ? colors.text : colors.textMuted;
445
+ ctx.fillStyle =
446
+ data.valueType === 'object' || data.valueType === 'array' ? colors.text : colors.textMuted;
351
447
 
352
448
  if (data.valueType === 'object') {
353
- drawTruncatedText(ctx, node.label ?? node.id, labelX, cy, Math.max(20, rect.x + rect.width - labelX - 8));
449
+ drawTruncatedText(
450
+ ctx,
451
+ node.label ?? node.id,
452
+ labelX,
453
+ cy,
454
+ Math.max(20, rect.x + rect.width - labelX - 8)
455
+ );
354
456
  if (data.meta?.updated) this.#drawUpdatedMarker(ctx, row, theme);
355
457
  ctx.globalAlpha = 1;
356
458
  return;
357
459
  }
358
460
 
359
- const layout = inspectorPaneLayout(rect.width, row.depth, theme.indentWidth, data.editorType, this.scene.inspectorPaneLabelEnd);
360
- drawTruncatedText(ctx, node.label ?? node.id, labelX, cy, Math.max(20, rect.x + layout.editorLeft - labelX - 8));
461
+ const layout = inspectorPaneLayout(
462
+ rect.width,
463
+ row.depth,
464
+ theme.indentWidth,
465
+ data.editorType,
466
+ this.scene.inspectorPaneLabelEnd
467
+ );
468
+ drawTruncatedText(
469
+ ctx,
470
+ node.label ?? node.id,
471
+ labelX,
472
+ cy,
473
+ Math.max(20, rect.x + layout.editorLeft - labelX - 8)
474
+ );
361
475
 
362
476
  const editorX = rect.x + layout.editorLeft;
363
477
  const editorWidth = layout.editorWidth;
364
478
  if (data.valueType === 'array') {
365
479
  ctx.fillStyle = colors.textMuted;
366
480
  drawTruncatedText(ctx, data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
367
- this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme, {
368
- hovered: hovered && hoverPart === 'arrayAdd',
369
- active: activePart === 'arrayAdd',
370
- });
371
- this.#drawSmallButton(ctx, rect.x + rect.width - 28, rect.y + 5, 22, rect.height - 10, '-', theme, {
372
- hovered: hovered && hoverPart === 'arrayRemove',
373
- active: activePart === 'arrayRemove',
374
- });
481
+ this.#drawSmallButton(
482
+ ctx,
483
+ rect.x + rect.width - 54,
484
+ rect.y + 5,
485
+ 22,
486
+ rect.height - 10,
487
+ '+',
488
+ theme,
489
+ {
490
+ hovered: hovered && hoverPart === 'arrayAdd',
491
+ active: activePart === 'arrayAdd'
492
+ }
493
+ );
494
+ this.#drawSmallButton(
495
+ ctx,
496
+ rect.x + rect.width - 28,
497
+ rect.y + 5,
498
+ 22,
499
+ rect.height - 10,
500
+ '-',
501
+ theme,
502
+ {
503
+ hovered: hovered && hoverPart === 'arrayRemove',
504
+ active: activePart === 'arrayRemove'
505
+ }
506
+ );
375
507
  } else {
376
508
  this.#drawInspectorValueCell(ctx, {
377
509
  node,
378
- rect: { x: editorX, y: rect.y, width: editorWidth, height: rect.height },
510
+ rect: {
511
+ x: editorX,
512
+ y: rect.y,
513
+ width: editorWidth,
514
+ height: rect.height
515
+ },
379
516
  theme,
380
517
  hovered,
381
518
  hoverPart,
382
519
  activePart,
383
- suppressUpdatedMarker: true,
520
+ suppressUpdatedMarker: true
384
521
  });
385
522
  }
386
523
  if (data.meta?.updated) this.#drawUpdatedMarker(ctx, row, theme);
387
524
  ctx.globalAlpha = 1;
388
525
  }
389
526
 
390
- #drawInspectorValueCell(ctx, { node, rect, theme, hovered = false, hoverPart = null, activePart = null, suppressUpdatedMarker = false }) {
527
+ #drawInspectorValueCell(
528
+ ctx,
529
+ {
530
+ node,
531
+ rect,
532
+ theme,
533
+ hovered = false,
534
+ hoverPart = null,
535
+ activePart = null,
536
+ suppressUpdatedMarker = false
537
+ }
538
+ ) {
391
539
  const data = node.data ?? {};
392
540
  const meta = data.meta ?? {};
393
541
  const disabled = data.disabled;
394
542
  const readonly = data.readonly;
543
+ const valueColor = resolveValueColor(
544
+ theme,
545
+ data.value,
546
+ data.editorType === 'select' ? 'enum' : data.valueType
547
+ );
395
548
  const x = rect.x + 10;
396
549
  const y = rect.y + 5;
397
550
  const fullWidth = Math.max(24, rect.width - 20);
@@ -406,52 +559,94 @@ export class TreeRowRenderer {
406
559
  if (numeric.unit && numeric.unitWidth) {
407
560
  ctx.font = theme.unitFont ?? theme.monoFont ?? theme.font;
408
561
  ctx.fillStyle = theme.colors.unit ?? theme.colors.textMuted;
409
- drawTruncatedText(ctx, numeric.unit, x + numeric.unitLeft, rect.y + rect.height / 2, numeric.unitWidth);
562
+ drawTruncatedText(
563
+ ctx,
564
+ numeric.unit,
565
+ x + numeric.unitLeft,
566
+ rect.y + rect.height / 2,
567
+ numeric.unitWidth
568
+ );
410
569
  ctx.font = theme.font;
411
570
  }
412
571
 
413
572
  if (data.editorType === 'checkbox') {
414
- this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
573
+ drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
415
574
  } else if (data.editorType === 'range') {
416
575
  this.#drawInspectorRange(ctx, x, rect.y + rect.height / 2 - 4, width, data, theme, {
417
576
  hoveredNumber: hovered && hoverPart === 'number',
418
- activeNumber: activePart === 'number',
577
+ activeNumber: activePart === 'number'
419
578
  });
420
579
  } else if (data.editorType === 'color') {
421
580
  ctx.fillStyle = String(data.value || '#000000');
422
581
  ctx.fillRect(x, y + 2, 28, height - 4);
423
582
  ctx.strokeStyle = theme.colors.border;
424
583
  ctx.strokeRect(x + 0.5, y + 2.5, 28, height - 4);
425
- this.#drawMutedText(ctx, String(data.value ?? ''), x + 38, rect.y + rect.height / 2, width - 38, theme);
584
+ this.#drawMutedText(
585
+ ctx,
586
+ String(data.value ?? ''),
587
+ x + 38,
588
+ rect.y + rect.height / 2,
589
+ width - 38,
590
+ theme,
591
+ false,
592
+ null,
593
+ valueColor
594
+ );
426
595
  } else if (data.editorType === 'button') {
427
596
  const buttonWidth = meta.fullWidthButton ? width : Math.min(width, 140);
428
597
  this.#drawControlSurface(ctx, x, y, buttonWidth, height, theme, {
429
598
  hovered: hovered && hoverPart === 'button',
430
599
  active: activePart === 'button',
431
- disabled: readonly || disabled,
600
+ disabled: readonly || disabled
432
601
  });
433
602
  ctx.fillStyle = theme.colors.text;
434
603
  ctx.textAlign = 'center';
435
- drawTruncatedText(ctx, meta.button ?? node.label, x + buttonWidth / 2, rect.y + rect.height / 2, Math.max(10, buttonWidth - 12));
604
+ drawTruncatedText(
605
+ ctx,
606
+ meta.button ?? node.label,
607
+ x + buttonWidth / 2,
608
+ rect.y + rect.height / 2,
609
+ Math.max(10, buttonWidth - 12)
610
+ );
436
611
  ctx.textAlign = 'left';
437
612
  } else if (data.editorType === 'select') {
438
613
  const selectWidth = Math.min(width, 180);
439
614
  this.#drawControlSurface(ctx, x, y, selectWidth, height, theme, {
440
615
  hovered: hovered && hoverPart === 'editor',
441
616
  active: activePart === 'editor',
442
- disabled: readonly || disabled,
617
+ disabled: readonly || disabled
443
618
  });
444
- this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme);
619
+ this.#drawMutedText(
620
+ ctx,
621
+ data.valueText,
622
+ x + 8,
623
+ rect.y + rect.height / 2,
624
+ selectWidth - 30,
625
+ theme,
626
+ false,
627
+ null,
628
+ valueColor
629
+ );
445
630
  this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
446
631
  } else {
447
632
  if (numeric.unit) {
448
633
  ctx.font = theme.monoFont ?? theme.font;
449
- ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
634
+ ctx.fillStyle = valueColor;
450
635
  ctx.textAlign = 'right';
451
636
  drawTruncatedText(ctx, data.valueText, x + width, rect.y + rect.height / 2, width);
452
637
  ctx.textAlign = 'left';
453
638
  } else {
454
- this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
639
+ this.#drawMutedText(
640
+ ctx,
641
+ data.valueText,
642
+ x,
643
+ rect.y + rect.height / 2,
644
+ width,
645
+ theme,
646
+ false,
647
+ null,
648
+ valueColor
649
+ );
455
650
  }
456
651
  }
457
652
 
@@ -474,23 +669,27 @@ export class TreeRowRenderer {
474
669
  }
475
670
 
476
671
  #drawInspectorTypeCell(ctx, { node, rect, theme }) {
477
- this.#drawMutedText(ctx, node.data?.valueType ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme, false, theme.monoFont);
672
+ this.#drawMutedText(
673
+ ctx,
674
+ node.data?.valueType ?? '',
675
+ rect.x + 10,
676
+ rect.y + rect.height / 2,
677
+ rect.width - 20,
678
+ theme,
679
+ false,
680
+ theme.monoFont
681
+ );
478
682
  }
479
683
 
480
684
  #drawInspectorDescriptionCell(ctx, { node, rect, theme }) {
481
- this.#drawMutedText(ctx, node.data?.meta?.description ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
482
- }
483
-
484
- #drawCheckbox(ctx, x, y, checked, theme) {
485
- roundRect(ctx, x, y, 16, 16, 3);
486
- ctx.fillStyle = theme.colors.progressTrack;
487
- ctx.fill();
488
- ctx.strokeStyle = checked ? theme.colors.progressFill : theme.colors.textMuted;
489
- ctx.stroke();
490
- if (!checked) return;
491
- ctx.fillStyle = theme.colors.progressFill;
492
- roundRect(ctx, x + 4, y + 4, 8, 8, 1.5);
493
- ctx.fill();
685
+ this.#drawMutedText(
686
+ ctx,
687
+ node.data?.meta?.description ?? '',
688
+ rect.x + 10,
689
+ rect.y + rect.height / 2,
690
+ rect.width - 20,
691
+ theme
692
+ );
494
693
  }
495
694
 
496
695
  #drawInspectorRange(ctx, x, y, width, data, theme, state = {}) {
@@ -504,12 +703,18 @@ export class TreeRowRenderer {
504
703
  this.#drawMeterBar(ctx, x, y + 0.5, barWidth, 7, ratio, theme.colors.progressFill, theme);
505
704
  this.#drawControlSurface(ctx, x + barWidth + gap, y - 6, valueWidth, 20, theme, {
506
705
  hovered: Boolean(state.hoveredNumber),
507
- active: Boolean(state.activeNumber),
706
+ active: Boolean(state.activeNumber)
508
707
  });
509
- ctx.fillStyle = theme.colors.text;
708
+ ctx.fillStyle = resolveValueColor(theme, data.value);
510
709
  ctx.font = theme.monoFont ?? theme.font;
511
710
  ctx.textAlign = 'right';
512
- drawTruncatedText(ctx, String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
711
+ drawTruncatedText(
712
+ ctx,
713
+ String(data.valueText ?? ''),
714
+ x + barWidth + gap + valueWidth - 6,
715
+ y + 4,
716
+ valueWidth - 10
717
+ );
513
718
  ctx.textAlign = 'left';
514
719
  ctx.font = theme.font;
515
720
  }
@@ -523,7 +728,15 @@ export class TreeRowRenderer {
523
728
  ctx.textAlign = 'left';
524
729
  }
525
730
 
526
- #drawControlSurface(ctx, x, y, width, height, theme, { hovered = false, active = false, disabled = false } = {}) {
731
+ #drawControlSurface(
732
+ ctx,
733
+ x,
734
+ y,
735
+ width,
736
+ height,
737
+ theme,
738
+ { hovered = false, active = false, disabled = false } = {}
739
+ ) {
527
740
  const colors = theme.colors;
528
741
  ctx.fillStyle = disabled
529
742
  ? colors.progressTrack
@@ -534,7 +747,11 @@ export class TreeRowRenderer {
534
747
  : colors.progressTrack;
535
748
  roundRect(ctx, x, y, width, height, 5);
536
749
  ctx.fill();
537
- ctx.strokeStyle = active ? colors.focus : hovered ? mixColor(colors.border, colors.focus, 0.5) : colors.border;
750
+ ctx.strokeStyle = active
751
+ ? colors.focus
752
+ : hovered
753
+ ? mixColor(colors.border, colors.focus, 0.5)
754
+ : colors.border;
538
755
  ctx.stroke();
539
756
  }
540
757
 
@@ -548,8 +765,8 @@ export class TreeRowRenderer {
548
765
  ctx.fill();
549
766
  }
550
767
 
551
- #drawMutedText(ctx, text, x, y, width, theme, readonly = false, font = null) {
552
- ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
768
+ #drawMutedText(ctx, text, x, y, width, theme, readonly = false, font = null, color = null) {
769
+ ctx.fillStyle = color ?? (readonly ? theme.colors.textMuted : theme.colors.text);
553
770
  ctx.font = font ?? theme.font;
554
771
  ctx.textBaseline = 'middle';
555
772
  ctx.textAlign = 'left';
@@ -558,19 +775,33 @@ export class TreeRowRenderer {
558
775
 
559
776
  #drawTreeCell(ctx, { node, row, rect, theme, style }) {
560
777
  const colors = theme.colors;
561
- const x = rect.x + row.depth * theme.indentWidth;
778
+ const layout = treeCellLayout(
779
+ row.depth,
780
+ theme.indentWidth,
781
+ this.scene.childrenByParent.size > 1
782
+ );
562
783
  const cy = rect.y + rect.height / 2;
563
- this.#drawChevron(ctx, x + 10, cy, row, colors);
564
- this.iconRegistry.draw(ctx, style.icon, x + 27, rect.y + 6, 15, style.color);
784
+ if (row.hasChildren) this.#drawChevron(ctx, rect.x + layout.chevronX, cy, row, colors);
785
+ this.iconRegistry.draw(ctx, style.icon, rect.x + layout.iconX, rect.y + 6, 15, style.color);
565
786
  ctx.fillStyle = colors.text;
566
787
  ctx.font = theme.font;
567
788
  ctx.textBaseline = 'middle';
568
789
  ctx.textAlign = 'left';
569
- drawTruncatedText(ctx, node.label ?? node.id, x + 50, cy, Math.max(40, rect.x + rect.width - x - 56));
790
+ drawTruncatedText(
791
+ ctx,
792
+ node.label ?? node.id,
793
+ rect.x + layout.labelX,
794
+ cy,
795
+ Math.max(0, rect.width - layout.labelX - 6)
796
+ );
570
797
  }
571
798
 
572
799
  #drawStatusCell(ctx, { rect, style, theme }) {
573
- const badgeWidth = Math.min(58, rect.width - 12);
800
+ ctx.font = theme.font;
801
+ const badgeWidth = Math.max(
802
+ 0,
803
+ Math.min(ctx.measureText(style.status.label).width + 18, rect.width - 12)
804
+ );
574
805
  const x = rect.x + (rect.width - badgeWidth) / 2;
575
806
  const y = rect.y + (rect.height - 17) / 2;
576
807
  const statusColor = style.status.color;
@@ -581,7 +812,7 @@ export class TreeRowRenderer {
581
812
  ctx.strokeStyle = mixColor(theme.colors.border, statusColor, 0.42);
582
813
  ctx.stroke();
583
814
  ctx.fillStyle = mixColor(theme.colors.text, statusColor, 0.36);
584
- ctx.font = '10px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace';
815
+ ctx.font = theme.font;
585
816
  ctx.textAlign = 'center';
586
817
  ctx.textBaseline = 'middle';
587
818
  drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8.5, badgeWidth - 8);
@@ -589,7 +820,18 @@ export class TreeRowRenderer {
589
820
  }
590
821
 
591
822
  #drawProgressCell(ctx, { state, rect, theme, style }) {
592
- if (state.progress === undefined) return this.#drawTextCell(ctx, { state, rect, theme, style, column: { align: 'right', value: () => '' }, node: {} });
823
+ if (state.progress === undefined)
824
+ return this.#drawTextCell(ctx, {
825
+ state,
826
+ rect,
827
+ theme,
828
+ style,
829
+ column: {
830
+ align: 'right',
831
+ value: () => ''
832
+ },
833
+ node: {}
834
+ });
593
835
  const ratio = clamp01(state.progress);
594
836
  const barX = rect.x + 10;
595
837
  const barHeight = 7;
@@ -623,13 +865,33 @@ export class TreeRowRenderer {
623
865
 
624
866
  #drawTextCell(ctx, { node, state, rect, column, theme }) {
625
867
  let value = column.value(node, state);
626
- if (column.kind === 'updated' && typeof value === 'number') value = formatTime(value);
627
- if (typeof value === 'number') value = Math.round(value).toString();
628
- ctx.fillStyle = column.kind === 'type' ? resolveNodeStyle(theme, node, state).color : theme.colors.textMuted;
629
- ctx.font = column.kind === 'type' || column.kind === 'updated' || typeof value === 'number' ? theme.monoFont ?? theme.font : theme.font;
868
+ const numeric = typeof value === 'number';
869
+ const valueType =
870
+ typeof column.valueType === 'function'
871
+ ? column.valueType(value, node, state)
872
+ : column.valueType;
873
+ const valueColor = resolveValueColor(theme, value, valueType);
874
+ if (column.format) value = column.format(value, node, state);
875
+ else if (column.kind === 'updated' && numeric) value = formatTime(value);
876
+ else if (numeric) value = formatInspectorValue(value);
877
+ ctx.fillStyle =
878
+ column.kind === 'type'
879
+ ? resolveNodeStyle(theme, node, state).color
880
+ : column.kind === 'value'
881
+ ? valueColor
882
+ : theme.colors.textMuted;
883
+ ctx.font =
884
+ column.kind === 'type' || column.kind === 'updated' || numeric
885
+ ? (theme.monoFont ?? theme.font)
886
+ : theme.font;
630
887
  ctx.textBaseline = 'middle';
631
888
  ctx.textAlign = column.align;
632
- const x = column.align === 'right' ? rect.x + rect.width - 10 : column.align === 'center' ? rect.x + rect.width / 2 : rect.x + 10;
889
+ const x =
890
+ column.align === 'right'
891
+ ? rect.x + rect.width - 10
892
+ : column.align === 'center'
893
+ ? rect.x + rect.width / 2
894
+ : rect.x + 10;
633
895
  drawTruncatedText(ctx, String(value ?? ''), x, rect.y + rect.height / 2, rect.width - 20);
634
896
  ctx.textAlign = 'left';
635
897
  }
@@ -648,13 +910,8 @@ export class TreeRowRenderer {
648
910
  }
649
911
 
650
912
  #drawChevron(ctx, x, y, row, colors) {
651
- ctx.fillStyle = row.hasChildren ? colors.chevron : colors.textMuted;
652
- if (!row.hasChildren) {
653
- ctx.beginPath();
654
- ctx.arc(x, y, 1.5, 0, Math.PI * 2);
655
- ctx.fill();
656
- return;
657
- }
913
+ if (!row.hasChildren) return;
914
+ ctx.fillStyle = colors.chevron;
658
915
  ctx.beginPath();
659
916
  if (row.expanded) {
660
917
  ctx.moveTo(x - 5, y - 2);
@@ -668,35 +925,23 @@ export class TreeRowRenderer {
668
925
  ctx.closePath();
669
926
  ctx.fill();
670
927
  }
928
+
671
929
  }
672
930
 
673
931
  function resolveNodeStyle(theme, node, state = {}) {
674
932
  const typeRule = theme.types[node?.type ?? ''] ?? {};
675
- const status = theme.statuses[state.status ?? 0] ?? { label: String(state.status ?? ''), color: theme.colors.textMuted };
933
+ const status = theme.statuses[state.status ?? 0] ?? {
934
+ label: String(state.status ?? ''),
935
+ color: theme.colors.textMuted
936
+ };
676
937
  return {
677
938
  icon: state.icon ?? node?.icon ?? typeRule.icon ?? 'placeholder',
678
939
  color: state.color ?? typeRule.color ?? theme.colors.progressFill,
679
940
  typeColor: typeRule.color ?? theme.colors.progressFill,
680
- status,
941
+ status
681
942
  };
682
943
  }
683
944
 
684
- function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
685
- const safeWidth = Math.max(1, width);
686
- const rightPadding = 14;
687
- if (editorType === 'checkbox') {
688
- const editorWidth = 34;
689
- return { editorLeft: Math.max(64, safeWidth - rightPadding - editorWidth), editorWidth };
690
- }
691
- const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
692
- const minLabelEnd = Math.max(88, depth * indentWidth + 104);
693
- const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
694
- const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
695
- const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
696
- const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
697
- return { editorLeft, editorWidth };
698
- }
699
-
700
945
  function clamp01(value) {
701
946
  return Math.max(0, Math.min(1, value));
702
947
  }
@@ -785,7 +1030,7 @@ function setTextFitCache(key, value) {
785
1030
  const timeFormatter = new Intl.DateTimeFormat([], {
786
1031
  hour: '2-digit',
787
1032
  minute: '2-digit',
788
- second: '2-digit',
1033
+ second: '2-digit'
789
1034
  });
790
1035
 
791
1036
  function formatTime(value) {
@@ -793,3 +1038,7 @@ function formatTime(value) {
793
1038
  if (!Number.isFinite(time)) return '';
794
1039
  return timeFormatter.format(time);
795
1040
  }
1041
+
1042
+ function resolveValueColor(theme, value, type) {
1043
+ return theme.valueColors?.[type ?? (value === null ? 'null' : typeof value)] ?? theme.colors.text;
1044
+ }