@elicdavis/node-flow 0.1.19 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/index.d.mts +386 -227
  2. package/dist/index.d.ts +386 -227
  3. package/dist/index.js +1884 -1076
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +1867 -1075
  6. package/dist/index.mjs.map +1 -1
  7. package/index.html +1134 -329
  8. package/package.json +1 -1
  9. package/src/connection.ts +30 -5
  10. package/src/contextMenu.ts +66 -34
  11. package/src/graph.ts +12 -10
  12. package/src/index.ts +26 -12
  13. package/src/markdown/entry.ts +11 -15
  14. package/src/markdown/syntaxParser.ts +2 -1
  15. package/src/node.ts +162 -111
  16. package/src/nodes/subsystem.ts +123 -29
  17. package/src/notes/note.ts +9 -1
  18. package/src/organize.ts +540 -111
  19. package/src/popup.ts +4 -2
  20. package/src/port.ts +83 -6
  21. package/src/styles/bevel.ts +31 -0
  22. package/src/styles/cursor.ts +1 -0
  23. package/src/styles/text.ts +4 -4
  24. package/src/theme.ts +179 -92
  25. package/src/types/text.ts +5 -1
  26. package/src/utils/color.ts +1 -1
  27. package/src/widgets/button.ts +52 -107
  28. package/src/widgets/color.ts +91 -150
  29. package/src/widgets/factory.ts +89 -46
  30. package/src/widgets/field.ts +99 -0
  31. package/src/widgets/image.ts +107 -165
  32. package/src/widgets/inplace.ts +84 -0
  33. package/src/widgets/number.ts +122 -154
  34. package/src/widgets/row.ts +138 -0
  35. package/src/widgets/slider.ts +101 -216
  36. package/src/widgets/string.ts +39 -130
  37. package/src/widgets/text.ts +81 -92
  38. package/src/widgets/toggle.ts +62 -207
  39. package/src/widgets/value.ts +127 -0
  40. package/src/widgets/widget.ts +180 -34
  41. package/test/context_menu.test.ts +82 -0
  42. package/test/cursor.test.ts +17 -0
  43. package/test/hidden_port.test.ts +125 -0
  44. package/test/hover_after_removal.test.ts +72 -0
  45. package/test/markdown.test.ts +2 -2
  46. package/test/markdown_code_block.test.ts +58 -0
  47. package/test/multi_input.test.ts +124 -0
  48. package/test/organize.test.ts +117 -1
  49. package/test/property_binding.test.ts +49 -0
  50. package/test/support.ts +175 -0
  51. package/test/theme.test.ts +86 -0
  52. package/test/widget_click.test.ts +111 -97
  53. package/test/widget_construction.test.ts +5 -4
  54. package/test/widgets.test.ts +333 -0
  55. package/test/wire_follows_camera.test.ts +57 -0
@@ -1,126 +1,140 @@
1
1
  import { strict as assert } from 'assert';
2
- import { NodeSubsystem } from '../src/nodes/subsystem';
3
- import { PassSubsystem } from '../src/pass/subsystem';
4
2
  import { FlowNode } from '../src/node';
5
- import { Camera } from '../src/camera';
6
3
  import { Box } from '../src/types/box';
7
4
  import { Vector2 } from '../src/types/vector2';
8
-
9
- function stubCtx(): CanvasRenderingContext2D {
10
- const ctx: any = {
11
- canvas: { width: 800, height: 600 },
12
- lineWidth: 1,
13
- font: '',
14
- textAlign: 'left',
15
- textBaseline: 'top',
16
- fillStyle: '',
17
- strokeStyle: '',
18
- globalAlpha: 1,
19
- measureText: () => ({ width: 40 }),
20
- save: () => { }, restore: () => { }, translate: () => { }, scale: () => { },
21
- beginPath: () => { }, closePath: () => { }, roundRect: () => { }, rect: () => { },
22
- moveTo: () => { }, lineTo: () => { }, arc: () => { }, ellipse: () => { },
23
- bezierCurveTo: () => { }, quadraticCurveTo: () => { },
24
- fill: () => { }, stroke: () => { }, fillText: () => { }, strokeText: () => { },
25
- clip: () => { }, drawImage: () => { }, setLineDash: () => { },
26
- createLinearGradient: () => ({ addColorStop: () => { } }),
27
- };
28
- return ctx as CanvasRenderingContext2D;
5
+ import { WidgetBase, WidgetPointer } from '../src/widgets/widget';
6
+ import { center, widgetScene } from './support';
7
+
8
+ class Recording extends WidgetBase {
9
+ clicks = 0;
10
+ downs: Array<WidgetPointer> = [];
11
+ moves: Array<WidgetPointer> = [];
12
+ ups: Array<WidgetPointer> = [];
13
+ drawnIn: Box | null = null;
14
+
15
+ Size(): Vector2 {
16
+ return { x: 100, y: 20 };
17
+ }
18
+
19
+ protected render(_ctx: CanvasRenderingContext2D, box: Box): void {
20
+ this.drawnIn = box;
21
+ }
22
+
23
+ protected onPointerDown(p: WidgetPointer): void { this.downs.push(p); }
24
+ protected onPointerMove(p: WidgetPointer): void { this.moves.push(p); }
25
+ protected onPointerUp(p: WidgetPointer): void { this.ups.push(p); }
26
+ protected onClick(): void { this.clicks++; }
29
27
  }
30
28
 
31
- function recordingWidget() {
32
- let clicks = 0;
33
- let drawnAt: Box | null = null;
34
- const widget = {
35
- Size: (): Vector2 => ({ x: 100, y: 20 }),
36
- Draw: (_ctx: CanvasRenderingContext2D, position: Vector2, scale: number): Box => {
37
- drawnAt = { Position: { x: position.x, y: position.y }, Size: { x: 100 * scale, y: 20 * scale } };
38
- return drawnAt;
39
- },
40
- ClickStart: (): void => { },
41
- ClickEnd: (): void => { clicks++; },
42
- };
43
- return {
44
- widget,
45
- clicks: () => clicks,
46
- center: (): Vector2 => {
47
- assert.ok(drawnAt !== null, 'widget was never drawn');
48
- const b = drawnAt as Box;
49
- return { x: b.Position.x + b.Size.x / 2, y: b.Position.y + b.Size.y / 2 };
50
- },
51
- };
52
- }
29
+ describe('clicking a widget', () => {
30
+ it('clicks it when the press and release are both on it', () => {
31
+ const { widget, widgetBox, click } = widgetScene(() => new Recording());
32
+ click(center(widgetBox()));
33
+ assert.equal((widget as Recording).clicks, 1);
34
+ });
53
35
 
54
- function scene() {
55
- const graph = new NodeSubsystem(new PassSubsystem());
56
- const node = new FlowNode({ title: 'test', position: { x: 100, y: 100 } });
57
- const rec = recordingWidget();
58
- node.addWidget(rec.widget as any);
59
- graph.addNode(node);
36
+ it('does not click it when the gesture drags away before releasing', () => {
37
+ const { widget, widgetBox, press } = widgetScene(() => new Recording());
38
+ const on = center(widgetBox());
60
39
 
61
- const camera = new Camera();
62
- const ctx = stubCtx();
63
- return { graph, camera, ctx, rec };
64
- }
40
+ const gesture = press(on);
41
+ gesture.moveTo({ x: on.x + 400, y: on.y + 300 });
42
+ gesture.release();
65
43
 
66
- describe('clicking a widget', () => {
67
- it('opens it when the press and release are both on it', () => {
68
- const { graph, camera, ctx, rec } = scene();
69
- graph.render(ctx, camera, undefined);
70
- const on = rec.center();
44
+ const recording = widget as Recording;
45
+ assert.equal(recording.clicks, 0);
46
+ assert.equal(recording.ups.length, 1, 'it still hears the release');
47
+ assert.equal(recording.ups[0].inside, false);
48
+ });
71
49
 
72
- graph.render(ctx, camera, on);
73
- graph.clickStart(on, camera, false);
74
- graph.render(ctx, camera, on);
75
- graph.clickEnd();
50
+ it('keeps reporting the drag to the widget once the pointer has left it', () => {
51
+ const { widget, widgetBox, press } = widgetScene(() => new Recording());
52
+ const on = center(widgetBox());
53
+
54
+ const gesture = press(on);
55
+ gesture.moveTo({ x: on.x + 300, y: on.y });
56
+ gesture.release();
76
57
 
77
- assert.equal(rec.clicks(), 1);
58
+ const recording = widget as Recording;
59
+ assert.deepEqual(recording.moves.map((m) => m.position), [{ x: on.x + 300, y: on.y }]);
60
+ assert.equal(recording.moves[0].inside, false);
78
61
  });
79
62
 
80
- it('leaves it alone when the gesture drags away before releasing', () => {
81
- const { graph, camera, ctx, rec } = scene();
82
- graph.render(ctx, camera, undefined);
83
- const on = rec.center();
63
+ it('ignores a disabled widget', () => {
64
+ const { widget, widgetBox, click } = widgetScene(() => {
65
+ const w = new Recording();
66
+ w.setDisabled(true);
67
+ return w;
68
+ });
69
+ click(center(widgetBox()));
70
+ const recording = widget as Recording;
71
+ assert.equal(recording.downs.length, 0);
72
+ assert.equal(recording.clicks, 0);
73
+ });
74
+ });
84
75
 
85
- graph.render(ctx, camera, on);
86
- graph.clickStart(on, camera, false);
87
- graph.render(ctx, camera, { x: on.x + 400, y: on.y + 300 });
88
- graph.clickEnd();
76
+ describe('laying out widgets', () => {
77
+ it('stretches a widget across a node made wide by its ports', () => {
78
+ const { node, widget } = widgetScene(
79
+ () => new Recording(),
80
+ { inputs: [{ name: 'A port name long enough to widen the whole node', type: 'float64' }] },
81
+ );
82
+ const drawnIn = (widget as Recording).drawnIn as Box;
83
+ assert.ok(drawnIn.Size.x > 100 * 2);
84
+ assert.ok(node.widgets().length === 1);
85
+ });
86
+
87
+ it('leaves a hidden widget out of the node and out of reach', () => {
88
+ const shown = widgetScene(() => new Recording());
89
+ const hidden = widgetScene(() => {
90
+ const w = new Recording();
91
+ w.setHidden(true);
92
+ return w;
93
+ });
94
+
95
+ const shownHeight = shown.node.calculateBounds(shown.ctx, shown.camera).Size.y;
96
+ const hiddenHeight = hidden.node.calculateBounds(hidden.ctx, hidden.camera).Size.y;
97
+ assert.ok(hiddenHeight < shownHeight);
89
98
 
90
- assert.equal(rec.clicks(), 0);
99
+ hidden.click(center(shown.widgetBox()));
100
+ assert.equal((hidden.widget as Recording).clicks, 0);
91
101
  });
92
102
  });
93
103
 
94
104
  describe('a node panned off screen', () => {
95
105
  it('stops hit-testing as a widget where it used to be drawn', () => {
96
- const graph = new NodeSubsystem(new PassSubsystem());
97
- const node = new FlowNode({
98
- title: 'test',
99
- position: { x: 100, y: 100 },
100
- inputs: [{ name: 'In', type: 'float64' }],
101
- });
102
- const rec = recordingWidget();
103
- node.addWidget(rec.widget as any);
104
- graph.addNode(node);
105
-
106
- const camera = new Camera();
107
- const ctx = stubCtx();
108
-
109
- graph.render(ctx, camera, undefined);
110
- const on = rec.center();
106
+ const { node, widget, camera, ctx, graph, widgetBox } = widgetScene(
107
+ () => new Recording(),
108
+ { inputs: [{ name: 'In', type: 'float64' }] },
109
+ );
110
+ const on = center(widgetBox());
111
111
 
112
112
  camera.position.x = -50000;
113
113
  graph.render(ctx, camera, undefined);
114
114
 
115
- assert.equal(
116
- node.inBounds(ctx, camera, on).Widget,
117
- undefined,
118
- 'a node that was not drawn has no widget under any screen point',
119
- );
120
-
115
+ assert.equal(node.inBounds(ctx, camera, on).Widget, undefined);
121
116
  graph.clickStart(on, camera, false);
122
117
  graph.clickEnd();
118
+ assert.equal((widget as Recording).clicks, 0);
119
+ });
120
+ });
123
121
 
124
- assert.equal(rec.clicks(), 0);
122
+ describe('a node built from config', () => {
123
+ it('builds a row of labelled numbers bound to properties', () => {
124
+ const node = new FlowNode({
125
+ title: 'vector',
126
+ widgets: [{
127
+ type: 'row',
128
+ config: {
129
+ widgets: [
130
+ { type: 'number', config: { property: 'x', label: 'X', width: 60 } },
131
+ { type: 'number', config: { property: 'y', label: 'Y', width: 60 } },
132
+ ],
133
+ },
134
+ }],
135
+ });
136
+ node.setProperty('y', 4);
137
+ assert.equal(node.widgets().length, 1);
138
+ assert.equal(node.getProperty('x'), 0);
125
139
  });
126
140
  });
@@ -35,14 +35,15 @@ describe('widget construction', () => {
35
35
 
36
36
  it('does not fire a slider callback for its initial value', () => {
37
37
  let fired = 0;
38
- new SliderWidget(stubNode(), { value: 7, min: 0, max: 10, change: () => { fired++; } });
38
+ new SliderWidget(stubNode(), { value: 7, min: 0, max: 10, callback: () => { fired++; }, onInput: () => { fired++; } });
39
39
  assert.equal(fired, 0);
40
40
  });
41
41
 
42
- it('still fires the number callback on a real change', () => {
42
+ it('does not fire the number callback for a value set from code', () => {
43
43
  const values: Array<number> = [];
44
44
  const widget = new NumberWidget(stubNode(), { value: 42, callback: (v) => { values.push(v); } });
45
- widget.Set(43);
46
- assert.deepEqual(values, [43]);
45
+ widget.setValue(43);
46
+ assert.equal(widget.value(), 43);
47
+ assert.deepEqual(values, []);
47
48
  });
48
49
  });
@@ -0,0 +1,333 @@
1
+ import { strict as assert } from 'assert';
2
+ import { FlowNode } from '../src/node';
3
+ import { Box } from '../src/types/box';
4
+ import { Vector2 } from '../src/types/vector2';
5
+ import { NumberWidget } from '../src/widgets/number';
6
+ import { StringWidget } from '../src/widgets/string';
7
+ import { SliderWidget } from '../src/widgets/slider';
8
+ import { TextWidget } from '../src/widgets/text';
9
+ import { ToggleWidget } from '../src/widgets/toggle';
10
+ import { ImageWidget } from '../src/widgets/image';
11
+ import { ColorWidget } from '../src/widgets/color';
12
+ import { RowWidget } from '../src/widgets/row';
13
+ import { WidgetBase } from '../src/widgets/widget';
14
+ import { CanvasRecord, center, stubCtx, widgetScene, withDocument } from './support';
15
+
16
+ describe('a number widget', () => {
17
+ it('follows a drag and reports one edit when let go', () => {
18
+ const inputs: Array<number> = [];
19
+ const commits: Array<number> = [];
20
+ const edits: Array<number> = [];
21
+ const { node, widget, widgetBox, press } = widgetScene((n) => {
22
+ n.addPropertyEditListener('value', (v) => edits.push(v));
23
+ return new NumberWidget(n, {
24
+ property: 'value', value: 10, step: 0.5,
25
+ onInput: (v) => inputs.push(v),
26
+ callback: (v) => commits.push(v),
27
+ });
28
+ });
29
+ const on = center(widgetBox());
30
+
31
+ const gesture = press(on);
32
+ gesture.moveTo({ x: on.x + 10, y: on.y });
33
+ gesture.moveTo({ x: on.x + 20, y: on.y });
34
+ gesture.release();
35
+
36
+ assert.deepEqual(inputs, [15, 20]);
37
+ assert.deepEqual(commits, [20]);
38
+ assert.deepEqual(edits, [20]);
39
+ assert.equal(node.getProperty('value'), 20);
40
+ assert.equal((widget as NumberWidget).value(), 20);
41
+ });
42
+
43
+ it('keeps on following a drag that leaves the node', () => {
44
+ const { widget, widgetBox, press } = widgetScene((n) => new NumberWidget(n, { value: 0, step: 1 }));
45
+ const on = center(widgetBox());
46
+
47
+ const gesture = press(on);
48
+ gesture.moveTo({ x: on.x + 500, y: on.y + 200 });
49
+ gesture.release();
50
+
51
+ assert.equal((widget as NumberWidget).value(), 500);
52
+ });
53
+
54
+ it('rounds an integer while it is dragged', () => {
55
+ const { widget, widgetBox, press } = widgetScene((n) => new NumberWidget(n, { value: 1, integer: true, step: 0.3 }));
56
+ const on = center(widgetBox());
57
+
58
+ const gesture = press(on);
59
+ gesture.moveTo({ x: on.x + 10, y: on.y });
60
+ gesture.release();
61
+
62
+ assert.equal((widget as NumberWidget).value(), 4);
63
+ });
64
+
65
+ it('opens an input over itself when clicked, and keeps what is typed', () => {
66
+ withDocument((body) => {
67
+ const commits: Array<number> = [];
68
+ const { widget, widgetBox, click } = widgetScene((n) => new NumberWidget(n, { value: 3, callback: (v) => commits.push(v) }));
69
+
70
+ click(center(widgetBox()));
71
+ const input = body.children[0];
72
+ assert.ok(input, 'an input was laid over the widget');
73
+ assert.equal(input.value, '3');
74
+ assert.ok(input.focused);
75
+
76
+ input.value = '7.5';
77
+ input.fire('keydown', { key: 'Enter' });
78
+
79
+ assert.deepEqual(commits, [7.5]);
80
+ assert.equal((widget as NumberWidget).value(), 7.5);
81
+ assert.equal(body.children.length, 0);
82
+ });
83
+ });
84
+
85
+ it('throws away what was typed on escape', () => {
86
+ withDocument((body) => {
87
+ const commits: Array<number> = [];
88
+ const { widget, widgetBox, click } = widgetScene((n) => new NumberWidget(n, { value: 3, callback: (v) => commits.push(v) }));
89
+
90
+ click(center(widgetBox()));
91
+ const input = body.children[0];
92
+ input.value = '99';
93
+ input.fire('keydown', { key: 'Escape' });
94
+
95
+ assert.deepEqual(commits, []);
96
+ assert.equal((widget as NumberWidget).value(), 3);
97
+ });
98
+ });
99
+
100
+ it('does not open an input after a drag', () => {
101
+ withDocument((body) => {
102
+ const { widgetBox, press } = widgetScene((n) => new NumberWidget(n, { value: 0 }));
103
+ const on = center(widgetBox());
104
+ const gesture = press(on);
105
+ gesture.moveTo({ x: on.x + 10, y: on.y });
106
+ gesture.release();
107
+ assert.equal(body.children.length, 0);
108
+ });
109
+ });
110
+
111
+ it('shows a value set from code without reporting it as an edit', () => {
112
+ const commits: Array<number> = [];
113
+ const edits: Array<number> = [];
114
+ const node = new FlowNode({ title: 't' });
115
+ node.addPropertyEditListener('value', (v) => edits.push(v));
116
+ const widget = new NumberWidget(node, { property: 'value', callback: (v) => commits.push(v) });
117
+
118
+ widget.setValue(5);
119
+ node.setProperty('value', 6);
120
+
121
+ assert.equal(widget.value(), 6);
122
+ assert.deepEqual(commits, []);
123
+ assert.deepEqual(edits, []);
124
+ });
125
+
126
+ it('draws its label on the left and its value on the right', () => {
127
+ const record: CanvasRecord = { fills: [], texts: [] };
128
+ const widget = new NumberWidget(new FlowNode({ title: 't' }), { label: 'X', value: 1.5 });
129
+ widget.Draw(stubCtx(record), { Position: { x: 0, y: 0 }, Size: { x: 100, y: 25 } }, 1, undefined);
130
+
131
+ const label = record.texts.find((t) => t.text === 'X');
132
+ const value = record.texts.find((t) => t.text === '1.5');
133
+ assert.ok(label && value);
134
+ assert.ok(label.x < value.x);
135
+ });
136
+ });
137
+
138
+ describe('a string widget', () => {
139
+ it('edits in place', () => {
140
+ withDocument((body) => {
141
+ const commits: Array<string> = [];
142
+ const { widgetBox, click } = widgetScene((n) => new StringWidget(n, { value: 'a', callback: (v) => commits.push(v) }));
143
+
144
+ click(center(widgetBox()));
145
+ body.children[0].value = 'b';
146
+ body.children[0].fire('blur');
147
+
148
+ assert.deepEqual(commits, ['b']);
149
+ });
150
+ });
151
+ });
152
+
153
+ describe('a slider', () => {
154
+ it('stops following the mouse once released away from it', () => {
155
+ const commits: Array<number> = [];
156
+ const { widget, widgetBox, press, graph, ctx, camera } = widgetScene((n) => new SliderWidget(n, {
157
+ value: 0.5,
158
+ callback: (v) => commits.push(v),
159
+ }));
160
+ const on = center(widgetBox());
161
+
162
+ const gesture = press(on);
163
+ gesture.moveTo({ x: on.x + 400, y: on.y + 300 });
164
+ gesture.release();
165
+
166
+ assert.deepEqual(commits, [1], 'dragging past the end and letting go still finishes the drag');
167
+ graph.render(ctx, camera, { x: 5, y: 500 });
168
+ assert.equal((widget as SliderWidget).value(), 1);
169
+ });
170
+ });
171
+
172
+ describe('a toggle', () => {
173
+ it('flips when clicked and reports the new value', () => {
174
+ const seen: Array<boolean> = [];
175
+ const { widgetBox, click } = widgetScene((n) => new ToggleWidget(n, { callback: (v) => seen.push(v) }));
176
+ click(center(widgetBox()));
177
+ assert.deepEqual(seen, [true]);
178
+ });
179
+
180
+ it('stays put when the press is dragged off before release', () => {
181
+ const seen: Array<boolean> = [];
182
+ const { widgetBox, press } = widgetScene((n) => new ToggleWidget(n, { callback: (v) => seen.push(v) }));
183
+ const on = center(widgetBox());
184
+ const gesture = press(on);
185
+ gesture.moveTo({ x: on.x + 300, y: on.y });
186
+ gesture.release();
187
+ assert.deepEqual(seen, []);
188
+ });
189
+
190
+ it('lights with the colour configured for its off state', () => {
191
+ const record: CanvasRecord = { fills: [], texts: [] };
192
+ const toggle = new ToggleWidget(new FlowNode({ title: 't' }), { onColor: '#abcdef', offColor: '#123456' });
193
+ toggle.Draw(stubCtx(record), { Position: { x: 0, y: 0 }, Size: { x: 150, y: 25 } }, 1, undefined);
194
+ assert.ok(record.fills.includes('#123456'));
195
+ assert.ok(!record.fills.includes('#abcdef'));
196
+ });
197
+ });
198
+
199
+ describe('a colour widget', () => {
200
+ it('opens the browser picker on click and keeps its alpha', () => {
201
+ withDocument((body) => {
202
+ const inputs: Array<string> = [];
203
+ const commits: Array<string> = [];
204
+ const { widgetBox, click } = widgetScene((n) => new ColorWidget(n, {
205
+ value: '#ff000080',
206
+ onInput: (c) => inputs.push(c),
207
+ callback: (c) => commits.push(c),
208
+ }));
209
+
210
+ click(center(widgetBox()));
211
+ const picker = body.children[0];
212
+ assert.equal(picker.clicked, 1);
213
+ assert.equal(picker.value, '#ff0000');
214
+
215
+ picker.value = '#00ff00';
216
+ picker.oninput?.();
217
+ picker.onchange?.();
218
+
219
+ assert.deepEqual(inputs, ['#00ff0080']);
220
+ assert.deepEqual(commits, ['#00ff0080']);
221
+ });
222
+ });
223
+
224
+ it('opens the picker over the widget just clicked, not the one before', () => {
225
+ withDocument((body) => {
226
+ let first: ColorWidget | undefined;
227
+ const { node, widgetBox, click } = widgetScene((n) => {
228
+ first = new ColorWidget(n, { value: '#ff0000' });
229
+ n.addWidget(first);
230
+ return new ColorWidget(n, { value: '#00ff00' });
231
+ });
232
+ const firstBox = node.widgetPosition(node.widgets().indexOf(first as ColorWidget)) as Box;
233
+
234
+ click(center(firstBox));
235
+ const picker = body.children[0];
236
+ const overFirst = picker.style.cssText;
237
+ click(center(widgetBox()));
238
+ const overSecond = picker.style.cssText;
239
+
240
+ assert.notEqual(overFirst, overSecond);
241
+ assert.deepEqual(picker.openedAt, [overFirst, overSecond]);
242
+ });
243
+ });
244
+
245
+ it('writes white on a dark colour that has alpha', () => {
246
+ const record: CanvasRecord = { fills: [], texts: [] };
247
+ const widget = new ColorWidget(new FlowNode({ title: 't' }), { value: '#10101080' });
248
+ widget.Draw(stubCtx(record), { Position: { x: 0, y: 0 }, Size: { x: 150, y: 25 } }, 1, undefined);
249
+ assert.deepEqual(record.texts.map((t) => t.fillStyle), ['white']);
250
+ });
251
+ });
252
+
253
+ class Tap extends WidgetBase {
254
+ clicks = 0;
255
+ width: number;
256
+ constructor(width: number) { super(); this.width = width; }
257
+ Size(): Vector2 { return { x: this.width, y: 20 }; }
258
+ protected render(_ctx: CanvasRenderingContext2D, _box: Box): void { }
259
+ protected onClick(): void { this.clicks++; }
260
+ }
261
+
262
+ describe('a row', () => {
263
+ it('hands the press to the child under it', () => {
264
+ const left = new Tap(40);
265
+ const right = new Tap(40);
266
+ const { widgetBox, click } = widgetScene(() => new RowWidget([left, right]));
267
+ const box = widgetBox();
268
+
269
+ click({ x: box.Position.x + box.Size.x - 5, y: box.Position.y + 10 });
270
+
271
+ assert.equal(left.clicks, 0);
272
+ assert.equal(right.clicks, 1);
273
+ });
274
+
275
+ it('gives the spare room only to children that grow', () => {
276
+ const drawn: Array<Box> = [];
277
+ const spy = (w: number) => new (class extends Tap {
278
+ protected render(_ctx: CanvasRenderingContext2D, box: Box): void { drawn.push(box); }
279
+ })(w);
280
+ const row = new RowWidget([spy(40), spy(20)], { grow: [1, 0], gap: 0 });
281
+ row.Draw(stubCtx(), { Position: { x: 0, y: 0 }, Size: { x: 200, y: 20 } }, 1, undefined);
282
+
283
+ assert.equal(drawn[0].Size.x, 180);
284
+ assert.equal(drawn[1].Size.x, 20);
285
+ assert.equal(drawn[1].Position.x, 180);
286
+ });
287
+ });
288
+
289
+ describe('a text widget', () => {
290
+ it('follows the property it is bound to', () => {
291
+ const node = new FlowNode({ title: 't' });
292
+ const text = new TextWidget(node, { property: 'label' });
293
+ node.setProperty('label', 'hi');
294
+ assert.equal(text.text(), 'hi');
295
+ });
296
+ });
297
+
298
+ interface StubImage {
299
+ src: string;
300
+ width: number;
301
+ height: number;
302
+ onload?: () => void;
303
+ }
304
+
305
+ describe('an image widget', () => {
306
+ it('keeps the newest image when an older one finishes loading last', () => {
307
+ const g = globalThis as any;
308
+ const previous = g.document;
309
+ const images: Array<StubImage> = [];
310
+ g.document = {
311
+ createElement: () => {
312
+ const img: StubImage = { src: '', width: 0, height: 0 };
313
+ images.push(img);
314
+ return img;
315
+ },
316
+ };
317
+ try {
318
+ const widget = new ImageWidget();
319
+ widget.SetUrl('old');
320
+ widget.SetUrl('new');
321
+ const [older, newer] = images;
322
+ newer.width = 100; newer.height = 100;
323
+ older.width = 40; older.height = 40;
324
+
325
+ newer.onload?.();
326
+ older.onload?.();
327
+
328
+ assert.equal(widget.Size().x, 100);
329
+ } finally {
330
+ g.document = previous;
331
+ }
332
+ });
333
+ });
@@ -0,0 +1,57 @@
1
+ import { strict as assert } from 'assert';
2
+ import { NodeSubsystem } from '../src/nodes/subsystem';
3
+ import { PassSubsystem } from '../src/pass/subsystem';
4
+ import { FlowNode } from '../src/node';
5
+ import { Camera } from '../src/camera';
6
+ import { Vector2 } from '../src/types/vector2';
7
+ import { center, stubCtx } from './support';
8
+
9
+ function scene() {
10
+ let drawnTo: Vector2 | null = null;
11
+ const graph = new NodeSubsystem(new PassSubsystem(), {
12
+ idleConnection: {
13
+ renderer: (params) => { drawnTo = { x: params.start.x, y: params.start.y }; },
14
+ },
15
+ });
16
+ const from = new FlowNode({ title: 'from', position: { x: 50, y: 50 }, outputs: [{ name: 'out', type: 'n' }] });
17
+ const to = new FlowNode({ title: 'to', position: { x: 300, y: 80 }, inputs: [{ name: 'in', type: 'n' }] });
18
+ graph.addNode(from);
19
+ graph.addNode(to);
20
+ graph.connectNodes(from, 0, to, 0);
21
+
22
+ const camera = new Camera();
23
+ const ctx = stubCtx();
24
+ graph.render(ctx, camera, undefined);
25
+ graph.render(ctx, camera, undefined);
26
+ return { graph, to, camera, ctx, drawnTo: () => drawnTo as Vector2 | null };
27
+ }
28
+
29
+ describe('a wire', () => {
30
+ it('meets its port in the first frame after the camera moves', () => {
31
+ const { graph, to, camera, ctx, drawnTo } = scene();
32
+
33
+ camera.position.x += 137;
34
+ camera.position.y -= 41;
35
+ graph.render(ctx, camera, undefined);
36
+
37
+ assert.deepEqual(drawnTo(), center(to.inputPortPosition(0)));
38
+ });
39
+
40
+ it('meets its port in the first frame after the camera zooms', () => {
41
+ const { graph, to, camera, ctx, drawnTo } = scene();
42
+
43
+ camera.zoom = 1.7;
44
+ graph.render(ctx, camera, undefined);
45
+
46
+ assert.deepEqual(drawnTo(), center(to.inputPortPosition(0)));
47
+ });
48
+
49
+ it('meets its port in the first frame after the node is dragged', () => {
50
+ const { graph, to, camera, ctx, drawnTo } = scene();
51
+
52
+ to.translate({ x: 60, y: 25 });
53
+ graph.render(ctx, camera, undefined);
54
+
55
+ assert.deepEqual(drawnTo(), center(to.inputPortPosition(0)));
56
+ });
57
+ });