virtual-tree-canvas 0.6.0 → 0.7.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.
@@ -11,14 +11,18 @@ export class RowReorderInput {
11
11
  this.previousTouchAction = this.canvas.style.touchAction;
12
12
  this.canvas.addEventListener('pointerdown', this.onDown);
13
13
  this.canvas.addEventListener('keydown', this.onKey);
14
+ this.canvas.addEventListener('lostpointercapture', this.onCancel);
14
15
  this.view.addEventListener('pointermove', this.onMove);
15
16
  this.view.addEventListener('pointerup', this.onUp);
16
17
  this.view.addEventListener('pointercancel', this.onCancel);
17
18
  this.view.addEventListener('blur', this.cancel);
18
- this.stops = ['datachange', 'filterchange', 'sortchange', 'rowreorderchange'].map(type => controller.on(type, () => {
19
+ this.stops = ['filterchange', 'sortchange', 'rowreorderchange'].map(type => controller.on(type, () => {
19
20
  this.cancel();
20
21
  this.syncMode();
21
22
  }));
23
+ this.stops.push(controller.on('datachange', () => {
24
+ if (this.gesture && !controller.model.index.getNode(this.gesture.id)) this.cancel();
25
+ }));
22
26
  this.stops.push(controller.on('rowreorder', event => {
23
27
  if (!this.status) return;
24
28
  const { nodeId, toIndex, siblingOrder } = event.detail;
@@ -37,21 +41,28 @@ export class RowReorderInput {
37
41
  }
38
42
 
39
43
  syncMode() {
40
- this.canvas.style.touchAction = this.controller.canReorderRows() ? 'none' : this.previousTouchAction;
44
+ this.canvas.style.touchAction = (this.controller.canReorderRows() || this.controller.rowDrag) ? 'none' : this.previousTouchAction;
41
45
  }
42
46
 
43
47
  onDown = event => {
44
- if (event.button !== 0 || !this.controller.canReorderRows() || this.gesture) return;
48
+ if (event.button !== 0 || event.isPrimary === false || this.gesture) return;
49
+ this.suppressClick = false;
45
50
  const point = this.point(event);
46
51
  const hit = this.controller.hitTest(point.x, point.y);
47
- if (!hit?.row || !['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
48
- this.canvas.focus();
52
+ if (!hit?.row) return;
53
+ const node = this.controller.model.index.getNode(hit.row.nodeId);
54
+ const payload = this.controller.rowDrag?.(node, this.controller.model.dynamicState.get(node.id) ?? {}) ?? null;
55
+ const orderPart = ['rowDrag', 'rowUp', 'rowDown'].includes(hit.part);
56
+ if (orderPart && node.reorderable === false) return;
57
+ if (orderPart ? !this.controller.canReorderRows() && !(payload != null && hit.part === 'rowDrag') : payload == null || !['label', 'row', 'icon', 'cell'].includes(hit.part)) return;
58
+ this.canvas.focus({ preventScroll: true });
49
59
  this.controller.cellEditor?.close?.();
50
60
  this.controller.focusedId = hit.row.nodeId;
51
- this.gesture = { id: hit.row.nodeId, part: hit.part, pointerId: event.pointerId,
61
+ this.gesture = { id: hit.row.nodeId, part: orderPart ? hit.part : 'rowDrag', payload, pointerId: event.pointerId,
52
62
  startX: point.x, startY: point.y, point, dragging: false };
53
63
  this.canvas.setPointerCapture?.(event.pointerId);
54
- event.preventDefault();
64
+ event.stopPropagation();
65
+ if (orderPart) event.preventDefault();
55
66
  };
56
67
 
57
68
  onMove = event => {
@@ -60,7 +71,9 @@ export class RowReorderInput {
60
71
  gesture.point = this.point(event);
61
72
  if (gesture.part !== 'rowDrag') return;
62
73
  if (!gesture.dragging && Math.hypot(gesture.point.x - gesture.startX, gesture.point.y - gesture.startY) < 5) return;
74
+ if (!gesture.dragging && gesture.payload != null) this.emitDrag('rowdragstart', event);
63
75
  gesture.dragging = true;
76
+ if (gesture.payload != null) this.emitDrag('rowdragmove', event);
64
77
  this.canvas.style.cursor = 'grabbing';
65
78
  this.updateTarget();
66
79
  this.scheduleScroll();
@@ -73,7 +86,9 @@ export class RowReorderInput {
73
86
  gesture.point = this.point(event);
74
87
  const target = gesture.dragging ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y) : null;
75
88
  const hit = this.controller.hitTest(gesture.point.x, gesture.point.y);
76
- this.cancel();
89
+ if (gesture.dragging && gesture.payload != null) this.emitDrag('rowdragend', event);
90
+ this.suppressClick = gesture.dragging;
91
+ this.cancel(false);
77
92
  if (target) this.controller.moveRow(gesture.id, target.index, { source: 'pointer' });
78
93
  else if (!gesture.dragging && hit?.row?.nodeId === gesture.id && hit.part === gesture.part) {
79
94
  if (gesture.part === 'rowUp') this.controller.moveRowBy(gesture.id, -1, { source: 'button' });
@@ -102,6 +117,7 @@ export class RowReorderInput {
102
117
  scheduleScroll() {
103
118
  if (this.frame !== null || !this.gesture?.dragging) return;
104
119
  const viewport = this.controller.viewport;
120
+ if (this.gesture.point.x < 0 || this.gesture.point.x > viewport.viewportWidth || this.gesture.point.y < 0 || this.gesture.point.y > viewport.viewportHeight) return;
105
121
  const y = this.gesture.point.y - viewport.renderInsetY;
106
122
  const top = viewport.headerHeight;
107
123
  const bottom = top + viewport.rowViewportHeight;
@@ -118,8 +134,16 @@ export class RowReorderInput {
118
134
  });
119
135
  }
120
136
 
121
- cancel = () => {
137
+ emitDrag(type, originalEvent) {
138
+ const gesture = this.gesture;
139
+ if (gesture) this.controller.events.emit(type, { nodeId: gesture.id, payload: gesture.payload,
140
+ label: this.controller.model.index.getNode(gesture.id)?.label ?? gesture.id, originalEvent });
141
+ }
142
+
143
+ cancel = (notify = true) => {
122
144
  const gesture = this.gesture;
145
+ if (gesture?.dragging) this.suppressClick = true;
146
+ if (notify && gesture?.dragging && gesture.payload != null) this.emitDrag('rowdragcancel');
123
147
  this.gesture = null;
124
148
  if (this.frame !== null) (this.view.cancelAnimationFrame?.bind(this.view) ?? clearTimeout)(this.frame);
125
149
  this.frame = null;
@@ -132,6 +156,7 @@ export class RowReorderInput {
132
156
  this.cancel();
133
157
  this.canvas.removeEventListener('pointerdown', this.onDown);
134
158
  this.canvas.removeEventListener('keydown', this.onKey);
159
+ this.canvas.removeEventListener('lostpointercapture', this.onCancel);
135
160
  this.view.removeEventListener('pointermove', this.onMove);
136
161
  this.view.removeEventListener('pointerup', this.onUp);
137
162
  this.view.removeEventListener('pointercancel', this.onCancel);
@@ -18,7 +18,7 @@ export class TreeViewInputController {
18
18
  this.canvas.addEventListener('wheel', this.#onWheel, { passive: false });
19
19
  this.canvas.addEventListener('mousedown', this.#onMouseDown);
20
20
  this.canvas.addEventListener('mousemove', this.#onMouseMove);
21
- window.addEventListener('mouseup', this.#onMouseUp);
21
+ (this.canvas.ownerDocument.defaultView ?? window).addEventListener('mouseup', this.#onMouseUp);
22
22
  this.canvas.addEventListener('mouseleave', this.#onMouseLeave);
23
23
  this.canvas.addEventListener('click', this.#onClick);
24
24
  this.canvas.addEventListener('dblclick', this.#onDoubleClick);
@@ -29,7 +29,7 @@ export class TreeViewInputController {
29
29
  this.canvas.removeEventListener('wheel', this.#onWheel);
30
30
  this.canvas.removeEventListener('mousedown', this.#onMouseDown);
31
31
  this.canvas.removeEventListener('mousemove', this.#onMouseMove);
32
- window.removeEventListener('mouseup', this.#onMouseUp);
32
+ (this.canvas.ownerDocument.defaultView ?? window).removeEventListener('mouseup', this.#onMouseUp);
33
33
  this.canvas.removeEventListener('mouseleave', this.#onMouseLeave);
34
34
  this.canvas.removeEventListener('click', this.#onClick);
35
35
  this.canvas.removeEventListener('dblclick', this.#onDoubleClick);
@@ -71,7 +71,7 @@ export class TreeViewInputController {
71
71
  };
72
72
 
73
73
  #onClick = (event) => {
74
- this.canvas.focus();
74
+ if (this.controller.rowReorderInput?.suppressClick) { this.controller.rowReorderInput.suppressClick = false; event.preventDefault(); return; }
75
75
  const hit = this.#hitTest(event);
76
76
  if (!hit) return;
77
77
  if (['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
@@ -85,6 +85,7 @@ export class TreeViewInputController {
85
85
  return;
86
86
  }
87
87
  if (this.cellEditor?.handleClick(event, hit)) return;
88
+ this.canvas.focus({ preventScroll: true });
88
89
  this.controller.clickNode(hit.row.nodeId, {
89
90
  shiftKey: event.shiftKey,
90
91
  ctrlKey: event.ctrlKey,
@@ -99,6 +100,7 @@ export class TreeViewInputController {
99
100
  };
100
101
 
101
102
  #onMouseDown = (event) => {
103
+ if (this.controller.rowReorderInput?.gesture) return;
102
104
  const hit = this.#hitTest(event);
103
105
  if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) return;
104
106
  this.controller.setActiveHit(hit);
@@ -6,11 +6,13 @@ export class CellEditorManager {
6
6
  if (!controller.canvas) throw new Error('CellEditorManager requires an initialized TreeViewController canvas');
7
7
  this.controller = controller;
8
8
  this.canvas = controller.canvas;
9
+ this.view = this.canvas.ownerDocument.defaultView;
9
10
  this.host = host ?? controller.canvas.parentElement ?? document.body;
10
11
  this.editable = true;
11
12
  this.overlay = null;
12
13
  this.rangeDrag = null;
13
14
  this.overlayKind = null;
15
+ this.overlayNode = null;
14
16
  this.stopFilter = controller.on('filterchange', ({ detail }) => {
15
17
  if (this.overlayKind === 'filter' && this.overlay) this.overlay.value = detail.query;
16
18
  });
@@ -26,15 +28,25 @@ export class CellEditorManager {
26
28
  destroy() {
27
29
  this.stopFilter();
28
30
  this.#removeOverlay();
29
- window.removeEventListener('mousemove', this.onMouseMove);
30
- window.removeEventListener('mouseup', this.onMouseUp);
31
+ this.view.removeEventListener('mousemove', this.onMouseMove);
32
+ this.view.removeEventListener('mouseup', this.onMouseUp);
31
33
  }
32
34
 
33
35
  close() {
34
36
  this.#removeOverlay();
35
37
  this.rangeDrag = null;
36
- window.removeEventListener('mousemove', this.onMouseMove);
37
- window.removeEventListener('mouseup', this.onMouseUp);
38
+ this.view.removeEventListener('mousemove', this.onMouseMove);
39
+ this.view.removeEventListener('mouseup', this.onMouseUp);
40
+ }
41
+
42
+ /** Keep an in-progress edit while values refresh; cancel if its editor changes. */
43
+ reconcile(nodes) {
44
+ const previous = this.overlayNode ?? this.rangeDrag?.node;
45
+ if (!previous) return;
46
+ const next = nodes.find(node => node.id === previous.id);
47
+ const signature = data => JSON.stringify([data.editorType, data.readonly, data.disabled,
48
+ ...['min', 'max', 'step', 'integer', 'options', 'unit'].map(key => data.meta?.[key])]);
49
+ if (!next || signature(previous.data) !== signature(next.data)) this.close();
38
50
  }
39
51
 
40
52
  handlePointerDown(event, hit) {
@@ -42,10 +54,10 @@ export class CellEditorManager {
42
54
  const data = hit.row ? this.controller.model.nodes[hit.row.nodeIndex]?.data : null;
43
55
  if (!data || data.readonly || data.disabled) return false;
44
56
  if (data.editorType === 'range' && hit.part === 'range') {
45
- this.rangeDrag = { hit, data };
57
+ this.rangeDrag = { hit, data, node: this.controller.model.nodes[hit.row.nodeIndex] };
46
58
  this.#updateRangeFromEvent(event);
47
- window.addEventListener('mousemove', this.onMouseMove);
48
- window.addEventListener('mouseup', this.onMouseUp);
59
+ this.view.addEventListener('mousemove', this.onMouseMove);
60
+ this.view.addEventListener('mouseup', this.onMouseUp);
49
61
  return true;
50
62
  }
51
63
  if (shouldOpenOverlayOnPointerDown(data, hit)) {
@@ -137,6 +149,7 @@ export class CellEditorManager {
137
149
  this.host.append(element);
138
150
  this.overlay = element;
139
151
  this.overlayKind = 'value';
152
+ this.overlayNode = node;
140
153
  element.focus({ preventScroll: true });
141
154
  element.select?.();
142
155
  if (options.showPicker && element.showPicker) {
@@ -219,8 +232,8 @@ export class CellEditorManager {
219
232
 
220
233
  #onMouseUp() {
221
234
  this.rangeDrag = null;
222
- window.removeEventListener('mousemove', this.onMouseMove);
223
- window.removeEventListener('mouseup', this.onMouseUp);
235
+ this.view.removeEventListener('mousemove', this.onMouseMove);
236
+ this.view.removeEventListener('mouseup', this.onMouseUp);
224
237
  }
225
238
 
226
239
  #isEditableHit(hit) {
@@ -273,6 +286,7 @@ export class CellEditorManager {
273
286
  const overlay = this.overlay;
274
287
  this.overlay = null;
275
288
  this.overlayKind = null;
289
+ this.overlayNode = null;
276
290
  overlay?.remove();
277
291
  }
278
292
 
@@ -0,0 +1,12 @@
1
+ export function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
2
+ const safeWidth = Math.max(1, width);
3
+ const rightPadding = 14;
4
+ const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
5
+ const minLabelEnd = Math.max(88, depth * indentWidth + 104);
6
+ const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
7
+ const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
8
+ const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
9
+ const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
10
+ return { editorLeft, editorWidth };
11
+ }
12
+
@@ -0,0 +1,14 @@
1
+ export function drawCheckbox(ctx, x, y, checked, theme) {
2
+ ctx.beginPath();
3
+ ctx.roundRect(x, y, 16, 16, 3);
4
+ ctx.fillStyle = theme.colors.progressTrack;
5
+ ctx.fill();
6
+ ctx.strokeStyle = checked ? theme.colors.progressFill : theme.colors.textMuted;
7
+ ctx.stroke();
8
+ if (!checked) return;
9
+ ctx.fillStyle = theme.colors.progressFill;
10
+ ctx.beginPath();
11
+ ctx.roundRect(x + 4, y + 4, 8, 8, 1.5);
12
+ ctx.fill();
13
+ }
14
+
@@ -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
 
@@ -51,7 +55,7 @@ export class TreeRowRenderer {
51
55
  if (scene?.rows) this.scene = scene;
52
56
  if (!this.canvas || !this.ctx || !this.scene) return;
53
57
  const { viewport, theme } = this.scene;
54
- const dpr = Math.max(1, window.devicePixelRatio || 1);
58
+ const dpr = Math.max(1, this.canvas.ownerDocument?.defaultView?.devicePixelRatio || 1);
55
59
  const viewportWidth = Math.max(0, viewport.viewportWidth);
56
60
  const viewportHeight = Math.max(0, viewport.viewportHeight);
57
61
  if (viewportWidth <= 0 || viewportHeight <= 0) return;
@@ -119,7 +123,7 @@ export class TreeRowRenderer {
119
123
  ctx.textBaseline = 'middle';
120
124
 
121
125
  for (const column of columns) {
122
- if (headerFilter && column === columns.find(item => item.kind !== 'rowOrder')) {
126
+ if (headerFilter && column === columns.find(item => item.kind !== 'rowOrder' && item.id !== '__vtc_actions')) {
123
127
  this.#drawHeaderFilter(ctx, column, viewport, theme, filterQuery);
124
128
  } else {
125
129
  ctx.fillStyle = colors.textMuted;
@@ -212,6 +216,7 @@ export class TreeRowRenderer {
212
216
  }
213
217
  ctx.restore();
214
218
 
219
+ if (!stickyRows.some(row => row.stickyY > row.y - viewport.scrollY)) return;
215
220
  const bottom = viewport.headerHeight + height;
216
221
  ctx.strokeStyle = theme.colors.border;
217
222
  ctx.beginPath();
@@ -234,7 +239,8 @@ export class TreeRowRenderer {
234
239
  const visibleX = viewport.scrollX;
235
240
  const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
236
241
 
237
- ctx.fillStyle = selected ? colors.rowSelected : highlighted ? colors.rowHighlighted : hovered ? colors.rowHover : colors.row;
242
+ const background = selected ? colors.rowSelected : highlighted ? colors.rowHighlighted : hovered ? colors.rowHover : colors.row;
243
+ ctx.fillStyle = background;
238
244
  ctx.fillRect(visibleX, y, visibleWidth, row.height);
239
245
 
240
246
  this.#drawIndentGuides(ctx, row, colors);
@@ -261,6 +267,12 @@ export class TreeRowRenderer {
261
267
  ctx.stroke();
262
268
  }
263
269
 
270
+ const actions = columns.find(column => column.id === '__vtc_actions');
271
+ if (actions) {
272
+ ctx.fillStyle = background;
273
+ ctx.fillRect(visibleX + Math.max(0, visibleWidth - actions.width), y, actions.width, row.height);
274
+ }
275
+
264
276
  ctx.strokeStyle = colors.border;
265
277
  ctx.beginPath();
266
278
  ctx.moveTo(visibleX, y + row.height + 0.5);
@@ -292,6 +304,7 @@ export class TreeRowRenderer {
292
304
  }
293
305
 
294
306
  #drawRowOrder(ctx, { node, row, rect, theme }) {
307
+ if (node.reorderable === false) return;
295
308
  const siblings = this.scene.childrenByParent?.get(node.parentId ?? null) ?? [];
296
309
  const enabled = this.scene.canReorderRows && siblings.length > 1;
297
310
  const cy = rect.y + row.height / 2;
@@ -299,7 +312,7 @@ export class TreeRowRenderer {
299
312
  ctx.strokeStyle = theme.colors.textMuted;
300
313
  ctx.fillStyle = theme.colors.textMuted;
301
314
  ctx.lineWidth = 1.5;
302
- ctx.globalAlpha = enabled ? 1 : 0.3;
315
+ ctx.globalAlpha = enabled || this.scene.rowDrag?.(node, this.scene.dynamicState.get(node.id) ?? {}) != null ? 1 : 0.3;
303
316
  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
317
  for (const [center, direction, available] of [[36, -1, siblings[0] !== node.id], [60, 1, siblings.at(-1) !== node.id]]) {
305
318
  ctx.globalAlpha = enabled && available ? 1 : 0.3;
@@ -392,6 +405,7 @@ export class TreeRowRenderer {
392
405
  const meta = data.meta ?? {};
393
406
  const disabled = data.disabled;
394
407
  const readonly = data.readonly;
408
+ const valueColor = resolveValueColor(theme, data.value, data.editorType === 'select' ? 'enum' : data.valueType);
395
409
  const x = rect.x + 10;
396
410
  const y = rect.y + 5;
397
411
  const fullWidth = Math.max(24, rect.width - 20);
@@ -411,7 +425,7 @@ export class TreeRowRenderer {
411
425
  }
412
426
 
413
427
  if (data.editorType === 'checkbox') {
414
- this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
428
+ drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
415
429
  } else if (data.editorType === 'range') {
416
430
  this.#drawInspectorRange(ctx, x, rect.y + rect.height / 2 - 4, width, data, theme, {
417
431
  hoveredNumber: hovered && hoverPart === 'number',
@@ -422,7 +436,7 @@ export class TreeRowRenderer {
422
436
  ctx.fillRect(x, y + 2, 28, height - 4);
423
437
  ctx.strokeStyle = theme.colors.border;
424
438
  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);
439
+ this.#drawMutedText(ctx, String(data.value ?? ''), x + 38, rect.y + rect.height / 2, width - 38, theme, false, null, valueColor);
426
440
  } else if (data.editorType === 'button') {
427
441
  const buttonWidth = meta.fullWidthButton ? width : Math.min(width, 140);
428
442
  this.#drawControlSurface(ctx, x, y, buttonWidth, height, theme, {
@@ -441,17 +455,17 @@ export class TreeRowRenderer {
441
455
  active: activePart === 'editor',
442
456
  disabled: readonly || disabled,
443
457
  });
444
- this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme);
458
+ this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme, false, null, valueColor);
445
459
  this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
446
460
  } else {
447
461
  if (numeric.unit) {
448
462
  ctx.font = theme.monoFont ?? theme.font;
449
- ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
463
+ ctx.fillStyle = valueColor;
450
464
  ctx.textAlign = 'right';
451
465
  drawTruncatedText(ctx, data.valueText, x + width, rect.y + rect.height / 2, width);
452
466
  ctx.textAlign = 'left';
453
467
  } else {
454
- this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
468
+ this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, false, null, valueColor);
455
469
  }
456
470
  }
457
471
 
@@ -481,17 +495,6 @@ export class TreeRowRenderer {
481
495
  this.#drawMutedText(ctx, node.data?.meta?.description ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
482
496
  }
483
497
 
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();
494
- }
495
498
 
496
499
  #drawInspectorRange(ctx, x, y, width, data, theme, state = {}) {
497
500
  const meta = data.meta ?? {};
@@ -506,7 +509,7 @@ export class TreeRowRenderer {
506
509
  hovered: Boolean(state.hoveredNumber),
507
510
  active: Boolean(state.activeNumber),
508
511
  });
509
- ctx.fillStyle = theme.colors.text;
512
+ ctx.fillStyle = resolveValueColor(theme, data.value);
510
513
  ctx.font = theme.monoFont ?? theme.font;
511
514
  ctx.textAlign = 'right';
512
515
  drawTruncatedText(ctx, String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
@@ -548,8 +551,8 @@ export class TreeRowRenderer {
548
551
  ctx.fill();
549
552
  }
550
553
 
551
- #drawMutedText(ctx, text, x, y, width, theme, readonly = false, font = null) {
552
- ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
554
+ #drawMutedText(ctx, text, x, y, width, theme, readonly = false, font = null, color = null) {
555
+ ctx.fillStyle = color ?? (readonly ? theme.colors.textMuted : theme.colors.text);
553
556
  ctx.font = font ?? theme.font;
554
557
  ctx.textBaseline = 'middle';
555
558
  ctx.textAlign = 'left';
@@ -558,19 +561,20 @@ export class TreeRowRenderer {
558
561
 
559
562
  #drawTreeCell(ctx, { node, row, rect, theme, style }) {
560
563
  const colors = theme.colors;
561
- const x = rect.x + row.depth * theme.indentWidth;
564
+ const layout = treeCellLayout(row.depth, theme.indentWidth, this.scene.childrenByParent.size > 1);
562
565
  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);
566
+ if (row.hasChildren) this.#drawChevron(ctx, rect.x + layout.chevronX, cy, row, colors);
567
+ this.iconRegistry.draw(ctx, style.icon, rect.x + layout.iconX, rect.y + 6, 15, style.color);
565
568
  ctx.fillStyle = colors.text;
566
569
  ctx.font = theme.font;
567
570
  ctx.textBaseline = 'middle';
568
571
  ctx.textAlign = 'left';
569
- drawTruncatedText(ctx, node.label ?? node.id, x + 50, cy, Math.max(40, rect.x + rect.width - x - 56));
572
+ drawTruncatedText(ctx, node.label ?? node.id, rect.x + layout.labelX, cy, Math.max(0, rect.width - layout.labelX - 6));
570
573
  }
571
574
 
572
575
  #drawStatusCell(ctx, { rect, style, theme }) {
573
- const badgeWidth = Math.min(58, rect.width - 12);
576
+ ctx.font = theme.font;
577
+ const badgeWidth = Math.max(0, Math.min(ctx.measureText(style.status.label).width + 18, rect.width - 12));
574
578
  const x = rect.x + (rect.width - badgeWidth) / 2;
575
579
  const y = rect.y + (rect.height - 17) / 2;
576
580
  const statusColor = style.status.color;
@@ -581,7 +585,7 @@ export class TreeRowRenderer {
581
585
  ctx.strokeStyle = mixColor(theme.colors.border, statusColor, 0.42);
582
586
  ctx.stroke();
583
587
  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';
588
+ ctx.font = theme.font;
585
589
  ctx.textAlign = 'center';
586
590
  ctx.textBaseline = 'middle';
587
591
  drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8.5, badgeWidth - 8);
@@ -623,10 +627,14 @@ export class TreeRowRenderer {
623
627
 
624
628
  #drawTextCell(ctx, { node, state, rect, column, theme }) {
625
629
  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;
630
+ const numeric = typeof value === 'number';
631
+ const valueType = typeof column.valueType === 'function' ? column.valueType(value, node, state) : column.valueType;
632
+ const valueColor = resolveValueColor(theme, value, valueType);
633
+ if (column.format) value = column.format(value, node, state);
634
+ else if (column.kind === 'updated' && numeric) value = formatTime(value);
635
+ else if (numeric) value = formatInspectorValue(value);
636
+ ctx.fillStyle = column.kind === 'type' ? resolveNodeStyle(theme, node, state).color : column.kind === 'value' ? valueColor : theme.colors.textMuted;
637
+ ctx.font = column.kind === 'type' || column.kind === 'updated' || numeric ? theme.monoFont ?? theme.font : theme.font;
630
638
  ctx.textBaseline = 'middle';
631
639
  ctx.textAlign = column.align;
632
640
  const x = column.align === 'right' ? rect.x + rect.width - 10 : column.align === 'center' ? rect.x + rect.width / 2 : rect.x + 10;
@@ -648,13 +656,8 @@ export class TreeRowRenderer {
648
656
  }
649
657
 
650
658
  #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
- }
659
+ if (!row.hasChildren) return;
660
+ ctx.fillStyle = colors.chevron;
658
661
  ctx.beginPath();
659
662
  if (row.expanded) {
660
663
  ctx.moveTo(x - 5, y - 2);
@@ -681,21 +684,6 @@ function resolveNodeStyle(theme, node, state = {}) {
681
684
  };
682
685
  }
683
686
 
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
687
 
700
688
  function clamp01(value) {
701
689
  return Math.max(0, Math.min(1, value));
@@ -793,3 +781,7 @@ function formatTime(value) {
793
781
  if (!Number.isFinite(time)) return '';
794
782
  return timeFormatter.format(time);
795
783
  }
784
+
785
+ function resolveValueColor(theme, value, type) {
786
+ return theme.valueColors?.[type ?? (value === null ? 'null' : typeof value)] ?? theme.colors.text;
787
+ }