virtual-tree-canvas 0.5.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +181 -27
  3. package/docs/icon-catalog.html +67 -0
  4. package/docs/icon-catalog.json +392 -0
  5. package/docs/icon-catalog.md +171 -0
  6. package/examples/generate-tree.js +86 -0
  7. package/examples/reorder-table.html +33 -0
  8. package/examples/units.html +43 -0
  9. package/package.json +7 -3
  10. package/resources/icons/bicycle.svg +1 -0
  11. package/resources/icons/bird.svg +1 -0
  12. package/resources/icons/box.svg +1 -0
  13. package/resources/icons/building.svg +1 -0
  14. package/resources/icons/calendar.svg +1 -0
  15. package/resources/icons/car.svg +1 -0
  16. package/resources/icons/cat.svg +1 -0
  17. package/resources/icons/child.svg +1 -0
  18. package/resources/icons/clock.svg +1 -0
  19. package/resources/icons/cloud.svg +1 -0
  20. package/resources/icons/data-bus.svg +1 -0
  21. package/resources/icons/database.svg +1 -0
  22. package/resources/icons/dog.svg +1 -0
  23. package/resources/icons/drone.svg +1 -0
  24. package/resources/icons/fish.svg +1 -0
  25. package/resources/icons/flower.svg +1 -0
  26. package/resources/icons/fog.svg +1 -0
  27. package/resources/icons/globe.svg +1 -0
  28. package/resources/icons/grip.svg +1 -0
  29. package/resources/icons/heart.svg +1 -0
  30. package/resources/icons/helicopter.svg +1 -0
  31. package/resources/icons/insect.svg +1 -0
  32. package/resources/icons/leaf.svg +1 -0
  33. package/resources/icons/link.svg +1 -0
  34. package/resources/icons/lock.svg +1 -0
  35. package/resources/icons/moon.svg +1 -0
  36. package/resources/icons/network.svg +1 -0
  37. package/resources/icons/people.svg +1 -0
  38. package/resources/icons/person.svg +1 -0
  39. package/resources/icons/pin.svg +1 -0
  40. package/resources/icons/radar.svg +1 -0
  41. package/resources/icons/rain.svg +1 -0
  42. package/resources/icons/seedling.svg +1 -0
  43. package/resources/icons/sensor.svg +1 -0
  44. package/resources/icons/server.svg +1 -0
  45. package/resources/icons/ship.svg +1 -0
  46. package/resources/icons/snow.svg +1 -0
  47. package/resources/icons/star-filled.svg +1 -0
  48. package/resources/icons/star.svg +1 -0
  49. package/resources/icons/storm.svg +1 -0
  50. package/resources/icons/sun.svg +1 -0
  51. package/resources/icons/thermometer.svg +1 -0
  52. package/resources/icons/trash.svg +1 -0
  53. package/resources/icons/tree.svg +1 -0
  54. package/resources/icons/wind.svg +1 -0
  55. package/src/assets/icons.js +75 -25
  56. package/src/core/icon-registry.js +6 -8
  57. package/src/core/theme-manager.js +17 -4
  58. package/src/core/tree-cell-layout.js +9 -0
  59. package/src/core/tree-column-model.js +27 -3
  60. package/src/core/tree-model.js +20 -0
  61. package/src/core/types.js +1 -0
  62. package/src/core/view-options.js +37 -0
  63. package/src/core/visible-row-model.js +1 -1
  64. package/src/index.d.ts +209 -5
  65. package/src/index.js +3 -0
  66. package/src/input/index.js +1 -0
  67. package/src/input/row-actions.js +115 -0
  68. package/src/input/row-reorder-input.js +168 -0
  69. package/src/input/tree-filter-bar.js +55 -0
  70. package/src/input/tree-tooltip.js +55 -0
  71. package/src/input/tree-view-input-controller.js +10 -3
  72. package/src/inspector/cell-editor-manager.js +42 -13
  73. package/src/inspector/pane-layout.js +12 -0
  74. package/src/renderers/checkbox.js +14 -0
  75. package/src/renderers/tree-row-renderer.js +103 -62
  76. package/src/tree-view-controller.js +309 -57
  77. package/src/tree-view.js +183 -0
  78. package/src/view/styles.js +14 -0
  79. package/src/assets/icons/air.svg +0 -1
  80. package/src/assets/icons/radar.svg +0 -1
  81. /package/{src/assets → resources}/icons/aircraft.svg +0 -0
  82. /package/{src/assets/icons/bus.svg → resources/icons/bus-vehicle.svg} +0 -0
  83. /package/{src/assets → resources}/icons/control.svg +0 -0
  84. /package/{src/assets → resources}/icons/damage.svg +0 -0
  85. /package/{src/assets → resources}/icons/error.svg +0 -0
  86. /package/{src/assets → resources}/icons/folder.svg +0 -0
  87. /package/{src/assets → resources}/icons/ground.svg +0 -0
  88. /package/{src/assets → resources}/icons/inspector-array.svg +0 -0
  89. /package/{src/assets → resources}/icons/inspector-object.svg +0 -0
  90. /package/{src/assets → resources}/icons/inspector-value.svg +0 -0
  91. /package/{src/assets → resources}/icons/munition.svg +0 -0
  92. /package/{src/assets → resources}/icons/placeholder.svg +0 -0
  93. /package/{src/assets → resources}/icons/point.svg +0 -0
  94. /package/{src/assets → resources}/icons/situation.svg +0 -0
  95. /package/{src/assets → resources}/icons/space.svg +0 -0
  96. /package/{src/assets → resources}/icons/subsurface.svg +0 -0
  97. /package/{src/assets → resources}/icons/surface.svg +0 -0
  98. /package/{src/assets → resources}/icons/task.svg +0 -0
  99. /package/{src/assets → resources}/icons/track.svg +0 -0
  100. /package/{src/assets → resources}/icons/warning.svg +0 -0
@@ -0,0 +1,55 @@
1
+ /** DOM tooltip shared by framework adapters and standalone canvas hosts. */
2
+ export class TreeTooltip {
3
+ constructor({ controller, host = controller.canvas?.parentElement }) {
4
+ this.controller = controller;
5
+ this.canvas = controller.canvas;
6
+ this.host = host;
7
+ if (!this.canvas || !host?.ownerDocument) throw new Error('TreeTooltip requires a mounted canvas and host');
8
+ this.element = host.ownerDocument.createElement('div');
9
+ this.element.className = 'virtual-tree-canvas-tooltip';
10
+ this.element.setAttribute('role', 'tooltip');
11
+ Object.assign(this.element.style, {
12
+ position: 'absolute', display: 'none', pointerEvents: 'none', zIndex: '20',
13
+ maxWidth: 'calc(100% - 16px)', padding: '6px 8px', borderRadius: '5px',
14
+ background: 'var(--vtc-tooltip-background, #111827)', color: 'var(--vtc-tooltip-color, #e5e7eb)',
15
+ border: '1px solid var(--vtc-tooltip-border, #374151)',
16
+ font: '12px/1.35 var(--font-family, ui-monospace, monospace)', overflowWrap: 'anywhere'
17
+ });
18
+ host.appendChild(this.element);
19
+ this.canvas.addEventListener('mousemove', this.onMove);
20
+ this.canvas.addEventListener('mouseleave', this.hide);
21
+ this.canvas.addEventListener('wheel', this.hide);
22
+ this.canvas.addEventListener('pointerdown', this.hide);
23
+ this.stops = ['viewportchange', 'filterchange', 'datachange', 'rowdrag'].map(type => controller.on(type, this.hide));
24
+ }
25
+
26
+ onMove = event => {
27
+ const rect = this.canvas.getBoundingClientRect();
28
+ const hit = this.controller.hitTest(event.clientX - rect.left, event.clientY - rect.top);
29
+ let text;
30
+ if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) {
31
+ text = this.controller.canReorderRows()
32
+ ? { rowDrag: 'Drag to reorder. Alt + Up/Down also moves the focused row.', rowUp: 'Move up', rowDown: 'Move down' }[hit.part]
33
+ : 'Clear sorting and filtering to reorder rows.';
34
+ } else text = this.controller.getTooltipForHit(hit)?.text;
35
+ if (!text || this.controller.rowReorderInput?.gesture) return this.hide();
36
+ const hostRect = this.host.getBoundingClientRect();
37
+ this.element.textContent = text;
38
+ this.element.style.display = 'block';
39
+ const x = event.clientX - hostRect.left + 12;
40
+ const y = event.clientY - hostRect.top + 14;
41
+ this.element.style.left = `${Math.max(8, Math.min(x, hostRect.width - this.element.offsetWidth - 8))}px`;
42
+ this.element.style.top = `${Math.max(8, Math.min(y, hostRect.height - this.element.offsetHeight - 8))}px`;
43
+ };
44
+
45
+ hide = () => { this.element.style.display = 'none'; };
46
+
47
+ destroy() {
48
+ this.canvas.removeEventListener('mousemove', this.onMove);
49
+ this.canvas.removeEventListener('mouseleave', this.hide);
50
+ this.canvas.removeEventListener('wheel', this.hide);
51
+ this.canvas.removeEventListener('pointerdown', this.hide);
52
+ for (const stop of this.stops) stop();
53
+ this.element.remove();
54
+ }
55
+ }
@@ -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);
@@ -43,6 +43,7 @@ export class TreeViewInputController {
43
43
  };
44
44
 
45
45
  #onMouseMove = (event) => {
46
+ if (this.controller.rowReorderInput?.gesture) return;
46
47
  if (this.resizeDrag) {
47
48
  const rect = this.canvas.getBoundingClientRect();
48
49
  const clientX = event.clientX - rect.left;
@@ -70,9 +71,10 @@ export class TreeViewInputController {
70
71
  };
71
72
 
72
73
  #onClick = (event) => {
73
- this.canvas.focus();
74
+ if (this.controller.rowReorderInput?.suppressClick) { this.controller.rowReorderInput.suppressClick = false; event.preventDefault(); return; }
74
75
  const hit = this.#hitTest(event);
75
76
  if (!hit) return;
77
+ if (['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
76
78
  if (hit.area === 'header') {
77
79
  if (hit.part === 'filter' && this.cellEditor?.handleHeaderClick(event, hit)) return;
78
80
  if (!this.resizeDrag && hit.part === 'label' && hit.column) this.controller.sortBy(hit.column.id);
@@ -83,6 +85,7 @@ export class TreeViewInputController {
83
85
  return;
84
86
  }
85
87
  if (this.cellEditor?.handleClick(event, hit)) return;
88
+ this.canvas.focus({ preventScroll: true });
86
89
  this.controller.clickNode(hit.row.nodeId, {
87
90
  shiftKey: event.shiftKey,
88
91
  ctrlKey: event.ctrlKey,
@@ -97,7 +100,9 @@ export class TreeViewInputController {
97
100
  };
98
101
 
99
102
  #onMouseDown = (event) => {
103
+ if (this.controller.rowReorderInput?.gesture) return;
100
104
  const hit = this.#hitTest(event);
105
+ if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) return;
101
106
  this.controller.setActiveHit(hit);
102
107
  if (this.cellEditor?.handlePointerDown(event, hit)) {
103
108
  event.preventDefault();
@@ -135,6 +140,8 @@ export class TreeViewInputController {
135
140
  }
136
141
 
137
142
  function cursorForHit(hit) {
143
+ if (hit?.part === 'rowDrag') return 'grab';
144
+ if (hit?.part === 'rowUp' || hit?.part === 'rowDown') return 'pointer';
138
145
  if (hit?.area === 'header' && hit.part === 'resize') return 'col-resize';
139
146
  if (hit?.area === 'header' && hit.part === 'filter') return 'text';
140
147
  if (hit?.area !== 'row') return '';
@@ -6,35 +6,58 @@ 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;
11
+ this.editable = true;
10
12
  this.overlay = null;
11
13
  this.rangeDrag = null;
14
+ this.overlayKind = null;
15
+ this.overlayNode = null;
16
+ this.stopFilter = controller.on('filterchange', ({ detail }) => {
17
+ if (this.overlayKind === 'filter' && this.overlay) this.overlay.value = detail.query;
18
+ });
12
19
  this.onMouseMove = this.#onMouseMove.bind(this);
13
20
  this.onMouseUp = this.#onMouseUp.bind(this);
14
21
  }
15
22
 
23
+ setEditable(enabled) {
24
+ this.editable = enabled;
25
+ if (!enabled) this.close();
26
+ }
27
+
16
28
  destroy() {
29
+ this.stopFilter();
17
30
  this.#removeOverlay();
18
- window.removeEventListener('mousemove', this.onMouseMove);
19
- window.removeEventListener('mouseup', this.onMouseUp);
31
+ this.view.removeEventListener('mousemove', this.onMouseMove);
32
+ this.view.removeEventListener('mouseup', this.onMouseUp);
20
33
  }
21
34
 
22
35
  close() {
23
36
  this.#removeOverlay();
24
37
  this.rangeDrag = null;
25
- window.removeEventListener('mousemove', this.onMouseMove);
26
- 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();
27
50
  }
28
51
 
29
52
  handlePointerDown(event, hit) {
30
- if (!this.#isEditableHit(hit)) return false;
53
+ if (!this.editable || !this.#isEditableHit(hit)) return false;
31
54
  const data = hit.row ? this.controller.model.nodes[hit.row.nodeIndex]?.data : null;
32
55
  if (!data || data.readonly || data.disabled) return false;
33
56
  if (data.editorType === 'range' && hit.part === 'range') {
34
- this.rangeDrag = { hit, data };
57
+ this.rangeDrag = { hit, data, node: this.controller.model.nodes[hit.row.nodeIndex] };
35
58
  this.#updateRangeFromEvent(event);
36
- window.addEventListener('mousemove', this.onMouseMove);
37
- window.addEventListener('mouseup', this.onMouseUp);
59
+ this.view.addEventListener('mousemove', this.onMouseMove);
60
+ this.view.addEventListener('mouseup', this.onMouseUp);
38
61
  return true;
39
62
  }
40
63
  if (shouldOpenOverlayOnPointerDown(data, hit)) {
@@ -46,7 +69,7 @@ export class CellEditorManager {
46
69
  }
47
70
 
48
71
  handleClick(event, hit) {
49
- if (!this.#isEditableHit(hit)) return false;
72
+ if (!this.editable || !this.#isEditableHit(hit)) return false;
50
73
  const node = this.controller.model.nodes[hit.row.nodeIndex];
51
74
  const data = node?.data;
52
75
  if (!data || data.disabled) return false;
@@ -108,7 +131,7 @@ export class CellEditorManager {
108
131
  });
109
132
  let committed = false;
110
133
  const commit = () => {
111
- if (committed) return;
134
+ if (committed || this.overlay !== element) return;
112
135
  committed = true;
113
136
  const nextValue = parseEditorValue(element, data);
114
137
  this.controller.updateInspectorValue(node.id, nextValue, data.editorType);
@@ -125,6 +148,8 @@ export class CellEditorManager {
125
148
  ensureOverlayHost(this.host);
126
149
  this.host.append(element);
127
150
  this.overlay = element;
151
+ this.overlayKind = 'value';
152
+ this.overlayNode = node;
128
153
  element.focus({ preventScroll: true });
129
154
  element.select?.();
130
155
  if (options.showPicker && element.showPicker) {
@@ -180,6 +205,7 @@ export class CellEditorManager {
180
205
  ensureOverlayHost(this.host);
181
206
  this.host.append(element);
182
207
  this.overlay = element;
208
+ this.overlayKind = 'filter';
183
209
  element.focus({ preventScroll: true });
184
210
  element.select();
185
211
  }
@@ -206,8 +232,8 @@ export class CellEditorManager {
206
232
 
207
233
  #onMouseUp() {
208
234
  this.rangeDrag = null;
209
- window.removeEventListener('mousemove', this.onMouseMove);
210
- window.removeEventListener('mouseup', this.onMouseUp);
235
+ this.view.removeEventListener('mousemove', this.onMouseMove);
236
+ this.view.removeEventListener('mouseup', this.onMouseUp);
211
237
  }
212
238
 
213
239
  #isEditableHit(hit) {
@@ -257,8 +283,11 @@ export class CellEditorManager {
257
283
  }
258
284
 
259
285
  #removeOverlay() {
260
- this.overlay?.remove();
286
+ const overlay = this.overlay;
261
287
  this.overlay = null;
288
+ this.overlayKind = null;
289
+ this.overlayNode = null;
290
+ overlay?.remove();
262
291
  }
263
292
 
264
293
  #toggleRangeMinMax(node, data) {
@@ -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
 
@@ -10,7 +14,8 @@ export class TreeRowRenderer {
10
14
  this.scene = null;
11
15
  this.iconRegistry = iconRegistry ?? new IconRegistry();
12
16
  this.renderedRows = 0;
13
- this.iconRenderQueued = false;
17
+ this.iconRenderFrame = null;
18
+ this.invalidate = null;
14
19
  this.preparedIconThemeKey = null;
15
20
  this.stopIconListener = this.iconRegistry.onChange?.(() => this.#scheduleIconRender()) ?? null;
16
21
  }
@@ -26,7 +31,15 @@ export class TreeRowRenderer {
26
31
  this.scene = scene;
27
32
  }
28
33
 
34
+ setInvalidationHandler(invalidate) { this.invalidate = invalidate; }
35
+
29
36
  destroy() {
37
+ if (this.iconRenderFrame !== null) {
38
+ const view = this.canvas?.ownerDocument?.defaultView ?? globalThis;
39
+ (view.cancelAnimationFrame?.bind(view) ?? clearTimeout)(this.iconRenderFrame);
40
+ }
41
+ this.iconRenderFrame = null;
42
+ this.invalidate = null;
30
43
  this.stopIconListener?.();
31
44
  this.stopIconListener = null;
32
45
  this.canvas = null;
@@ -42,7 +55,7 @@ export class TreeRowRenderer {
42
55
  if (scene?.rows) this.scene = scene;
43
56
  if (!this.canvas || !this.ctx || !this.scene) return;
44
57
  const { viewport, theme } = this.scene;
45
- const dpr = Math.max(1, window.devicePixelRatio || 1);
58
+ const dpr = Math.max(1, this.canvas.ownerDocument?.defaultView?.devicePixelRatio || 1);
46
59
  const viewportWidth = Math.max(0, viewport.viewportWidth);
47
60
  const viewportHeight = Math.max(0, viewport.viewportHeight);
48
61
  if (viewportWidth <= 0 || viewportHeight <= 0) return;
@@ -65,15 +78,18 @@ export class TreeRowRenderer {
65
78
  this.#drawHeader(ctx);
66
79
  this.#drawRows(ctx);
67
80
  this.#drawStickyRows(ctx);
81
+ this.#drawRowDrop(ctx);
68
82
  ctx.restore();
69
83
  }
70
84
 
71
85
  #scheduleIconRender() {
72
- if (this.iconRenderQueued) return;
73
- this.iconRenderQueued = true;
74
- const schedule = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0));
75
- schedule(() => {
76
- this.iconRenderQueued = false;
86
+ if (!this.canvas) return;
87
+ if (this.invalidate) return this.invalidate();
88
+ if (this.iconRenderFrame !== null) return;
89
+ const view = this.canvas.ownerDocument?.defaultView ?? globalThis;
90
+ const schedule = view.requestAnimationFrame?.bind(view) ?? (callback => setTimeout(callback, 0));
91
+ this.iconRenderFrame = schedule(() => {
92
+ this.iconRenderFrame = null;
77
93
  this.render();
78
94
  });
79
95
  }
@@ -107,7 +123,7 @@ export class TreeRowRenderer {
107
123
  ctx.textBaseline = 'middle';
108
124
 
109
125
  for (const column of columns) {
110
- if (headerFilter && column.kind === 'inspectorPane') {
126
+ if (headerFilter && column === columns.find(item => item.kind !== 'rowOrder' && item.id !== '__vtc_actions')) {
111
127
  this.#drawHeaderFilter(ctx, column, viewport, theme, filterQuery);
112
128
  } else {
113
129
  ctx.fillStyle = colors.textMuted;
@@ -145,7 +161,7 @@ export class TreeRowRenderer {
145
161
  ctx.strokeStyle = colors.border;
146
162
  ctx.stroke();
147
163
  ctx.fillStyle = filterQuery ? colors.text : colors.textMuted;
148
- drawTruncatedText(ctx, filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
164
+ drawTruncatedText(ctx, filterQuery || 'Filter rows', x + 8, viewport.headerHeight / 2, width - 16);
149
165
  }
150
166
 
151
167
  #drawSortIndicator(ctx, x, y, direction, theme) {
@@ -200,6 +216,7 @@ export class TreeRowRenderer {
200
216
  }
201
217
  ctx.restore();
202
218
 
219
+ if (!stickyRows.some(row => row.stickyY > row.y - viewport.scrollY)) return;
203
220
  const bottom = viewport.headerHeight + height;
204
221
  ctx.strokeStyle = theme.colors.border;
205
222
  ctx.beginPath();
@@ -222,7 +239,8 @@ export class TreeRowRenderer {
222
239
  const visibleX = viewport.scrollX;
223
240
  const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
224
241
 
225
- 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;
226
244
  ctx.fillRect(visibleX, y, visibleWidth, row.height);
227
245
 
228
246
  this.#drawIndentGuides(ctx, row, colors);
@@ -249,6 +267,12 @@ export class TreeRowRenderer {
249
267
  ctx.stroke();
250
268
  }
251
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
+
252
276
  ctx.strokeStyle = colors.border;
253
277
  ctx.beginPath();
254
278
  ctx.moveTo(visibleX, y + row.height + 0.5);
@@ -268,7 +292,8 @@ export class TreeRowRenderer {
268
292
  cell.column.render(ctx, cell);
269
293
  return;
270
294
  }
271
- if (cell.column.kind === 'tree') this.#drawTreeCell(ctx, cell);
295
+ if (cell.column.kind === 'rowOrder') this.#drawRowOrder(ctx, cell);
296
+ else if (cell.column.kind === 'tree') this.#drawTreeCell(ctx, cell);
272
297
  else if (cell.column.kind === 'inspectorPane') this.#drawInspectorPaneCell(ctx, cell);
273
298
  else if (cell.column.kind === 'inspectorValue') this.#drawInspectorValueCell(ctx, cell);
274
299
  else if (cell.column.kind === 'inspectorType') this.#drawInspectorTypeCell(ctx, cell);
@@ -278,6 +303,43 @@ export class TreeRowRenderer {
278
303
  else this.#drawTextCell(ctx, cell);
279
304
  }
280
305
 
306
+ #drawRowOrder(ctx, { node, row, rect, theme }) {
307
+ if (node.reorderable === false) return;
308
+ const siblings = this.scene.childrenByParent?.get(node.parentId ?? null) ?? [];
309
+ const enabled = this.scene.canReorderRows && siblings.length > 1;
310
+ const cy = rect.y + row.height / 2;
311
+ ctx.save();
312
+ ctx.strokeStyle = theme.colors.textMuted;
313
+ ctx.fillStyle = theme.colors.textMuted;
314
+ ctx.lineWidth = 1.5;
315
+ ctx.globalAlpha = enabled || this.scene.rowDrag?.(node, this.scene.dynamicState.get(node.id) ?? {}) != null ? 1 : 0.3;
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);
317
+ for (const [center, direction, available] of [[36, -1, siblings[0] !== node.id], [60, 1, siblings.at(-1) !== node.id]]) {
318
+ ctx.globalAlpha = enabled && available ? 1 : 0.3;
319
+ ctx.beginPath();
320
+ ctx.moveTo(rect.x + center - 4, cy - direction * 2);
321
+ ctx.lineTo(rect.x + center, cy + direction * 2);
322
+ ctx.lineTo(rect.x + center + 4, cy - direction * 2);
323
+ ctx.stroke();
324
+ }
325
+ ctx.restore();
326
+ }
327
+
328
+ #drawRowDrop(ctx) {
329
+ const { rowDrop, viewport, theme } = this.scene;
330
+ if (!rowDrop) return;
331
+ const y = viewport.headerHeight + rowDrop.y - viewport.scrollY;
332
+ if (y < viewport.headerHeight || y > viewport.headerHeight + viewport.rowViewportHeight) return;
333
+ ctx.save();
334
+ ctx.strokeStyle = theme.colors.focus;
335
+ ctx.lineWidth = 2;
336
+ ctx.beginPath();
337
+ ctx.moveTo(3, y);
338
+ ctx.lineTo(viewport.contentViewportWidth - 3, y);
339
+ ctx.stroke();
340
+ ctx.restore();
341
+ }
342
+
281
343
  #drawInspectorPaneCell(ctx, { node, row, rect, theme, style, hovered, hoverPart, activePart }) {
282
344
  const visibleRight = this.scene.viewport.scrollX + (this.scene.viewport.contentViewportWidth ?? this.scene.viewport.viewportWidth);
283
345
  rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
@@ -343,6 +405,7 @@ export class TreeRowRenderer {
343
405
  const meta = data.meta ?? {};
344
406
  const disabled = data.disabled;
345
407
  const readonly = data.readonly;
408
+ const valueColor = resolveValueColor(theme, data.value, data.editorType === 'select' ? 'enum' : data.valueType);
346
409
  const x = rect.x + 10;
347
410
  const y = rect.y + 5;
348
411
  const fullWidth = Math.max(24, rect.width - 20);
@@ -362,7 +425,7 @@ export class TreeRowRenderer {
362
425
  }
363
426
 
364
427
  if (data.editorType === 'checkbox') {
365
- 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);
366
429
  } else if (data.editorType === 'range') {
367
430
  this.#drawInspectorRange(ctx, x, rect.y + rect.height / 2 - 4, width, data, theme, {
368
431
  hoveredNumber: hovered && hoverPart === 'number',
@@ -373,7 +436,7 @@ export class TreeRowRenderer {
373
436
  ctx.fillRect(x, y + 2, 28, height - 4);
374
437
  ctx.strokeStyle = theme.colors.border;
375
438
  ctx.strokeRect(x + 0.5, y + 2.5, 28, height - 4);
376
- 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);
377
440
  } else if (data.editorType === 'button') {
378
441
  const buttonWidth = meta.fullWidthButton ? width : Math.min(width, 140);
379
442
  this.#drawControlSurface(ctx, x, y, buttonWidth, height, theme, {
@@ -392,17 +455,17 @@ export class TreeRowRenderer {
392
455
  active: activePart === 'editor',
393
456
  disabled: readonly || disabled,
394
457
  });
395
- 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);
396
459
  this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
397
460
  } else {
398
461
  if (numeric.unit) {
399
462
  ctx.font = theme.monoFont ?? theme.font;
400
- ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
463
+ ctx.fillStyle = valueColor;
401
464
  ctx.textAlign = 'right';
402
465
  drawTruncatedText(ctx, data.valueText, x + width, rect.y + rect.height / 2, width);
403
466
  ctx.textAlign = 'left';
404
467
  } else {
405
- 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);
406
469
  }
407
470
  }
408
471
 
@@ -432,17 +495,6 @@ export class TreeRowRenderer {
432
495
  this.#drawMutedText(ctx, node.data?.meta?.description ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
433
496
  }
434
497
 
435
- #drawCheckbox(ctx, x, y, checked, theme) {
436
- roundRect(ctx, x, y, 16, 16, 3);
437
- ctx.fillStyle = theme.colors.progressTrack;
438
- ctx.fill();
439
- ctx.strokeStyle = checked ? theme.colors.progressFill : theme.colors.textMuted;
440
- ctx.stroke();
441
- if (!checked) return;
442
- ctx.fillStyle = theme.colors.progressFill;
443
- roundRect(ctx, x + 4, y + 4, 8, 8, 1.5);
444
- ctx.fill();
445
- }
446
498
 
447
499
  #drawInspectorRange(ctx, x, y, width, data, theme, state = {}) {
448
500
  const meta = data.meta ?? {};
@@ -457,7 +509,7 @@ export class TreeRowRenderer {
457
509
  hovered: Boolean(state.hoveredNumber),
458
510
  active: Boolean(state.activeNumber),
459
511
  });
460
- ctx.fillStyle = theme.colors.text;
512
+ ctx.fillStyle = resolveValueColor(theme, data.value);
461
513
  ctx.font = theme.monoFont ?? theme.font;
462
514
  ctx.textAlign = 'right';
463
515
  drawTruncatedText(ctx, String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
@@ -499,8 +551,8 @@ export class TreeRowRenderer {
499
551
  ctx.fill();
500
552
  }
501
553
 
502
- #drawMutedText(ctx, text, x, y, width, theme, readonly = false, font = null) {
503
- 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);
504
556
  ctx.font = font ?? theme.font;
505
557
  ctx.textBaseline = 'middle';
506
558
  ctx.textAlign = 'left';
@@ -509,19 +561,20 @@ export class TreeRowRenderer {
509
561
 
510
562
  #drawTreeCell(ctx, { node, row, rect, theme, style }) {
511
563
  const colors = theme.colors;
512
- const x = rect.x + row.depth * theme.indentWidth;
564
+ const layout = treeCellLayout(row.depth, theme.indentWidth, this.scene.childrenByParent.size > 1);
513
565
  const cy = rect.y + rect.height / 2;
514
- this.#drawChevron(ctx, x + 10, cy, row, colors);
515
- 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);
516
568
  ctx.fillStyle = colors.text;
517
569
  ctx.font = theme.font;
518
570
  ctx.textBaseline = 'middle';
519
571
  ctx.textAlign = 'left';
520
- 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));
521
573
  }
522
574
 
523
575
  #drawStatusCell(ctx, { rect, style, theme }) {
524
- 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));
525
578
  const x = rect.x + (rect.width - badgeWidth) / 2;
526
579
  const y = rect.y + (rect.height - 17) / 2;
527
580
  const statusColor = style.status.color;
@@ -532,7 +585,7 @@ export class TreeRowRenderer {
532
585
  ctx.strokeStyle = mixColor(theme.colors.border, statusColor, 0.42);
533
586
  ctx.stroke();
534
587
  ctx.fillStyle = mixColor(theme.colors.text, statusColor, 0.36);
535
- ctx.font = '10px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace';
588
+ ctx.font = theme.font;
536
589
  ctx.textAlign = 'center';
537
590
  ctx.textBaseline = 'middle';
538
591
  drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8.5, badgeWidth - 8);
@@ -574,10 +627,14 @@ export class TreeRowRenderer {
574
627
 
575
628
  #drawTextCell(ctx, { node, state, rect, column, theme }) {
576
629
  let value = column.value(node, state);
577
- if (column.kind === 'updated' && typeof value === 'number') value = formatTime(value);
578
- if (typeof value === 'number') value = Math.round(value).toString();
579
- ctx.fillStyle = column.kind === 'type' ? resolveNodeStyle(theme, node, state).color : theme.colors.textMuted;
580
- 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;
581
638
  ctx.textBaseline = 'middle';
582
639
  ctx.textAlign = column.align;
583
640
  const x = column.align === 'right' ? rect.x + rect.width - 10 : column.align === 'center' ? rect.x + rect.width / 2 : rect.x + 10;
@@ -599,13 +656,8 @@ export class TreeRowRenderer {
599
656
  }
600
657
 
601
658
  #drawChevron(ctx, x, y, row, colors) {
602
- ctx.fillStyle = row.hasChildren ? colors.chevron : colors.textMuted;
603
- if (!row.hasChildren) {
604
- ctx.beginPath();
605
- ctx.arc(x, y, 1.5, 0, Math.PI * 2);
606
- ctx.fill();
607
- return;
608
- }
659
+ if (!row.hasChildren) return;
660
+ ctx.fillStyle = colors.chevron;
609
661
  ctx.beginPath();
610
662
  if (row.expanded) {
611
663
  ctx.moveTo(x - 5, y - 2);
@@ -632,21 +684,6 @@ function resolveNodeStyle(theme, node, state = {}) {
632
684
  };
633
685
  }
634
686
 
635
- function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
636
- const safeWidth = Math.max(1, width);
637
- const rightPadding = 14;
638
- if (editorType === 'checkbox') {
639
- const editorWidth = 34;
640
- return { editorLeft: Math.max(64, safeWidth - rightPadding - editorWidth), editorWidth };
641
- }
642
- const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
643
- const minLabelEnd = Math.max(88, depth * indentWidth + 104);
644
- const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
645
- const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
646
- const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
647
- const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
648
- return { editorLeft, editorWidth };
649
- }
650
687
 
651
688
  function clamp01(value) {
652
689
  return Math.max(0, Math.min(1, value));
@@ -744,3 +781,7 @@ function formatTime(value) {
744
781
  if (!Number.isFinite(time)) return '';
745
782
  return timeFormatter.format(time);
746
783
  }
784
+
785
+ function resolveValueColor(theme, value, type) {
786
+ return theme.valueColors?.[type ?? (value === null ? 'null' : typeof value)] ?? theme.colors.text;
787
+ }