virtual-tree-canvas 0.3.1 → 0.3.2

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.
package/README.md CHANGED
@@ -131,7 +131,8 @@ tree.setModel(model, meta, {
131
131
  presentation: 'pane',
132
132
  flatRoot: true, // render root properties directly
133
133
  enforceMeta: true, // fields without metadata are readonly/disabled
134
- filter: true // use the header as a filter input
134
+ filter: true, // use the header as a filter input
135
+ markUpdated: false // do not show update dots for local user edits
135
136
  });
136
137
  ```
137
138
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virtual-tree-canvas",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "High-performance Canvas2D virtual tree/table widget for very large hierarchical datasets.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "scripts": {
31
31
  "demo": "python3 -m http.server 4173 --bind localhost",
32
- "test": "node --test test/tree-view-controller.test.js test/theme-icons.test.js test/tree-columns.test.js test/benchmark.test.js test/tree-worker-operations.test.js test/model-inspector.test.js"
32
+ "test": "node --test test/tree-view-controller.test.js test/theme-icons.test.js test/tree-columns.test.js test/benchmark.test.js test/tree-worker-operations.test.js test/model-inspector.test.js test/dynamic-state.test.js"
33
33
  },
34
34
  "keywords": [
35
35
  "tree",
@@ -0,0 +1,47 @@
1
+ const hasOwn = Object.prototype.hasOwnProperty;
2
+
3
+ const knownDynamicStateKeys = new Set([
4
+ 'value',
5
+ 'status',
6
+ 'progress',
7
+ 'pulse',
8
+ 'color',
9
+ 'selected',
10
+ 'highlighted',
11
+ 'visible',
12
+ 'updated',
13
+ 'updatedAt',
14
+ 'icon',
15
+ ]);
16
+
17
+ /**
18
+ * @param {import('./types.js').NodeDynamicState} target
19
+ * @param {import('./types.js').NodeDynamicState} state
20
+ */
21
+ export function mergeDynamicState(target, state) {
22
+ if (!state) return target;
23
+
24
+ if (hasOwn.call(state, 'value') && target.value !== state.value) target.value = state.value;
25
+ if (hasOwn.call(state, 'status') && target.status !== state.status) target.status = state.status;
26
+ if (hasOwn.call(state, 'progress') && target.progress !== state.progress) target.progress = state.progress;
27
+ if (hasOwn.call(state, 'pulse') && target.pulse !== state.pulse) target.pulse = state.pulse;
28
+ if (hasOwn.call(state, 'color') && target.color !== state.color) target.color = state.color;
29
+ if (hasOwn.call(state, 'selected') && target.selected !== state.selected) target.selected = state.selected;
30
+ if (hasOwn.call(state, 'highlighted') && target.highlighted !== state.highlighted) target.highlighted = state.highlighted;
31
+ if (hasOwn.call(state, 'visible') && target.visible !== state.visible) target.visible = state.visible;
32
+ if (hasOwn.call(state, 'updated') && target.updated !== state.updated) target.updated = state.updated;
33
+ if (hasOwn.call(state, 'updatedAt') && target.updatedAt !== state.updatedAt) target.updatedAt = state.updatedAt;
34
+ if (hasOwn.call(state, 'icon') && target.icon !== state.icon) target.icon = state.icon;
35
+
36
+ for (const key in state) {
37
+ if (!hasOwn.call(state, key) || knownDynamicStateKeys.has(key)) continue;
38
+ if (target[key] !== state[key]) target[key] = state[key];
39
+ }
40
+
41
+ return target;
42
+ }
43
+
44
+ /** @param {import('./types.js').NodeDynamicState} state */
45
+ export function cloneDynamicState(state) {
46
+ return mergeDynamicState({}, state);
47
+ }
@@ -84,6 +84,9 @@ export class IconRegistry {
84
84
  this.register('control', drawControl);
85
85
  this.register('situation', drawSituation);
86
86
  this.register('damage', drawDamage);
87
+ this.register('inspector-object', drawInspectorObject);
88
+ this.register('inspector-array', drawInspectorArray);
89
+ this.register('inspector-value', drawInspectorValue);
87
90
  }
88
91
  }
89
92
 
@@ -420,3 +423,44 @@ function drawDamage(ctx, x, y, size, color) {
420
423
  ctx.lineTo(x + size * 0.82, y + size * 0.18);
421
424
  ctx.stroke();
422
425
  }
426
+
427
+ function drawInspectorObject(ctx, x, y, size, color) {
428
+ ctx.strokeStyle = color;
429
+ ctx.lineWidth = 1.2;
430
+ const left = x + size * 0.24;
431
+ const top = y + size * 0.22;
432
+ const width = size * 0.52;
433
+ const height = size * 0.56;
434
+ ctx.strokeRect(left, top, width, height);
435
+ ctx.beginPath();
436
+ ctx.moveTo(left + width * 0.28, top);
437
+ ctx.lineTo(left + width * 0.28, top + height);
438
+ ctx.moveTo(left + width * 0.72, top);
439
+ ctx.lineTo(left + width * 0.72, top + height);
440
+ ctx.stroke();
441
+ }
442
+
443
+ function drawInspectorArray(ctx, x, y, size, color) {
444
+ ctx.strokeStyle = color;
445
+ ctx.lineWidth = 1.2;
446
+ const cx = x + size * 0.5;
447
+ const cy = y + size * 0.5;
448
+ ctx.beginPath();
449
+ ctx.arc(cx - size * 0.18, cy, size * 0.18, 0, Math.PI * 2);
450
+ ctx.arc(cx + size * 0.18, cy, size * 0.18, 0, Math.PI * 2);
451
+ ctx.stroke();
452
+ }
453
+
454
+ function drawInspectorValue(ctx, x, y, size, color) {
455
+ const cx = x + size * 0.5;
456
+ const cy = y + size * 0.5;
457
+ ctx.strokeStyle = color;
458
+ ctx.fillStyle = color;
459
+ ctx.lineWidth = 1.25;
460
+ ctx.beginPath();
461
+ ctx.arc(cx, cy, size * 0.3, 0, Math.PI * 2);
462
+ ctx.stroke();
463
+ ctx.beginPath();
464
+ ctx.arc(cx, cy, size * 0.11, 0, Math.PI * 2);
465
+ ctx.fill();
466
+ }
@@ -1,3 +1,5 @@
1
+ import { cloneDynamicState, mergeDynamicState } from './dynamic-state.js';
2
+
1
3
  export class PatchBatcher {
2
4
  constructor() {
3
5
  /** @type {Map<string, import('./types.js').NodeDynamicState>} */
@@ -7,8 +9,8 @@ export class PatchBatcher {
7
9
  /** @param {string} id @param {import('./types.js').NodeDynamicState} state */
8
10
  set(id, state) {
9
11
  const current = this.pending.get(id);
10
- if (current) Object.assign(current, state);
11
- else this.pending.set(id, { ...state });
12
+ if (current) mergeDynamicState(current, state);
13
+ else this.pending.set(id, cloneDynamicState(state));
12
14
  }
13
15
 
14
16
  /** @param {Array<import('./types.js').DynamicPatch>} patches */
@@ -26,4 +28,3 @@ export class PatchBatcher {
26
28
  return this.pending.size;
27
29
  }
28
30
  }
29
-
@@ -1,7 +1,8 @@
1
1
  export const darkTheme = {
2
2
  rowHeight: 28,
3
3
  indentWidth: 18,
4
- font: '12px system-ui, sans-serif',
4
+ font: '12px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
5
+ monoFont: '12px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace',
5
6
  colors: {
6
7
  background: '#0b1020',
7
8
  row: '#0b1020',
@@ -37,6 +38,12 @@ export const darkTheme = {
37
38
  warning: { icon: 'warning', color: '#facc15' },
38
39
  error: { icon: 'error', color: '#ef4444' },
39
40
  task: { icon: 'task', color: '#f97316' },
41
+ object: { icon: 'inspector-object', color: '#7dd3fc' },
42
+ array: { icon: 'inspector-array', color: '#a78bfa' },
43
+ string: { icon: 'inspector-value', color: '#94a3b8' },
44
+ number: { icon: 'inspector-value', color: '#34d399' },
45
+ boolean: { icon: 'inspector-value', color: '#22c55e' },
46
+ null: { icon: 'inspector-value', color: '#64748b' },
40
47
  },
41
48
  statuses: {
42
49
  0: { label: 'OK', color: '#22c55e' },
@@ -1,4 +1,5 @@
1
1
  import { TreeIndex } from './tree-index.js';
2
+ import { mergeDynamicState } from './dynamic-state.js';
2
3
 
3
4
  /**
4
5
  * Owns structural nodes, expanded state, and per-node dynamic state.
@@ -68,7 +69,7 @@ export class TreeModel extends EventTarget {
68
69
  for (const patch of patches) {
69
70
  const current = this.dynamicState.get(patch.id);
70
71
  if (!current) continue;
71
- Object.assign(current, patch.state);
72
+ mergeDynamicState(current, patch.state);
72
73
  }
73
74
  }
74
75
 
package/src/core/types.js CHANGED
@@ -20,6 +20,9 @@
20
20
  * @property {boolean} [selected]
21
21
  * @property {boolean} [highlighted]
22
22
  * @property {boolean} [visible]
23
+ * @property {boolean} [updated]
24
+ * @property {number} [updatedAt]
25
+ * @property {string} [icon]
23
26
  */
24
27
 
25
28
  /**
@@ -41,4 +44,3 @@
41
44
  * @property {string} id
42
45
  * @property {NodeDynamicState} state
43
46
  */
44
-
@@ -61,18 +61,24 @@ export class TreeViewInputController {
61
61
  return;
62
62
  }
63
63
  const hit = this.#hitTest(event);
64
- this.canvas.style.cursor = hit?.area === 'header' && hit.part === 'resize' ? 'col-resize' : '';
64
+ this.canvas.style.cursor = cursorForHit(hit);
65
65
  const id = hit?.row?.nodeId ?? null;
66
- if (id === this.hoveredId) return;
66
+ const key = hitKey(hit);
67
+ if (id === this.hoveredId && key === this.hoveredHitKey) return;
67
68
  this.hoveredId = id;
68
- this.controller?.setHover(id);
69
+ this.hoveredHitKey = key;
70
+ if (this.controller?.setHoverHit) this.controller.setHoverHit(hit);
71
+ else this.controller?.setHover(id);
69
72
  this.onHoverChanged?.(id);
70
73
  };
71
74
 
72
75
  #onMouseLeave = () => {
73
76
  if (!this.resizeDrag) this.canvas.style.cursor = '';
74
77
  this.hoveredId = null;
75
- this.controller?.setHover(null);
78
+ this.hoveredHitKey = null;
79
+ this.controller?.setActiveHit?.(null);
80
+ if (this.controller?.setHoverHit) this.controller.setHoverHit(null);
81
+ else this.controller?.setHover(null);
76
82
  this.onHoverChanged?.(null);
77
83
  };
78
84
 
@@ -118,6 +124,7 @@ export class TreeViewInputController {
118
124
 
119
125
  #onMouseDown = (event) => {
120
126
  const hit = this.#hitTest(event);
127
+ this.controller?.setActiveHit?.(hit);
121
128
  if (this.cellEditor?.handlePointerDown(event, hit)) {
122
129
  event.preventDefault();
123
130
  return;
@@ -136,6 +143,7 @@ export class TreeViewInputController {
136
143
  #onMouseUp = () => {
137
144
  this.resizeDrag = null;
138
145
  this.canvas.style.cursor = '';
146
+ this.controller?.setActiveHit?.(null);
139
147
  };
140
148
 
141
149
  #onKeyDown = (event) => {
@@ -234,3 +242,18 @@ export class TreeViewInputController {
234
242
  return { area: 'row', row, x, y, part };
235
243
  }
236
244
  }
245
+
246
+ function cursorForHit(hit) {
247
+ if (hit?.area === 'header' && hit.part === 'resize') return 'col-resize';
248
+ if (hit?.area === 'header' && hit.part === 'filter') return 'text';
249
+ if (hit?.area !== 'row') return '';
250
+ if (hit.part === 'button' || hit.part === 'checkbox' || hit.part === 'arrayAdd' || hit.part === 'arrayRemove' || hit.part === 'chevron') return 'pointer';
251
+ if (hit.part === 'editor' || hit.part === 'number') return 'text';
252
+ if (hit.part === 'range') return 'ew-resize';
253
+ return '';
254
+ }
255
+
256
+ function hitKey(hit) {
257
+ if (!hit?.row || !hit.column) return null;
258
+ return `${hit.row.nodeId}:${hit.column.id}:${hit.part}`;
259
+ }
@@ -78,6 +78,7 @@ export class CellEditorManager {
78
78
  const rect = this.#clampRectToHost(this.#overlayRect(hit), 4);
79
79
  const hostRect = this.host.getBoundingClientRect();
80
80
  const element = createEditorElement(data);
81
+ element.className = `vtc-editor vtc-editor-${data.editorType}`;
81
82
  Object.assign(element.style, {
82
83
  position: 'absolute',
83
84
  left: `${rect.x - hostRect.left}px`,
@@ -90,12 +91,14 @@ export class CellEditorManager {
90
91
  boxSizing: 'border-box',
91
92
  margin: '0',
92
93
  outline: 'none',
93
- border: '1px solid #38bdf8',
94
- borderRadius: '3px',
95
- background: '#0b1020',
94
+ appearance: 'none',
95
+ border: '1px solid rgba(56, 189, 248, 0.65)',
96
+ borderRadius: '6px',
97
+ background: '#0f172a',
96
98
  color: '#e5e7eb',
97
- font: '12px system-ui, sans-serif',
98
- padding: data.editorType === 'color' ? '0 2px' : '0 6px',
99
+ boxShadow: '0 0 0 1px rgba(15, 23, 42, 0.8), 0 8px 20px rgba(0, 0, 0, 0.28)',
100
+ font: editorFont(data),
101
+ padding: data.editorType === 'color' ? '0 2px' : data.editorType === 'select' ? '0 22px 0 8px' : '0 8px',
99
102
  textAlign: data.editorType === 'number' || data.editorType === 'range' ? 'right' : 'left',
100
103
  });
101
104
  const commit = () => {
@@ -154,7 +157,7 @@ export class CellEditorManager {
154
157
  borderRadius: '4px',
155
158
  background: '#0b1020',
156
159
  color: '#e5e7eb',
157
- font: '12px system-ui, sans-serif',
160
+ font: SANS_FONT,
158
161
  padding: '0 8px',
159
162
  });
160
163
  element.addEventListener('input', () => this.controller.setFilter(element.value));
@@ -267,7 +270,10 @@ function createEditorElement(data) {
267
270
  return select;
268
271
  }
269
272
  const input = document.createElement('input');
270
- input.type = data.editorType === 'color' ? 'color' : data.editorType === 'number' || data.editorType === 'range' ? 'number' : 'text';
273
+ input.type = data.editorType === 'color' ? 'color' : 'text';
274
+ if (data.editorType === 'number' || data.editorType === 'range') {
275
+ input.inputMode = data.meta.integer ? 'numeric' : 'decimal';
276
+ }
271
277
  input.value = data.value ?? '';
272
278
  if (data.meta.min !== undefined) input.min = data.meta.min;
273
279
  if (data.meta.max !== undefined) input.max = data.meta.max;
@@ -288,7 +294,13 @@ function ensureOverlayHost(host) {
288
294
  }
289
295
 
290
296
  function parseEditorValue(element, data) {
291
- if (data.editorType === 'number' || data.editorType === 'range') return data.meta.integer ? Number.parseInt(element.value, 10) : Number(element.value);
297
+ if (data.editorType === 'number' || data.editorType === 'range') {
298
+ const value = data.meta.integer ? Number.parseInt(element.value, 10) : Number(element.value);
299
+ if (!Number.isFinite(value)) return data.value;
300
+ const min = Number.isFinite(data.meta.min) ? data.meta.min : -Infinity;
301
+ const max = Number.isFinite(data.meta.max) ? data.meta.max : Infinity;
302
+ return Math.max(min, Math.min(max, value));
303
+ }
292
304
  if (data.editorType === 'select') {
293
305
  const values = Object.values(data.meta.options ?? {});
294
306
  const match = values.find((value) => String(value) === element.value);
@@ -296,3 +308,10 @@ function parseEditorValue(element, data) {
296
308
  }
297
309
  return element.value;
298
310
  }
311
+
312
+ const SANS_FONT = '12px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
313
+ const MONO_FONT = '12px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace';
314
+
315
+ function editorFont(data) {
316
+ return data.editorType === 'number' || data.editorType === 'range' ? MONO_FONT : SANS_FONT;
317
+ }
@@ -78,7 +78,7 @@ export class TreeRowRenderer {
78
78
  drawTruncatedText(ctx, column.label, column.x + 10, viewport.headerHeight / 2, column.width - 20);
79
79
  }
80
80
  if (sort?.columnId === column.id && sort.direction) {
81
- drawTruncatedText(ctx, sort.direction === 'asc' ? '^' : 'v', column.x + column.width - 16, viewport.headerHeight / 2, 12);
81
+ this.#drawSortIndicator(ctx, column.x + column.width - 16, viewport.headerHeight / 2, sort.direction, theme);
82
82
  }
83
83
  ctx.strokeStyle = colors.border;
84
84
  ctx.beginPath();
@@ -112,6 +112,22 @@ export class TreeRowRenderer {
112
112
  drawTruncatedText(ctx, filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
113
113
  }
114
114
 
115
+ #drawSortIndicator(ctx, x, y, direction, theme) {
116
+ ctx.fillStyle = theme.colors.focus;
117
+ ctx.beginPath();
118
+ if (direction === 'asc') {
119
+ ctx.moveTo(x, y + 3);
120
+ ctx.lineTo(x + 5, y - 3);
121
+ ctx.lineTo(x + 10, y + 3);
122
+ } else {
123
+ ctx.moveTo(x, y - 3);
124
+ ctx.lineTo(x + 5, y + 3);
125
+ ctx.lineTo(x + 10, y - 3);
126
+ }
127
+ ctx.closePath();
128
+ ctx.fill();
129
+ }
130
+
115
131
  #drawRows(ctx) {
116
132
  const { rows, visibleRange, viewport } = this.scene;
117
133
  this.renderedRows = visibleRange.count;
@@ -178,7 +194,7 @@ export class TreeRowRenderer {
178
194
  }
179
195
 
180
196
  #drawRow(ctx, row) {
181
- const { columns, nodes, dynamicState, selection, hoverNodeId, focusNodeId, searchMatches, theme, viewport } = this.scene;
197
+ const { columns, nodes, dynamicState, selection, hoverNodeId, hoverPart, activeNodeId, activePart, focusNodeId, searchMatches, theme, viewport } = this.scene;
182
198
  const node = nodes[row.nodeIndex];
183
199
  const state = dynamicState.get(row.nodeId) ?? {};
184
200
  const style = resolveNodeStyle(theme, node, state);
@@ -198,7 +214,19 @@ export class TreeRowRenderer {
198
214
 
199
215
  for (const column of columns) {
200
216
  const rect = { x: column.x, y, width: column.width, height: row.height };
201
- this.#drawCell(ctx, { node, state, row, column, rect, theme, style });
217
+ this.#drawCell(ctx, {
218
+ node,
219
+ state,
220
+ row,
221
+ column,
222
+ rect,
223
+ theme,
224
+ style,
225
+ selected,
226
+ hovered,
227
+ hoverPart: hoverNodeId === row.nodeId ? hoverPart : null,
228
+ activePart: activeNodeId === row.nodeId ? activePart : null,
229
+ });
202
230
  ctx.strokeStyle = colors.border;
203
231
  ctx.beginPath();
204
232
  ctx.moveTo(column.x + column.width + 0.5, y);
@@ -235,7 +263,7 @@ export class TreeRowRenderer {
235
263
  else this.#drawTextCell(ctx, cell);
236
264
  }
237
265
 
238
- #drawInspectorPaneCell(ctx, { node, row, rect, theme, style }) {
266
+ #drawInspectorPaneCell(ctx, { node, row, rect, theme, style, hovered, hoverPart, activePart }) {
239
267
  const visibleRight = this.scene.viewport.scrollX + this.scene.viewport.viewportWidth;
240
268
  rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
241
269
  const data = node.data ?? {};
@@ -272,13 +300,22 @@ export class TreeRowRenderer {
272
300
  if (data.valueType === 'array') {
273
301
  ctx.fillStyle = colors.textMuted;
274
302
  drawTruncatedText(ctx, data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
275
- this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme);
276
- this.#drawSmallButton(ctx, rect.x + rect.width - 28, rect.y + 5, 22, rect.height - 10, '-', theme);
303
+ this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme, {
304
+ hovered: hovered && hoverPart === 'arrayAdd',
305
+ active: activePart === 'arrayAdd',
306
+ });
307
+ this.#drawSmallButton(ctx, rect.x + rect.width - 28, rect.y + 5, 22, rect.height - 10, '-', theme, {
308
+ hovered: hovered && hoverPart === 'arrayRemove',
309
+ active: activePart === 'arrayRemove',
310
+ });
277
311
  } else {
278
312
  this.#drawInspectorValueCell(ctx, {
279
313
  node,
280
314
  rect: { x: editorX, y: rect.y, width: editorWidth, height: rect.height },
281
315
  theme,
316
+ hovered,
317
+ hoverPart,
318
+ activePart,
282
319
  suppressUpdatedMarker: true,
283
320
  });
284
321
  }
@@ -286,7 +323,7 @@ export class TreeRowRenderer {
286
323
  ctx.globalAlpha = 1;
287
324
  }
288
325
 
289
- #drawInspectorValueCell(ctx, { node, rect, theme, suppressUpdatedMarker = false }) {
326
+ #drawInspectorValueCell(ctx, { node, rect, theme, hovered = false, hoverPart = null, activePart = null, suppressUpdatedMarker = false }) {
290
327
  const data = node.data ?? {};
291
328
  const meta = data.meta ?? {};
292
329
  const disabled = data.disabled;
@@ -301,9 +338,12 @@ export class TreeRowRenderer {
301
338
  ctx.globalAlpha = disabled ? DISABLED_ALPHA : 1;
302
339
 
303
340
  if (data.editorType === 'checkbox') {
304
- this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 7, Boolean(data.value), theme);
341
+ this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
305
342
  } else if (data.editorType === 'range') {
306
- this.#drawInspectorRange(ctx, x, rect.y + rect.height / 2 - 4, width, data, theme);
343
+ this.#drawInspectorRange(ctx, x, rect.y + rect.height / 2 - 4, width, data, theme, {
344
+ hoveredNumber: hovered && hoverPart === 'number',
345
+ activeNumber: activePart === 'number',
346
+ });
307
347
  } else if (data.editorType === 'color') {
308
348
  ctx.fillStyle = String(data.value || '#000000');
309
349
  ctx.fillRect(x, y + 2, 28, height - 4);
@@ -311,20 +351,25 @@ export class TreeRowRenderer {
311
351
  ctx.strokeRect(x + 0.5, y + 2.5, 28, height - 4);
312
352
  this.#drawMutedText(ctx, String(data.value ?? ''), x + 38, rect.y + rect.height / 2, width - 38, theme);
313
353
  } else if (data.editorType === 'button') {
314
- ctx.fillStyle = readonly || disabled ? theme.colors.progressTrack : theme.colors.rowHover;
315
- roundRect(ctx, x, y, meta.fullWidthButton ? width : Math.min(width, 140), height, 4);
316
- ctx.fill();
354
+ const buttonWidth = meta.fullWidthButton ? width : Math.min(width, 140);
355
+ this.#drawControlSurface(ctx, x, y, buttonWidth, height, theme, {
356
+ hovered: hovered && hoverPart === 'button',
357
+ active: activePart === 'button',
358
+ disabled: readonly || disabled,
359
+ });
317
360
  ctx.fillStyle = theme.colors.text;
318
361
  ctx.textAlign = 'center';
319
- drawTruncatedText(ctx, meta.button ?? node.label, x + (meta.fullWidthButton ? width : Math.min(width, 140)) / 2, rect.y + rect.height / 2, Math.max(10, (meta.fullWidthButton ? width : Math.min(width, 140)) - 12));
362
+ drawTruncatedText(ctx, meta.button ?? node.label, x + buttonWidth / 2, rect.y + rect.height / 2, Math.max(10, buttonWidth - 12));
320
363
  ctx.textAlign = 'left';
321
364
  } else if (data.editorType === 'select') {
322
- ctx.fillStyle = theme.colors.rowHover;
323
- roundRect(ctx, x, y, Math.min(width, 180), height, 4);
324
- ctx.fill();
325
- this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, Math.min(width, 180) - 22, theme);
326
- ctx.fillStyle = theme.colors.chevron;
327
- drawTruncatedText(ctx, 'v', x + Math.min(width, 180) - 14, rect.y + rect.height / 2, 10);
365
+ const selectWidth = Math.min(width, 180);
366
+ this.#drawControlSurface(ctx, x, y, selectWidth, height, theme, {
367
+ hovered: hovered && hoverPart === 'editor',
368
+ active: activePart === 'editor',
369
+ disabled: readonly || disabled,
370
+ });
371
+ this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme);
372
+ this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
328
373
  } else {
329
374
  this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
330
375
  }
@@ -348,7 +393,7 @@ export class TreeRowRenderer {
348
393
  }
349
394
 
350
395
  #drawInspectorTypeCell(ctx, { node, rect, theme }) {
351
- this.#drawMutedText(ctx, node.data?.valueType ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
396
+ this.#drawMutedText(ctx, node.data?.valueType ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme, false, theme.monoFont);
352
397
  }
353
398
 
354
399
  #drawInspectorDescriptionCell(ctx, { node, rect, theme }) {
@@ -356,20 +401,18 @@ export class TreeRowRenderer {
356
401
  }
357
402
 
358
403
  #drawCheckbox(ctx, x, y, checked, theme) {
359
- ctx.strokeStyle = theme.colors.textMuted;
360
- ctx.strokeRect(x + 0.5, y + 0.5, 14, 14);
361
- if (!checked) return;
362
- ctx.strokeStyle = theme.colors.progressFill;
363
- ctx.lineWidth = 2;
364
- ctx.beginPath();
365
- ctx.moveTo(x + 3, y + 7);
366
- ctx.lineTo(x + 6, y + 11);
367
- ctx.lineTo(x + 12, y + 3);
404
+ roundRect(ctx, x, y, 16, 16, 3);
405
+ ctx.fillStyle = theme.colors.progressTrack;
406
+ ctx.fill();
407
+ ctx.strokeStyle = checked ? theme.colors.progressFill : theme.colors.textMuted;
368
408
  ctx.stroke();
369
- ctx.lineWidth = 1;
409
+ if (!checked) return;
410
+ ctx.fillStyle = theme.colors.progressFill;
411
+ roundRect(ctx, x + 4, y + 4, 8, 8, 1.5);
412
+ ctx.fill();
370
413
  }
371
414
 
372
- #drawInspectorRange(ctx, x, y, width, data, theme) {
415
+ #drawInspectorRange(ctx, x, y, width, data, theme, state = {}) {
373
416
  const meta = data.meta ?? {};
374
417
  const min = meta.min ?? 0;
375
418
  const max = meta.max ?? 100;
@@ -382,19 +425,20 @@ export class TreeRowRenderer {
382
425
  ctx.fillRect(x, y, barWidth, 8);
383
426
  ctx.fillStyle = theme.colors.progressFill;
384
427
  ctx.fillRect(x, y, barWidth * ratio, 8);
385
- ctx.fillStyle = theme.colors.rowHover;
386
- roundRect(ctx, x + barWidth + gap, y - 6, valueWidth, 20, 3);
387
- ctx.fill();
428
+ this.#drawControlSurface(ctx, x + barWidth + gap, y - 6, valueWidth, 20, theme, {
429
+ hovered: Boolean(state.hoveredNumber),
430
+ active: Boolean(state.activeNumber),
431
+ });
388
432
  ctx.fillStyle = theme.colors.text;
433
+ ctx.font = theme.monoFont ?? theme.font;
389
434
  ctx.textAlign = 'right';
390
435
  drawTruncatedText(ctx, String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
391
436
  ctx.textAlign = 'left';
437
+ ctx.font = theme.font;
392
438
  }
393
439
 
394
- #drawSmallButton(ctx, x, y, width, height, label, theme) {
395
- ctx.fillStyle = theme.colors.rowHover;
396
- roundRect(ctx, x, y, width, height, 3);
397
- ctx.fill();
440
+ #drawSmallButton(ctx, x, y, width, height, label, theme, state = {}) {
441
+ this.#drawControlSurface(ctx, x, y, width, height, theme, state);
398
442
  ctx.fillStyle = theme.colors.text;
399
443
  ctx.textAlign = 'center';
400
444
  ctx.textBaseline = 'middle';
@@ -402,9 +446,34 @@ export class TreeRowRenderer {
402
446
  ctx.textAlign = 'left';
403
447
  }
404
448
 
405
- #drawMutedText(ctx, text, x, y, width, theme, readonly = false) {
449
+ #drawControlSurface(ctx, x, y, width, height, theme, { hovered = false, active = false, disabled = false } = {}) {
450
+ const colors = theme.colors;
451
+ ctx.fillStyle = disabled
452
+ ? colors.progressTrack
453
+ : active
454
+ ? mixColor(colors.rowHover, colors.focus, 0.36)
455
+ : hovered
456
+ ? mixColor(colors.rowHover, colors.focus, 0.18)
457
+ : colors.progressTrack;
458
+ roundRect(ctx, x, y, width, height, 5);
459
+ ctx.fill();
460
+ ctx.strokeStyle = active ? colors.focus : hovered ? mixColor(colors.border, colors.focus, 0.5) : colors.border;
461
+ ctx.stroke();
462
+ }
463
+
464
+ #drawSelectChevron(ctx, x, y, theme, disabled = false) {
465
+ ctx.fillStyle = disabled ? theme.colors.textMuted : theme.colors.chevron;
466
+ ctx.beginPath();
467
+ ctx.moveTo(x - 4, y - 2);
468
+ ctx.lineTo(x + 4, y - 2);
469
+ ctx.lineTo(x, y + 3);
470
+ ctx.closePath();
471
+ ctx.fill();
472
+ }
473
+
474
+ #drawMutedText(ctx, text, x, y, width, theme, readonly = false, font = null) {
406
475
  ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
407
- ctx.font = theme.font;
476
+ ctx.font = font ?? theme.font;
408
477
  ctx.textBaseline = 'middle';
409
478
  ctx.textAlign = 'left';
410
479
  drawTruncatedText(ctx, String(text ?? ''), x, y, Math.max(10, width));
@@ -431,7 +500,7 @@ export class TreeRowRenderer {
431
500
  roundRect(ctx, x, y, badgeWidth, 16, 8);
432
501
  ctx.fill();
433
502
  ctx.fillStyle = theme.colors.badgeText;
434
- ctx.font = '10px system-ui, sans-serif';
503
+ ctx.font = '10px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace';
435
504
  ctx.textAlign = 'center';
436
505
  ctx.textBaseline = 'middle';
437
506
  drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8, badgeWidth - 8);
@@ -454,7 +523,7 @@ export class TreeRowRenderer {
454
523
  if (column.kind === 'updated' && typeof value === 'number') value = formatTime(value);
455
524
  if (typeof value === 'number') value = Math.round(value).toString();
456
525
  ctx.fillStyle = column.kind === 'type' ? resolveNodeStyle(theme, node, state).color : theme.colors.textMuted;
457
- ctx.font = theme.font;
526
+ ctx.font = column.kind === 'type' || column.kind === 'updated' || typeof value === 'number' ? theme.monoFont ?? theme.font : theme.font;
458
527
  ctx.textBaseline = 'middle';
459
528
  ctx.textAlign = column.align;
460
529
  const x = column.align === 'right' ? rect.x + rect.width - 10 : column.align === 'center' ? rect.x + rect.width / 2 : rect.x + 10;
@@ -540,6 +609,10 @@ function clamp01(value) {
540
609
 
541
610
  function roundRect(ctx, x, y, width, height, radius) {
542
611
  ctx.beginPath();
612
+ if (typeof ctx.roundRect === 'function') {
613
+ ctx.roundRect(x, y, width, height, radius);
614
+ return;
615
+ }
543
616
  ctx.moveTo(x + radius, y);
544
617
  ctx.lineTo(x + width - radius, y);
545
618
  ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
@@ -551,6 +624,23 @@ function roundRect(ctx, x, y, width, height, radius) {
551
624
  ctx.quadraticCurveTo(x, y, x + radius, y);
552
625
  }
553
626
 
627
+ function mixColor(a, b, amount) {
628
+ const from = parseHexColor(a);
629
+ const to = parseHexColor(b);
630
+ if (!from || !to) return amount >= 0.5 ? b : a;
631
+ const t = clamp01(amount);
632
+ const value = from.map((channel, index) => Math.round(channel + (to[index] - channel) * t));
633
+ return `rgb(${value[0]}, ${value[1]}, ${value[2]})`;
634
+ }
635
+
636
+ function parseHexColor(value) {
637
+ const text = String(value ?? '').trim();
638
+ const match = /^#([0-9a-f]{6})$/i.exec(text);
639
+ if (!match) return null;
640
+ const number = Number.parseInt(match[1], 16);
641
+ return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
642
+ }
643
+
554
644
  function drawTruncatedText(ctx, text, x, y, maxWidth) {
555
645
  const value = String(text ?? '');
556
646
  const width = Math.max(0, maxWidth);
@@ -558,22 +648,54 @@ function drawTruncatedText(ctx, text, x, y, maxWidth) {
558
648
  ctx.fillText(fitText(ctx, value, width), x, y);
559
649
  }
560
650
 
651
+ const TEXT_FIT_CACHE_LIMIT = 6000;
652
+ const textFitCache = new Map();
653
+
561
654
  function fitText(ctx, text, maxWidth) {
562
- if (ctx.measureText(text).width <= maxWidth) return text;
655
+ const safeWidth = Math.max(0, Math.floor(maxWidth));
656
+ const cacheKey = `${ctx.font}\u0000${safeWidth}\u0000${text}`;
657
+ const cached = textFitCache.get(cacheKey);
658
+ if (cached !== undefined) return cached;
659
+
660
+ let result = text;
661
+ if (ctx.measureText(text).width <= safeWidth) {
662
+ setTextFitCache(cacheKey, result);
663
+ return result;
664
+ }
563
665
  const ellipsis = '...';
564
666
  const ellipsisWidth = ctx.measureText(ellipsis).width;
565
- if (ellipsisWidth > maxWidth) return '';
667
+ if (ellipsisWidth > safeWidth) {
668
+ setTextFitCache(cacheKey, '');
669
+ return '';
670
+ }
566
671
  let low = 0;
567
672
  let high = text.length;
568
673
  while (low < high) {
569
674
  const mid = Math.ceil((low + high) / 2);
570
675
  const candidate = text.slice(0, mid);
571
- if (ctx.measureText(candidate).width + ellipsisWidth <= maxWidth) low = mid;
676
+ if (ctx.measureText(candidate).width + ellipsisWidth <= safeWidth) low = mid;
572
677
  else high = mid - 1;
573
678
  }
574
- return `${text.slice(0, low)}${ellipsis}`;
679
+ result = `${text.slice(0, low)}${ellipsis}`;
680
+ setTextFitCache(cacheKey, result);
681
+ return result;
575
682
  }
576
683
 
684
+ function setTextFitCache(key, value) {
685
+ textFitCache.set(key, value);
686
+ if (textFitCache.size <= TEXT_FIT_CACHE_LIMIT) return;
687
+ const firstKey = textFitCache.keys().next().value;
688
+ if (firstKey !== undefined) textFitCache.delete(firstKey);
689
+ }
690
+
691
+ const timeFormatter = new Intl.DateTimeFormat([], {
692
+ hour: '2-digit',
693
+ minute: '2-digit',
694
+ second: '2-digit',
695
+ });
696
+
577
697
  function formatTime(value) {
578
- return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
698
+ const time = typeof value === 'number' ? value : Number(value);
699
+ if (!Number.isFinite(time)) return '';
700
+ return timeFormatter.format(time);
579
701
  }
@@ -44,6 +44,9 @@ export class TreeViewController {
44
44
  this.searchHighlights = new Set();
45
45
  this.filterQuery = '';
46
46
  this.hoverId = null;
47
+ this.hoverPart = null;
48
+ this.activeId = null;
49
+ this.activePart = null;
47
50
  this.focusedId = null;
48
51
  this.anchorRowIndex = null;
49
52
  this.lastPatchCount = 0;
@@ -63,6 +66,7 @@ export class TreeViewController {
63
66
  this.canvas = canvas;
64
67
  this.renderer.initialize(canvas);
65
68
  this.renderer.setScene(this.scene);
69
+ this.#resizeToCanvasClientSize();
66
70
  this.#observeCanvasSize();
67
71
  return this;
68
72
  }
@@ -469,9 +473,27 @@ export class TreeViewController {
469
473
  setHover(nodeId) {
470
474
  if (this.hoverId === nodeId) return;
471
475
  this.hoverId = nodeId;
476
+ this.hoverPart = null;
472
477
  this.events.emit('nodehover', { nodeId });
473
478
  }
474
479
 
480
+ setHoverHit(hit) {
481
+ const nodeId = hit?.row?.nodeId ?? null;
482
+ const part = hit?.part ?? null;
483
+ if (this.hoverId === nodeId && this.hoverPart === part) return;
484
+ this.hoverId = nodeId;
485
+ this.hoverPart = part;
486
+ this.events.emit('nodehover', { nodeId, part });
487
+ }
488
+
489
+ setActiveHit(hit) {
490
+ const nodeId = hit?.row?.nodeId ?? null;
491
+ const part = hit?.part ?? null;
492
+ if (this.activeId === nodeId && this.activePart === part) return;
493
+ this.activeId = nodeId;
494
+ this.activePart = part;
495
+ }
496
+
475
497
  clickNode(nodeId, event = {}) {
476
498
  const row = this.rowModel.getRowById(nodeId);
477
499
  if (!row) return;
@@ -588,6 +610,9 @@ export class TreeViewController {
588
610
  dynamicState: this.model.dynamicState,
589
611
  selection: this.selection.selected,
590
612
  hoverNodeId: this.hoverId,
613
+ hoverPart: this.hoverPart,
614
+ activeNodeId: this.activeId,
615
+ activePart: this.activePart,
591
616
  focusNodeId: this.focusedId,
592
617
  searchMatches: this.searchHighlights,
593
618
  sort: this.columnModel.sort,
@@ -661,6 +686,8 @@ export class TreeViewController {
661
686
  else if (localX >= treeX + 26 && localX <= treeX + 44) part = 'icon';
662
687
  else if (localX >= treeX + 48) part = 'label';
663
688
  else part = 'cell';
689
+ } else if (column.kind === 'inspectorValue') {
690
+ part = this.#inspectorEditorPart(row, x - column.x, column.width);
664
691
  }
665
692
  return { area: 'row', part, row, column, x, y: rowY };
666
693
  }
@@ -842,6 +869,13 @@ export class TreeViewController {
842
869
  this.#resizeObserver.observe(this.canvas);
843
870
  }
844
871
 
872
+ #resizeToCanvasClientSize() {
873
+ if (!this.canvas) return;
874
+ const width = Math.floor(this.canvas.clientWidth || this.canvas.getBoundingClientRect().width || 0);
875
+ const height = Math.floor(this.canvas.clientHeight || this.canvas.getBoundingClientRect().height || 0);
876
+ if (width > 0 && height > 0) this.resize(width, height);
877
+ }
878
+
845
879
  #computeInspectorPaneLabelEnd(visibleRange) {
846
880
  const column = this.columnModel.columns.find((item) => item.kind === 'inspectorPane');
847
881
  if (!column) return 0;