exodeui-react-native 1.0.0 → 1.0.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/src/engine.ts CHANGED
@@ -1,32 +1,55 @@
1
- import { SkCanvas, SkImage, SkPaint, PaintStyle, Skia, SkPath, SkColor, BlurStyle, SkImageFilter, SkMaskFilter } from '@shopify/react-native-skia';
2
- import { Artboard, Animation, ShapeObject, StateMachine, State, Fit, Alignment, Layout } from './types';
3
- import Matter from 'matter-js';
1
+ import { SkCanvas, SkImage, SkPaint, PaintStyle, Skia, SkPath, SkColor, BlurStyle, SkImageFilter, SkMaskFilter, ClipOp, matchFont, FontStyle, SkFont } from '@shopify/react-native-skia';
2
+ import { Artboard, Animation as SDKAnimation, ShapeObject, StateMachine, State, Fit, Alignment, Layout, LogicNode, LogicOp, Constraint, ComponentEvent } from './types';
3
+ import { PhysicsEngine, MatterPhysics } from './physics';
4
4
 
5
5
  export class ExodeUIEngine {
6
6
  private artboard: Artboard | null = null;
7
7
  private objectStates: Map<string, any> = new Map();
8
8
 
9
9
  // Physics State
10
- private physicsEngine: Matter.Engine | null = null;
11
- private physicsBodies: Map<string, Matter.Body> = new Map();
10
+ private physicsEngine: PhysicsEngine | null = null;
12
11
 
13
12
  // State Machine State
14
13
  private activeStateMachine: StateMachine | null = null;
14
+ private _renderCount: number = 0;
15
15
  private inputs: Map<string, any> = new Map(); // id -> value
16
16
  private inputNameMap: Map<string, string[]> = new Map(); // name -> id[] mapping
17
17
  private layerStates: Map<string, {
18
18
  currentStateIds: string[];
19
- animation: Animation | null;
19
+ animations: { animation: SDKAnimation; state: State | null }[];
20
20
  time: number;
21
- currentState: State | null;
21
+ duration: number;
22
22
  }> = new Map();
23
23
 
24
24
  private imageCache = new Map<string, SkImage>();
25
+ private fonts = new Map<number, SkFont>();
25
26
 
26
27
  private layout: Layout = { fit: 'Contain', alignment: 'Center' };
27
28
 
28
29
  private onTrigger?: (triggerName: string, animationName: string) => void;
29
30
  private onInputUpdate?: (nameOrId: string, value: any) => void;
31
+ private onComponentChange?: (event: ComponentEvent) => void;
32
+
33
+ // Specific Component Listeners
34
+ private onToggle?: (name: string, checked: boolean) => void;
35
+ private onInputChange?: (name: string, text: string) => void;
36
+ private onInputFocus?: (name: string) => void;
37
+ private onInputBlur?: (name: string) => void;
38
+
39
+ // Track triggers that were just fired in the current frame
40
+ private justFiredTriggers: Set<string> = new Set();
41
+ private _lastPointerPos: { x: number; y: number } | null = null;
42
+ private _prevPointerPos: { x: number; y: number } | null = null;
43
+
44
+ // Interaction State
45
+ private focusedId: string | null = null;
46
+ private draggingSliderId: string | null = null;
47
+ private activeDropdownId: string | null = null;
48
+ private draggingListViewId: string | null = null;
49
+ private lastHoveredObjectId: string | null = null;
50
+
51
+ // Physics/Animation State
52
+ private objectVelocities: Map<string, { vx: number; vy: number }> = new Map();
30
53
 
31
54
  setTriggerCallback(cb: (triggerName: string, animationName: string) => void) {
32
55
  this.onTrigger = cb;
@@ -36,6 +59,26 @@ export class ExodeUIEngine {
36
59
  this.onInputUpdate = cb;
37
60
  }
38
61
 
62
+ setComponentChangeCallback(cb: (event: ComponentEvent) => void) {
63
+ this.onComponentChange = cb;
64
+ }
65
+
66
+ setToggleCallback(cb: (name: string, checked: boolean) => void) {
67
+ this.onToggle = cb;
68
+ }
69
+
70
+ setInputChangeCallback(cb: (name: string, text: string) => void) {
71
+ this.onInputChange = cb;
72
+ }
73
+
74
+ setInputFocusCallback(cb: (name: string) => void) {
75
+ this.onInputFocus = cb;
76
+ }
77
+
78
+ setInputBlurCallback(cb: (name: string) => void) {
79
+ this.onInputBlur = cb;
80
+ }
81
+
39
82
  constructor() {}
40
83
 
41
84
  // Helper to update Map AND notify listener
@@ -55,81 +98,156 @@ export class ExodeUIEngine {
55
98
  return state ? state.currentStateIds : [];
56
99
  }
57
100
 
101
+ getObjectState(id: string): any {
102
+ return this.objectStates.get(id);
103
+ }
104
+
105
+
106
+ updateConstraint(objectId: string, index: number, properties: Partial<Constraint>) {
107
+ const obj = this.artboard?.objects.find(o => o.id === objectId);
108
+ if (obj && obj.constraints && obj.constraints[index]) {
109
+ obj.constraints[index] = { ...obj.constraints[index], ...properties } as any;
110
+
111
+ // Clear velocity if spring is disabled or reset
112
+ if (properties.useSpring === false) {
113
+ this.objectVelocities.delete(objectId);
114
+ }
115
+ }
116
+ }
117
+
118
+ updateObjectOptions(id: string, newOptions: any) {
119
+ const state = this.objectStates.get(id);
120
+ if (state) {
121
+ state.options = { ...state.options, ...newOptions };
122
+ }
123
+ }
124
+
125
+ public updateGraphData(nameOrId: string, data: number[]) {
126
+ if (!this.artboard) return;
127
+
128
+ // Find the graph object
129
+ const obj = this.artboard.objects.find(o => o.id === nameOrId || o.name === nameOrId);
130
+ if (obj && obj.geometry.type === 'LineGraph') {
131
+ const geo = obj.geometry as any;
132
+
133
+ if (!geo.datasets || geo.datasets.length === 0) {
134
+ geo.datasets = [{
135
+ id: 'default',
136
+ label: 'Runtime Data',
137
+ data: data,
138
+ lineColor: '#3b82f6',
139
+ lineWidth: 2,
140
+ showArea: false
141
+ }];
142
+ } else {
143
+ geo.datasets[0].data = data;
144
+ }
145
+
146
+ const state = this.objectStates.get(obj.id);
147
+ if (state && state.geometry && state.geometry.type === 'LineGraph') {
148
+ if (!state.geometry.datasets || state.geometry.datasets.length === 0) {
149
+ state.geometry.datasets = JSON.parse(JSON.stringify(geo.datasets));
150
+ } else {
151
+ state.geometry.datasets[0].data = [...data];
152
+ }
153
+ }
154
+
155
+ if (geo.datasets[0].inputId) {
156
+ this.updateInput(geo.datasets[0].inputId, data);
157
+ }
158
+ } else {
159
+ this.updateInput(nameOrId, data);
160
+ }
161
+ }
162
+
58
163
  load(data: Artboard) {
164
+ if (!data) {
165
+ console.warn('[ExodeUIEngine] Attempted to load null artboard');
166
+ return;
167
+ }
168
+ console.log(`[ExodeUIEngine] Loading artboard: ${data.name} (${data.width}x${data.height})`);
59
169
  this.artboard = data;
60
170
  this.reset();
171
+ this.advance(0);
61
172
  }
62
173
 
63
174
  reset() {
64
175
  if (!this.artboard) return;
65
176
  this.objectStates.clear();
177
+ this.objectVelocities.clear();
66
178
  this.inputs.clear();
67
179
  this.inputNameMap.clear();
68
180
  this.layerStates.clear();
69
- this.physicsBodies.clear();
70
- this.physicsEngine = null;
181
+ this.fonts.clear();
182
+
183
+ if (this.physicsEngine) {
184
+ this.physicsEngine.destroy();
185
+ this.physicsEngine = null;
186
+ }
71
187
 
72
188
  // Initialize object states
73
- this.artboard.objects.forEach((obj: ShapeObject) => {
74
- // Deep copy initial state
75
- this.objectStates.set(obj.id, {
76
- ...obj.transform,
77
- style: JSON.parse(JSON.stringify(obj.style || {})),
78
- geometry: JSON.parse(JSON.stringify(obj.geometry || {}))
79
- });
80
- });
81
-
82
- // Initialize Physics Engine if any object has physics enabled
83
- const hasPhysics = this.artboard.objects.some(obj => obj.physics?.enabled);
84
- if (hasPhysics) {
85
- this.physicsEngine = Matter.Engine.create();
86
-
87
- // Configure Global Physics (Gravity)
88
- if (this.artboard.physics) {
89
- this.physicsEngine.gravity.x = this.artboard.physics.gravity.x;
90
- this.physicsEngine.gravity.y = this.artboard.physics.gravity.y;
91
- } else {
92
- this.physicsEngine.gravity.y = 1; // Default
93
- }
94
-
95
- this.artboard.objects.forEach(obj => {
96
- if (obj.physics?.enabled && obj.type === 'Shape') {
97
- const state = this.objectStates.get(obj.id);
98
- const w = (obj.geometry as any).width || 100;
99
- const h = (obj.geometry as any).height || 100;
100
-
101
- const options: Matter.IChamferableBodyDefinition = {
102
- isStatic: obj.physics.bodyType === 'Static',
103
- label: obj.id,
104
- friction: obj.physics.friction,
105
- restitution: obj.physics.restitution,
106
- mass: obj.physics.mass,
107
- frictionAir: obj.physics.frictionAir,
108
- isSensor: obj.physics.isSensor,
109
- angle: state.rotation * (Math.PI / 180) // Convert deg to rad
110
- };
111
-
112
- if (obj.physics.density) {
113
- options.density = obj.physics.density;
114
- }
115
-
116
- let body: Matter.Body | null = null;
117
-
118
- if (obj.geometry.type === 'Rectangle' || obj.geometry.type === 'Image') {
119
- body = Matter.Bodies.rectangle(state.x, state.y, w, h, options);
120
- } else if (obj.geometry.type === 'Ellipse') {
121
- const r = (w + h) / 4;
122
- body = Matter.Bodies.circle(state.x, state.y, r, options);
123
- } else {
124
- body = Matter.Bodies.rectangle(state.x, state.y, w, h, options);
125
- }
189
+ if (this.artboard.objects) {
190
+ this.artboard.objects?.forEach((obj: ShapeObject) => {
191
+ // Deep copy initial state
192
+ const transform = obj.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
193
+ this.objectStates.set(obj.id, {
194
+ x: transform.x ?? 0,
195
+ y: transform.y ?? 0,
196
+ rotation: transform.rotation ?? 0,
197
+ scale_x: transform.scale_x ?? 1,
198
+ scale_y: transform.scale_y ?? 1,
199
+ width: (obj as any).width || (obj.geometry as any).width || 100,
200
+ height: (obj as any).height || (obj.geometry as any).height || 100,
201
+ cornerRadius: (obj as any).cornerRadius ?? (obj as any).corner_radius ?? 0,
202
+ opacity: obj.opacity !== undefined ? obj.opacity : 1,
203
+ visible: obj.visible !== undefined ? obj.visible : (obj.isVisible !== undefined ? obj.isVisible : true),
204
+ blendMode: obj.blendMode || 'Normal',
205
+ style: JSON.parse(JSON.stringify(obj.style || {})),
206
+ geometry: JSON.parse(JSON.stringify(obj.geometry || {})),
207
+ options: (obj as any).options ? JSON.parse(JSON.stringify((obj as any).options)) : {}
208
+ });
209
+ });
126
210
 
127
- if (body) {
128
- Matter.World.add(this.physicsEngine!.world, body);
129
- this.physicsBodies.set(obj.id, body);
211
+ // Initialize Physics Engine if any object has physics enabled
212
+ const hasPhysics = this.artboard.objects.some(obj => obj.physics?.enabled);
213
+ if (hasPhysics) {
214
+ this.physicsEngine = new MatterPhysics();
215
+
216
+ const gravity = this.artboard.physics
217
+ ? { x: this.artboard.physics.gravity.x, y: this.artboard.physics.gravity.y }
218
+ : { x: 0, y: 1 }; // Default
219
+
220
+ this.physicsEngine.init(gravity).then(() => {
221
+ if (!this.physicsEngine) return;
222
+
223
+ this.artboard?.objects?.forEach(obj => {
224
+ if (obj.physics?.enabled && obj.type === 'Shape') {
225
+ const state = this.objectStates.get(obj.id);
226
+ if (!state) return;
227
+
228
+ const w = (obj.geometry as any).width || 100;
229
+ const h = (obj.geometry as any).height || 100;
230
+
231
+ this.physicsEngine!.createBody({
232
+ id: obj.id,
233
+ type: obj.geometry.type as any,
234
+ x: state.x,
235
+ y: state.y,
236
+ width: w,
237
+ height: h,
238
+ rotation: state.rotation * (Math.PI / 180),
239
+ isStatic: obj.physics.bodyType === 'Static',
240
+ mass: obj.physics.mass,
241
+ friction: obj.physics.friction,
242
+ restitution: obj.physics.restitution,
243
+ frictionAir: obj.physics.frictionAir,
244
+ density: obj.physics.density,
245
+ isSensor: obj.physics.isSensor
246
+ });
130
247
  }
131
- }
132
- });
248
+ });
249
+ });
250
+ }
133
251
  }
134
252
 
135
253
  // Initialize State Machine
@@ -137,7 +255,7 @@ export class ExodeUIEngine {
137
255
  this.activeStateMachine = this.artboard.stateMachine;
138
256
 
139
257
  // Init Inputs
140
- this.activeStateMachine.inputs.forEach((input: any) => {
258
+ this.activeStateMachine.inputs?.forEach((input: any) => {
141
259
  this.inputs.set(input.id, input.value.value);
142
260
 
143
261
  // Map Name -> IDs
@@ -147,7 +265,7 @@ export class ExodeUIEngine {
147
265
  });
148
266
 
149
267
  // Init Layers
150
- this.activeStateMachine.layers.forEach((layer: any) => {
268
+ this.activeStateMachine.layers?.forEach((layer: any) => {
151
269
  // Initial Entry
152
270
  const entryState = layer.states.find((s: State) => s.id === layer.entryStateId);
153
271
 
@@ -155,17 +273,18 @@ export class ExodeUIEngine {
155
273
  this.enterStates(layer.name, [entryState.id]);
156
274
  }
157
275
  });
276
+
277
+ this.evaluateTransitions();
158
278
  }
159
279
 
160
280
  if (!this.artboard.stateMachine && this.artboard.animations.length > 0) {
161
- // Check for 'onLoad' animation to play by default
162
- const onLoadAnim = this.artboard.animations.find((a: Animation) => a.name === 'onLoad');
281
+ const onLoadAnim = this.artboard.animations.find((a: SDKAnimation) => a.name === 'onLoad');
163
282
  if (onLoadAnim) {
164
283
  this.layerStates.set('intro', {
165
284
  currentStateIds: ['onLoad'],
166
- animation: onLoadAnim,
285
+ animations: [{ animation: onLoadAnim, state: { loop: true } as any }],
167
286
  time: 0,
168
- currentState: null
287
+ duration: onLoadAnim.duration
169
288
  });
170
289
  }
171
290
  }
@@ -174,30 +293,26 @@ export class ExodeUIEngine {
174
293
  private enterStates(layerName: string, stateIds: string[]) {
175
294
  if (stateIds.length === 0) return;
176
295
 
177
- let anim: Animation | null = null;
178
- let selectedState: State | null = null;
296
+ const activeAnims: { animation: SDKAnimation; state: State | null }[] = [];
297
+ let maxDuration = 0;
179
298
 
180
299
  if (this.artboard && this.activeStateMachine) {
181
300
  const layer = this.activeStateMachine.layers.find(l => l.name === layerName);
182
301
  if (layer) {
183
- // Iterate backwards to find the last state that actually HAS an animation
184
- for (let i = stateIds.length - 1; i >= 0; i--) {
185
- const sId = stateIds[i];
302
+ for (const sId of stateIds) {
186
303
  const state = layer.states.find(s => s.id === sId);
187
-
188
304
  if (state) {
189
- // Priority 1: Direct ID Match
305
+ let anim: SDKAnimation | null = null;
190
306
  if (state.animationId) {
191
307
  anim = this.artboard.animations.find(a => a.id === state.animationId) || null;
192
308
  }
193
- // Priority 2: Name Match (Legacy)
194
309
  if (!anim) {
195
- anim = this.artboard.animations.find((a: Animation) => a.name === state.name || a.id === state.name || (state.name === "Entry" && a.name === "Rotate")) || null;
310
+ anim = this.artboard.animations.find((a: SDKAnimation) => a.name === state.name || a.id === state.name) || null;
196
311
  }
197
312
 
198
313
  if (anim) {
199
- selectedState = state; // Store the state
200
- break; // Found one!
314
+ activeAnims.push({ animation: anim, state });
315
+ maxDuration = Math.max(maxDuration, anim.duration);
201
316
  }
202
317
  }
203
318
  }
@@ -206,9 +321,9 @@ export class ExodeUIEngine {
206
321
 
207
322
  this.layerStates.set(layerName, {
208
323
  currentStateIds: stateIds,
209
- animation: anim,
324
+ animations: activeAnims,
210
325
  time: 0,
211
- currentState: selectedState
326
+ duration: maxDuration
212
327
  });
213
328
  }
214
329
 
@@ -229,11 +344,17 @@ export class ExodeUIEngine {
229
344
  this.updateInput(nameOrId, value);
230
345
  }
231
346
 
347
+ setInputNumberArray(nameOrId: string, value: number[]) {
348
+ this.updateInput(nameOrId, value);
349
+ }
350
+
351
+ setInputStringArray(nameOrId: string, value: string[]) {
352
+ this.updateInput(nameOrId, value);
353
+ }
354
+
232
355
  private updateInput(nameOrId: string, value: any) {
233
- console.log(`[Engine] updateInput: ${nameOrId} -> ${value}`);
234
356
 
235
357
  let inputType: string | undefined;
236
- // Resolve Type
237
358
  if (this.inputs.has(nameOrId)) {
238
359
  const input = this.activeStateMachine?.inputs.find(i => i.id === nameOrId);
239
360
  if (input && typeof input.value === 'object') {
@@ -255,42 +376,36 @@ export class ExodeUIEngine {
255
376
  else if (value === 0) finalValue = false;
256
377
  }
257
378
 
258
- // 1. Try treating as ID
259
379
  if (this.inputs.has(nameOrId)) {
260
380
  this.setInternalInput(nameOrId, finalValue);
261
381
  }
262
382
 
263
- // 2. Try treating as Name (Broadcast)
264
383
  const ids = this.inputNameMap.get(nameOrId);
265
384
  if (ids) {
266
- ids.forEach(id => this.setInternalInput(id, finalValue));
385
+ ids?.forEach(id => this.setInternalInput(id, finalValue));
267
386
  }
268
387
 
269
- // TRIGGER HARD RESET logic
270
388
  if (inputType === 'Trigger' && finalValue === true) {
271
- // Find transition usage to jump immediately
272
- this.activeStateMachine?.layers.forEach(layer => {
273
- layer.states.forEach(state => {
274
- state.transitions.forEach(trans => {
275
- if (trans.conditions) {
276
- const usesTrigger = trans.conditions.some((cond: any) => {
277
- const condInputId = cond.inputId;
278
- return condInputId === nameOrId || (ids && ids.includes(condInputId));
279
- });
280
-
281
- if (usesTrigger && this.checkConditions(trans.conditions)) {
282
- this.enterStates(layer.name, [trans.targetStateId]);
283
- this.setInternalInput(nameOrId, false);
284
- if (ids) {
285
- ids.forEach(id => this.setInternalInput(id, false));
286
- }
287
- return;
288
- }
389
+ this.justFiredTriggers.add(nameOrId);
390
+ if (ids) {
391
+ ids?.forEach(id => this.justFiredTriggers.add(id));
392
+ }
393
+ }
394
+
395
+ // Sync with compatible components (ListView/Dropdown)
396
+ if (Array.isArray(finalValue)) {
397
+ this.artboard?.objects?.forEach(obj => {
398
+ if (obj.inputId === nameOrId || obj.name === nameOrId) {
399
+ const state = this.objectStates.get(obj.id);
400
+ if (state && state.options) {
401
+ if (obj.type === 'ListView' || (obj as any).variant === 'listview') {
402
+ state.options.items = finalValue;
403
+ } else if (obj.type === 'Dropdown' || (obj as any).variant === 'dropdown') {
404
+ state.options.optionsList = finalValue;
289
405
  }
290
- });
291
- });
406
+ }
407
+ }
292
408
  });
293
- return;
294
409
  }
295
410
 
296
411
  this.evaluateTransitions();
@@ -299,7 +414,7 @@ export class ExodeUIEngine {
299
414
  private evaluateTransitions() {
300
415
  if (!this.activeStateMachine) return;
301
416
 
302
- this.activeStateMachine.layers.forEach((layer) => {
417
+ this.activeStateMachine.layers?.forEach((layer) => {
303
418
  const layerState = this.layerStates.get(layer.name);
304
419
  if (!layerState || layerState.currentStateIds.length === 0) return;
305
420
 
@@ -318,19 +433,12 @@ export class ExodeUIEngine {
318
433
  transitioned = true;
319
434
  hasTransition = true;
320
435
 
321
- // Reset Trigger/Number inputs used in this transition
322
436
  if (trans.conditions) {
323
- trans.conditions.forEach((cond: any) => {
437
+ trans.conditions?.forEach((cond: any) => {
324
438
  const input = this.activeStateMachine?.inputs.find(i => i.id === cond.inputId);
325
439
  if (input && typeof input.value === 'object') {
326
- if (input.value.type === 'Trigger') {
327
- this.setInternalInput(cond.inputId, false);
328
- if (this.inputNameMap.has(input.name)) {
329
- const ids = this.inputNameMap.get(input.name);
330
- ids?.forEach(id => this.setInternalInput(id, false));
331
- }
332
- } else if (input.value.type === 'Number') {
333
- this.setInternalInput(cond.inputId, 0);
440
+ if (input.value.type === 'Number') {
441
+ this.setInternalInput(cond.inputId, 0);
334
442
  if (this.inputNameMap.has(input.name)) {
335
443
  const ids = this.inputNameMap.get(input.name);
336
444
  ids?.forEach(id => this.setInternalInput(id, 0));
@@ -347,11 +455,63 @@ export class ExodeUIEngine {
347
455
  }
348
456
  }
349
457
 
458
+ if (!hasTransition) {
459
+ const globalTransition = this.findGlobalTransition(layer, layerState.currentStateIds);
460
+ if (globalTransition) {
461
+ nextStateIds.length = 0;
462
+ nextStateIds.push(globalTransition.targetStateId);
463
+ hasTransition = true;
464
+
465
+ if (globalTransition.conditions) {
466
+ globalTransition.conditions?.forEach((cond: any) => {
467
+ const input = this.activeStateMachine?.inputs.find(i => i.id === cond.inputId);
468
+ if (input && typeof input.value === 'object') {
469
+ if (input.value.type === 'Number') {
470
+ this.setInternalInput(cond.inputId, 0);
471
+ if (this.inputNameMap.has(input.name)) {
472
+ const ids = this.inputNameMap.get(input.name);
473
+ ids?.forEach(id => this.setInternalInput(id, 0));
474
+ }
475
+ }
476
+ }
477
+ });
478
+ }
479
+ }
480
+ }
481
+
350
482
  if (hasTransition) {
351
483
  const uniqueIds = Array.from(new Set(nextStateIds));
352
484
  this.enterStates(layer.name, uniqueIds);
353
485
  }
354
486
  });
487
+
488
+ if (this.justFiredTriggers.size > 0) {
489
+ this.justFiredTriggers?.forEach(id => {
490
+ this.setInternalInput(id, false);
491
+ const input = this.activeStateMachine?.inputs.find(i => i.id === id);
492
+ if (input && this.inputNameMap.has(input.name)) {
493
+ this.inputNameMap.get(input.name)?.forEach(nid => this.setInternalInput(nid, false));
494
+ }
495
+ });
496
+ this.justFiredTriggers.clear();
497
+ }
498
+ }
499
+
500
+ private findGlobalTransition(layer: any, currentStateIds: string[]): any | null {
501
+ for (const state of layer.states) {
502
+ const isActive = currentStateIds.includes(state.id);
503
+ if (isActive && this.justFiredTriggers.size === 0) continue;
504
+
505
+ for (const trans of state.transitions) {
506
+ const targetsActive = currentStateIds.includes(trans.targetStateId);
507
+ if (targetsActive && this.justFiredTriggers.size === 0) continue;
508
+
509
+ if (this.checkConditions(trans.conditions)) {
510
+ return trans;
511
+ }
512
+ }
513
+ }
514
+ return null;
355
515
  }
356
516
 
357
517
  private checkConditions(conditions: any[]): boolean {
@@ -362,7 +522,7 @@ export class ExodeUIEngine {
362
522
  const op = cond.op;
363
523
  const targetValue = cond.value;
364
524
 
365
- const inputValue = this.inputs.get(inputId);
525
+ const inputValue = this.evaluateLogicTree(inputId);
366
526
 
367
527
  if (inputValue === undefined) return false;
368
528
 
@@ -382,10 +542,57 @@ export class ExodeUIEngine {
382
542
  });
383
543
  }
384
544
 
385
- // Trigger Management
545
+ private evaluateLogicTree(sourceId: string, sourceHandleId?: string, visited: Set<string> = new Set()): any {
546
+ if (visited.has(sourceId)) return false;
547
+ visited.add(sourceId);
548
+
549
+ const logicNode = this.activeStateMachine?.logicNodes?.find((n: LogicNode) => n.id === sourceId);
550
+ if (logicNode) {
551
+ const getInputValue = (portId: string, defaultValue: any) => {
552
+ const inputPort = logicNode.inputs.find((i: any) => i.id === portId);
553
+ if (!inputPort) return defaultValue;
554
+
555
+ if (inputPort.sourceId) {
556
+ return this.evaluateLogicTree(inputPort.sourceId, inputPort.sourceHandleId, visited);
557
+ }
558
+ return inputPort.value !== undefined ? inputPort.value : defaultValue;
559
+ };
560
+
561
+ switch (logicNode.op) {
562
+ case LogicOp.AND: return getInputValue('a', false) && getInputValue('b', false);
563
+ case LogicOp.OR: return getInputValue('a', false) || getInputValue('b', false);
564
+ case LogicOp.NOT: return !getInputValue('in', false);
565
+ case LogicOp.XOR: return !!getInputValue('a', false) !== !!getInputValue('b', false);
566
+ default: return 0;
567
+ }
568
+ }
569
+
570
+ let value = this.inputs.get(sourceId);
571
+ if (this.justFiredTriggers.has(sourceId)) {
572
+ value = true;
573
+ }
574
+
575
+ if (sourceHandleId && value !== undefined) {
576
+ const input = this.activeStateMachine?.inputs.find(i => i.id === sourceId);
577
+ if (input && input.value.type === 'Number' && typeof value === 'number') {
578
+ const threshold = (input.value as any).defaultValue ?.(input.value as any).value;
579
+ if (sourceHandleId === 'source-greater') return value > threshold;
580
+ if (sourceHandleId === 'source-less') return value < threshold;
581
+ if (sourceHandleId === 'source-equal') return value === threshold;
582
+ }
583
+ if (sourceHandleId === 'source-true') return value === true;
584
+ if (sourceHandleId === 'source-false') return value === false;
585
+ if (sourceHandleId === 'source-fire') {
586
+ return this.justFiredTriggers.has(sourceId);
587
+ }
588
+ }
589
+
590
+ return value !== undefined ? value : 0;
591
+ }
592
+
386
593
  private activeTriggers: Map<string, {
387
594
  triggerId: string;
388
- animation: Animation;
595
+ animation: SDKAnimation;
389
596
  time: number;
390
597
  phase: 'entry' | 'hold' | 'exit';
391
598
  elapsedHold: number;
@@ -396,15 +603,22 @@ export class ExodeUIEngine {
396
603
 
397
604
  // 1. Step Physics
398
605
  if (this.physicsEngine) {
399
- Matter.Engine.update(this.physicsEngine, dt * 1000);
400
-
401
- // Sync Physics -> State
402
- this.physicsBodies.forEach((body, id) => {
403
- const state = this.objectStates.get(id);
404
- if (state) {
405
- state.x = body.position.x;
406
- state.y = body.position.y;
407
- state.rotation = body.angle * (180 / Math.PI); // Rad -> Deg
606
+ this.physicsEngine.step(dt);
607
+
608
+ this.artboard.objects?.forEach(obj => {
609
+ if (obj.physics?.enabled) {
610
+ const pos = this.physicsEngine!.getPosition(obj.id);
611
+ const rot = this.physicsEngine!.getRotation(obj.id);
612
+ const state = this.objectStates.get(obj.id);
613
+
614
+ if (state && pos !== null) {
615
+ state.x = pos.x;
616
+ state.y = pos.y;
617
+ }
618
+
619
+ if (state && rot !== null) {
620
+ state.rotation = rot * (180 / Math.PI);
621
+ }
408
622
  }
409
623
  });
410
624
  }
@@ -413,31 +627,35 @@ export class ExodeUIEngine {
413
627
  if (this.activeStateMachine) {
414
628
  this.evaluateTransitions();
415
629
 
416
- this.layerStates.forEach(state => {
417
- if (state.animation) {
418
- state.time += dt;
419
-
420
- const duration = state.animation.duration;
421
- const shouldLoop = state.currentState?.loop !== false;
630
+ this.layerStates?.forEach(layerState => {
631
+ if (layerState.animations.length > 0) {
632
+ layerState.time += dt;
422
633
 
423
- if (state.time > duration) {
424
- if (shouldLoop) {
425
- state.time %= duration;
634
+ if (layerState.time > layerState.duration) {
635
+ const canLoop = layerState.animations.some(a => a.state?.loop === true);
636
+ if (canLoop) {
637
+ layerState.time %= layerState.duration;
426
638
  } else {
427
- state.time = duration;
639
+ layerState.time = layerState.duration;
428
640
  }
429
641
  }
430
642
 
431
- this.applyAnimation(state.animation, state.time);
643
+ layerState.animations?.forEach(animObj => {
644
+ this.applyAnimation(animObj.animation, layerState.time);
645
+ });
432
646
  }
433
647
  });
434
648
  }
435
649
 
650
+ // Solve Constraints
651
+ this.solveConstraints(dt);
652
+
436
653
  // Advance Active Triggers
437
- this.activeTriggers.forEach((state, objectId) => {
654
+ this.activeTriggers?.forEach((state, objectId) => {
438
655
  if (state.phase === 'entry') {
439
656
  state.time += dt;
440
657
  if (state.time >= state.animation.duration) {
658
+ const trigger = this.artboard?.objects.find(o => o.id === objectId)?.triggers?.find(t => t.id === state.triggerId);
441
659
  state.phase = 'hold';
442
660
  state.elapsedHold = 0;
443
661
  state.time = state.animation.duration;
@@ -471,6 +689,97 @@ export class ExodeUIEngine {
471
689
  }
472
690
  }
473
691
  });
692
+
693
+ // 4. Apply Bindings
694
+ this.applyBindings();
695
+ }
696
+
697
+ private applyBindings() {
698
+ if (!this.artboard) return;
699
+ this.artboard.objects?.forEach(obj => {
700
+ const bindings = (obj as any).bindings || [];
701
+ if (bindings.length > 0) {
702
+ bindings?.forEach((binding: any) => {
703
+ const val = this.evaluateLogicTree(binding.inputId);
704
+ const state = this.objectStates.get(obj.id);
705
+ if (state && val !== undefined) {
706
+ state[binding.property] = val;
707
+ }
708
+ });
709
+ }
710
+ });
711
+ }
712
+
713
+ private solveConstraints(dt: number) {
714
+ if (!this.artboard || !this.artboard.objects) return;
715
+
716
+ this.artboard.objects?.forEach(obj => {
717
+ if (obj.constraints && obj.constraints.length > 0) {
718
+ obj.constraints?.forEach(constraint => {
719
+ this.applyConstraint(obj.id, constraint, dt);
720
+ });
721
+ }
722
+ });
723
+ }
724
+
725
+ private applyConstraint(objectId: string, constraint: Constraint, dt: number) {
726
+ const state = this.objectStates.get(objectId);
727
+ const targetState = this.objectStates.get(constraint.targetId);
728
+
729
+ if (!state || !targetState) return;
730
+
731
+ const strength = constraint.strength ?? 1;
732
+
733
+ switch (constraint.type) {
734
+ case 'Translation':
735
+ if (constraint.useSpring) {
736
+ this.applySpring(objectId, 'x', targetState.x, constraint, dt);
737
+ this.applySpring(objectId, 'y', targetState.y, constraint, dt);
738
+ } else {
739
+ if (constraint.copyX !== false) state.x += (targetState.x - state.x) * strength;
740
+ if (constraint.copyY !== false) state.y += (targetState.y - state.y) * strength;
741
+ }
742
+ break;
743
+ case 'Rotation':
744
+ if (constraint.useSpring) {
745
+ this.applySpring(objectId, 'rotation', targetState.rotation, constraint, dt);
746
+ } else {
747
+ state.rotation += (targetState.rotation - state.rotation) * strength;
748
+ }
749
+ break;
750
+ case 'Scale':
751
+ if (constraint.useSpring) {
752
+ this.applySpring(objectId, 'scale_x', targetState.scale_x, constraint, dt);
753
+ this.applySpring(objectId, 'scale_y', targetState.scale_y, constraint, dt);
754
+ } else {
755
+ state.scale_x += (targetState.scale_x - state.scale_x) * strength;
756
+ state.scale_y += (targetState.scale_y - state.scale_y) * strength;
757
+ }
758
+ break;
759
+ }
760
+ }
761
+
762
+ private applySpring(objectId: string, property: string, targetValue: number, constraint: Constraint, dt: number) {
763
+ const state = this.objectStates.get(objectId);
764
+ if (!state) return;
765
+
766
+ const current = state[property];
767
+ const velocityKey = `${objectId}_${property}`;
768
+ let v = this.objectVelocities.get(velocityKey) || { vx: 0, vy: 0 };
769
+
770
+ const k = constraint.stiffness ?? 100;
771
+ const d = constraint.damping ?? 10;
772
+ const m = constraint.mass ?? 1;
773
+
774
+ // F = -k * (x - target) - d * v
775
+ const x = current - targetValue;
776
+ const f = -k * x - d * v.vx;
777
+ const a = f / m;
778
+
779
+ v.vx += a * dt;
780
+ state[property] += v.vx * dt;
781
+
782
+ this.objectVelocities.set(velocityKey, v);
474
783
  }
475
784
 
476
785
  handlePointerInput(type: string, canvasX: number, canvasY: number, canvasWidth: number, canvasHeight: number) {
@@ -486,72 +795,437 @@ export class ExodeUIEngine {
486
795
  const artboardX = ((canvasX - transform.tx) / transform.scaleX) - (this.artboard.width / 2);
487
796
  const artboardY = ((canvasY - transform.ty) / transform.scaleY) - (this.artboard.height / 2);
488
797
 
798
+ this._prevPointerPos = this._lastPointerPos;
799
+ this._lastPointerPos = { x: artboardX, y: artboardY };
800
+
801
+ this.updateInput('mouseX', artboardX);
802
+ this.updateInput('mouseY', artboardY);
803
+
489
804
  this.handlePointerEvent(type, artboardX, artboardY);
490
805
  }
491
806
 
492
807
  private handlePointerEvent(type: string, x: number, y: number) {
493
808
  if (!this.artboard) return;
494
809
 
810
+ let hitObj: any = null;
811
+ console.log(`[ExodeUIEngine] PointerEvent: ${type} at ${x.toFixed(1)},${y.toFixed(1)}`);
812
+
813
+ const objectHandlesEvent = (obj: any, evType: string) => {
814
+ let artboardEvent = evType;
815
+ if (evType === 'click') artboardEvent = 'onClick';
816
+ else if (evType === 'PointerDown') artboardEvent = 'onPointerDown';
817
+ else if (evType === 'PointerUp') artboardEvent = 'onPointerUp';
818
+ else if (evType === 'PointerMove') artboardEvent = 'hover';
819
+
820
+ const aliases = new Set([artboardEvent]);
821
+ if (evType === 'PointerMove') {
822
+ aliases.add('onPointerEnter').add('hover').add('onPointerLeave');
823
+ }
824
+ const interacts = ['button', 'toggle', 'toggle_button', 'dropdown', 'listview', 'inputbox', 'slider'];
825
+ if (obj.type === 'Component' && interacts.includes((obj as any).variant)) return true;
826
+ if (interacts.includes(obj.type?.toLowerCase())) return true;
827
+
828
+ const interactions = obj.interactions || [];
829
+ if (interactions.some((int: any) => aliases.has(int.event))) return true;
830
+
831
+ const triggers = obj.triggers || [];
832
+ if (triggers.some((t: any) => aliases.has(t.eventType))) return true;
833
+
834
+ return false;
835
+ };
836
+
837
+ let topHit: any = null;
495
838
  for (let i = this.artboard.objects.length - 1; i >= 0; i--) {
496
839
  const obj = this.artboard.objects[i];
497
- const isHit = this.hitTest(obj, x, y);
498
-
499
- if (isHit) {
500
- // Check interactions
501
- const interactions = (obj as any).interactions || [];
502
- const matchingInteraction = interactions.find((int: any) => int.event === type || (int.event === 'onClick' && type === 'click'));
503
-
504
- if (matchingInteraction) {
505
- if (matchingInteraction.action === 'setInput') {
506
- this.updateInput(matchingInteraction.targetInputId, matchingInteraction.value);
507
- } else if (matchingInteraction.action === 'fireTrigger') {
508
- this.updateInput(matchingInteraction.targetInputId, true);
509
- }
840
+ const state = this.objectStates.get(obj.id);
841
+ if (state && state.visible !== false && state.opacity > 0 && this.hitTest(obj, x, y)) {
842
+ if (!topHit) topHit = obj;
843
+ if (objectHandlesEvent(obj, type)) {
844
+ hitObj = obj;
845
+ break;
846
+ }
847
+ }
848
+ }
849
+
850
+ if (!hitObj && topHit) hitObj = topHit;
851
+
852
+ if (hitObj) {
853
+ console.log(`[ExodeUIEngine] Hit object: ${hitObj.name || hitObj.id} (${hitObj.type})`);
854
+ } else {
855
+ console.log(`[ExodeUIEngine] No object hit at ${x.toFixed(1)},${y.toFixed(1)}`);
856
+ }
857
+ const hitId = hitObj?.id || null;
858
+
859
+ if (type === 'PointerMove') {
860
+ if (this.draggingSliderId) {
861
+ const obj = this.artboard.objects.find(o => o.id === this.draggingSliderId);
862
+ if (obj) this.updateSliderValueFromPointer(obj, x, y);
863
+ return;
864
+ }
865
+
866
+ if (this.draggingListViewId) {
867
+ const obj = this.artboard.objects.find(o => o.id === this.draggingListViewId);
868
+ if (obj) this.updateListViewScrollFromPointer(obj, x, y);
869
+ return;
870
+ }
871
+
872
+ if (hitId !== this.lastHoveredObjectId) {
873
+ if (this.lastHoveredObjectId) {
874
+ this.fireEventForObject(this.lastHoveredObjectId, 'onPointerLeave');
510
875
  }
876
+ if (hitId) {
877
+ this.fireEventForObject(hitId, 'onPointerEnter');
878
+ }
879
+ this.lastHoveredObjectId = hitId;
880
+ }
881
+ return;
882
+ }
511
883
 
512
- // Check triggers
513
- const triggers = obj.triggers || [];
514
- const matchingTrigger = triggers.find(t => t.eventType === type || (t.eventType === 'onClick' && type === 'click'));
515
-
516
- if (matchingTrigger && matchingTrigger.entryAnimationId) {
517
- const anim = this.artboard.animations.find(a => a.id === matchingTrigger.entryAnimationId);
518
- if (anim) {
519
- this.activeTriggers.set(obj.id, {
520
- triggerId: matchingTrigger.id,
521
- animation: anim,
522
- time: 0,
523
- phase: 'entry',
524
- elapsedHold: 0
525
- });
884
+ if (type === 'PointerUp') {
885
+ this.draggingSliderId = null;
886
+ this.draggingListViewId = null;
887
+ }
888
+
889
+ if (this.activeDropdownId && type === 'PointerDown') {
890
+ const activeObj = this.artboard.objects.find(o => o.id === this.activeDropdownId);
891
+ if (activeObj && (activeObj.type === 'Dropdown' || (activeObj as any).variant === 'dropdown')) {
892
+ const state = this.objectStates.get(activeObj.id);
893
+ if (state) {
894
+ const opts = state.options || {};
895
+ const w = state.width || (activeObj.geometry as any)?.width || 160;
896
+ const h = state.height || (activeObj.geometry as any)?.height || 40;
897
+ const world = this.getWorldTransform(activeObj.id);
898
+
899
+ if (hitId !== this.activeDropdownId) {
900
+ // Check if click is inside the expanded options list
901
+ const optionsList = opts.optionsList || [];
902
+ const itemH = opts.itemHeight || 36;
903
+ const listTop = world.y + h / 2;
904
+ const listBottom = listTop + optionsList.length * itemH;
905
+ const listLeft = world.x - w / 2;
906
+ const listRight = world.x + w / 2;
526
907
 
527
- if (this.onTrigger) {
528
- this.onTrigger(matchingTrigger.name, anim.name);
908
+ if (x >= listLeft && x <= listRight && y >= listTop && y <= listBottom) {
909
+ // Hit inside the options list — select item
910
+ const itemIndex = Math.floor((y - listTop) / itemH);
911
+ if (itemIndex >= 0 && itemIndex < optionsList.length) {
912
+ const selected = optionsList[itemIndex];
913
+ const selectedValue = typeof selected === 'string' ? selected : (selected?.label || selected?.value || itemIndex);
914
+ opts.activeItemIndex = itemIndex;
915
+ opts.selectedValue = selectedValue;
916
+ opts.isOpen = false;
917
+ this.activeDropdownId = null;
918
+ if (activeObj.inputId) this.updateInput(activeObj.inputId, selectedValue);
919
+ if (this.onComponentChange) {
920
+ this.onComponentChange({ objectId: activeObj.id, componentName: activeObj.name, variant: 'dropdown', property: 'selected', value: selectedValue });
921
+ }
922
+ console.log(`[ExodeUIEngine] Dropdown selected: ${selectedValue}`);
923
+ }
924
+ } else {
925
+ // Tap outside — close
926
+ opts.isOpen = false;
927
+ this.activeDropdownId = null;
529
928
  }
530
929
  return;
531
930
  }
532
931
  }
533
932
  }
534
933
  }
934
+
935
+ if (hitObj) {
936
+ let targetObj = hitObj;
937
+ let current: any = hitObj;
938
+ while (current && current.parentId) {
939
+ const parent = this.artboard.objects.find(o => o.id === current.parentId);
940
+ if (parent && parent.type === 'Component') {
941
+ targetObj = parent;
942
+ break;
943
+ }
944
+ current = parent;
945
+ }
946
+
947
+ let artboardEvent = type;
948
+ if (type === 'click') artboardEvent = 'onClick';
949
+ else if (type === 'PointerDown') artboardEvent = 'onPointerDown';
950
+ else if (type === 'PointerUp') artboardEvent = 'onPointerUp';
951
+
952
+ this.fireEventForObject(hitObj.id, artboardEvent);
953
+ if (targetObj !== hitObj) {
954
+ this.fireEventForObject(targetObj.id, artboardEvent);
955
+ }
956
+
957
+ if (targetObj.type === 'Component' && (targetObj as any).variant === 'toggle') {
958
+ if (type === 'PointerDown') {
959
+ const state = this.objectStates.get(targetObj.id);
960
+ const opts = state?.options || targetObj.options || {};
961
+ opts.checked = !opts.checked;
962
+ if (targetObj.inputId) this.updateInput(targetObj.inputId, opts.checked);
963
+ if (this.onToggle) this.onToggle(targetObj.name, opts.checked);
964
+ }
965
+ }
966
+
967
+ if (targetObj.type === 'Component' && (targetObj as any).variant === 'slider') {
968
+ if (type === 'PointerDown') {
969
+ this.draggingSliderId = targetObj.id;
970
+ this.updateSliderValueFromPointer(targetObj, x, y);
971
+ }
972
+ }
973
+
974
+ if (targetObj.type === 'Component' && (targetObj as any).variant === 'dropdown') {
975
+ if (type === 'PointerDown') {
976
+ const state = this.objectStates.get(targetObj.id);
977
+ if (!state) return;
978
+ if (!state.options) state.options = {};
979
+ const isOpening = !state.options.isOpen;
980
+ state.options.isOpen = isOpening;
981
+ this.activeDropdownId = isOpening ? targetObj.id : null;
982
+ console.log(`[ExodeUIEngine] Dropdown toggled: isOpen=${isOpening}`);
983
+ }
984
+ }
985
+
986
+ if (targetObj.type === 'Component' && (targetObj as any).variant === 'listview') {
987
+ if (type === 'PointerDown') {
988
+ this.draggingListViewId = targetObj.id;
989
+ this._prevPointerPos = { x, y };
990
+
991
+ const itemIndex = this.getListViewItemIndexAtPointer(targetObj, x, y);
992
+ if (itemIndex !== null) {
993
+ const state = this.objectStates.get(targetObj.id) || {};
994
+ const opts = state.options || targetObj.options || {};
995
+
996
+ opts.activeItemIndex = itemIndex;
997
+ const selected = (opts.items || [])[itemIndex];
998
+ const selectedValue = typeof selected === 'string' ? selected : (selected?.label || selected?.value || itemIndex);
999
+ opts.selectedValue = selectedValue;
1000
+
1001
+ if (targetObj.inputId) {
1002
+ this.updateInput(targetObj.inputId, selectedValue);
1003
+ }
1004
+
1005
+ if (this.onComponentChange) {
1006
+ this.onComponentChange({
1007
+ objectId: targetObj.id,
1008
+ componentName: targetObj.name,
1009
+ variant: 'listview',
1010
+ property: 'activeItemIndex',
1011
+ value: itemIndex
1012
+ });
1013
+ this.onComponentChange({
1014
+ objectId: targetObj.id,
1015
+ componentName: targetObj.name,
1016
+ variant: 'listview',
1017
+ property: 'selected',
1018
+ value: selectedValue
1019
+ });
1020
+ }
1021
+ }
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ private fireEventForObject(objectId: string, eventType: string) {
1028
+ if (!this.artboard) return;
1029
+ const obj = this.artboard.objects.find(o => o.id === objectId);
1030
+ if (!obj) return;
1031
+
1032
+ const eventAliases = new Set([eventType]);
1033
+ if (eventType === 'onPointerEnter') eventAliases.add('hover');
1034
+ if (eventType === 'onPointerLeave') eventAliases.add('blur');
1035
+
1036
+ const interactions = (obj as any).interactions || [];
1037
+ const matchingInteraction = interactions.find((int: any) => eventAliases.has(int.event));
1038
+
1039
+ if (matchingInteraction) {
1040
+ if (matchingInteraction.action === 'setInput') {
1041
+ this.updateInput(matchingInteraction.targetInputId, matchingInteraction.value);
1042
+ } else if (matchingInteraction.action === 'fireTrigger') {
1043
+ this.updateInput(matchingInteraction.targetInputId, true);
1044
+ }
1045
+ }
1046
+
1047
+ const triggers = obj.triggers || [];
1048
+ const matchingTrigger = triggers.find(t => eventAliases.has(t.eventType));
1049
+
1050
+ if (matchingTrigger && matchingTrigger.entryAnimationId) {
1051
+ const anim = this.artboard.animations.find(a => a.id === matchingTrigger.entryAnimationId);
1052
+ if (anim) {
1053
+ this.activeTriggers.set(obj.id, {
1054
+ triggerId: matchingTrigger.id,
1055
+ animation: anim,
1056
+ time: 0,
1057
+ phase: 'entry',
1058
+ elapsedHold: 0
1059
+ });
1060
+ if (this.onTrigger) this.onTrigger(matchingTrigger.name, anim.name);
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ private updateSliderValueFromPointer(obj: any, x: number, y: number) {
1066
+ const state = this.objectStates.get(obj.id);
1067
+ const world = this.getWorldTransform(obj.id);
1068
+ const opts = state?.options || obj.options || {};
1069
+ const w = obj.geometry?.width || 200;
1070
+
1071
+ const thumbWidth = opts.thumbWidth ?? 16;
1072
+ const travelW = w - thumbWidth;
1073
+
1074
+ const relativeX = (x - world.x) / world.scaleX;
1075
+ const percentage = Math.max(0, Math.min(1, (relativeX + travelW / 2) / travelW));
1076
+
1077
+ const min = opts.min ?? 0;
1078
+ const max = opts.max ?? 100;
1079
+ const newValue = min + (percentage * (max - min));
1080
+
1081
+ this.updateObjectOptions(obj.id, { value: newValue });
1082
+ if (obj.inputId) this.updateInput(obj.inputId, newValue);
1083
+ if (this.onComponentChange) {
1084
+ this.onComponentChange({
1085
+ objectId: obj.id,
1086
+ componentName: obj.name,
1087
+ variant: 'slider',
1088
+ property: 'value',
1089
+ value: newValue
1090
+ });
1091
+ }
1092
+ }
1093
+
1094
+ private updateListViewScrollFromPointer(obj: any, x: number, y: number) {
1095
+ if (!this._prevPointerPos) {
1096
+ this._prevPointerPos = { x, y };
1097
+ return;
1098
+ }
1099
+ const dy = y - this._prevPointerPos.y;
1100
+ const state = this.objectStates.get(obj.id) || {};
1101
+ const opts = state.options || obj.options || {};
1102
+ opts.scrollOffset = (opts.scrollOffset || 0) + dy;
1103
+
1104
+ const items = opts.items || [];
1105
+ const itemGap = opts.itemGap ?? 10;
1106
+ const itemHeight = opts.itemHeight ?? 50;
1107
+ const h = state.height !== undefined ? state.height : (obj.geometry?.height || 300);
1108
+
1109
+ const totalSize = (items.length || 0) * itemHeight + Math.max(0, (items.length || 0) - 1) * itemGap;
1110
+ const maxScroll = Math.max(0, totalSize - h + (opts.padding * 2 || 0));
1111
+
1112
+ if (opts.scrollOffset > 0) opts.scrollOffset = 0;
1113
+ if (opts.scrollOffset < -maxScroll) opts.scrollOffset = -maxScroll;
1114
+
1115
+ this._prevPointerPos = { x, y };
1116
+ }
1117
+
1118
+ private updateListViewHoverFromPointer(obj: any, x: number, y: number) {
1119
+ const index = this.getListViewItemIndexAtPointer(obj, x, y);
1120
+ const state = this.objectStates.get(obj.id) || {};
1121
+ const opts = state.options || obj.options || {};
1122
+ if (opts.hoveredIndex !== index) {
1123
+ opts.hoveredIndex = index;
1124
+ }
1125
+ }
1126
+
1127
+ private getListViewItemIndexAtPointer(obj: any, x: number, y: number): number | null {
1128
+ const state = this.objectStates.get(obj.id) || {};
1129
+ const opts = state.options || obj.options || {};
1130
+ const items = opts.items || [];
1131
+ if (items.length === 0) return null;
1132
+
1133
+ const w = state.width !== undefined ? state.width : (obj.geometry?.width || 200);
1134
+ const h = state.height !== undefined ? state.height : (obj.geometry?.height || 300);
1135
+ const itemGap = opts.itemGap ?? 10;
1136
+ const itemHeight = opts.itemHeight ?? 50;
1137
+ const scrollOffset = opts.scrollOffset ?? 0;
1138
+ let padVal = opts.padding ?? 0;
1139
+ if (typeof padVal !== 'number') padVal = padVal[0] || 0;
1140
+
1141
+ const worldTransform = this.getWorldTransform(obj.id);
1142
+ const dx = x - worldTransform.x;
1143
+ const dy = y - worldTransform.y;
1144
+ const rad = -(worldTransform.rotation) * (Math.PI / 180);
1145
+ const cos = Math.cos(rad);
1146
+ const sin = Math.sin(rad);
1147
+ const localX = dx * cos - dy * sin;
1148
+ const localY = dx * sin + dy * cos;
1149
+
1150
+ const startX = -w / 2;
1151
+ const startY = -h / 2;
1152
+
1153
+ let currentPos = startY + scrollOffset + padVal;
1154
+
1155
+ for (let i = 0; i < items.length; i++) {
1156
+ const iW = w;
1157
+ const iH = itemHeight;
1158
+ const iX = startX;
1159
+ const iY = currentPos;
1160
+
1161
+ if (localX >= iX && localX <= iX + iW && localY >= iY && localY <= iY + iH) {
1162
+ return i;
1163
+ }
1164
+ currentPos += itemHeight + itemGap;
1165
+ }
1166
+
1167
+ return null;
535
1168
  }
536
1169
 
537
1170
  private hitTest(obj: ShapeObject, x: number, y: number): boolean {
538
1171
  const state = this.objectStates.get(obj.id);
539
- if (!state) return false;
1172
+ if (!state || state.visible === false) return false;
1173
+
1174
+ const world = this.getWorldTransform(obj.id);
1175
+ const w = state.width || (obj.geometry as any).width || 100;
1176
+ const h = state.height || (obj.geometry as any).height || 100;
1177
+
1178
+ const dx = x - world.x;
1179
+ const dy = y - world.y;
1180
+
1181
+ // Basic AABB check in world space
1182
+ // Note: Full matrix-based hit test would be better for rotated objects
1183
+ return Math.abs(dx) <= (w * Math.abs(world.scaleX)) / 2 &&
1184
+ Math.abs(dy) <= (h * Math.abs(world.scaleY)) / 2;
1185
+ }
540
1186
 
541
- const w = (obj.geometry as any).width || 100;
542
- const h = (obj.geometry as any).height || 100;
1187
+ private getWorldTransform(objId: string): { x: number, y: number, scaleX: number, scaleY: number, rotation: number } {
1188
+ const state = this.objectStates.get(objId);
1189
+ if (!state) return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 };
543
1190
 
544
- const dx = x - state.x;
545
- const dy = y - state.y;
1191
+ const obj = this.artboard?.objects.find(o => o.id === objId);
1192
+ if (!obj || !obj.parentId) {
1193
+ return {
1194
+ x: state.x,
1195
+ y: state.y,
1196
+ scaleX: state.scale_x ?? 1,
1197
+ scaleY: state.scale_y ?? 1,
1198
+ rotation: state.rotation ?? 0
1199
+ };
1200
+ }
1201
+
1202
+ const parentWorld = this.getWorldTransform(obj.parentId);
546
1203
 
547
- return Math.abs(dx) <= w / 2 && Math.abs(dy) <= h / 2;
1204
+ const rad = parentWorld.rotation * (Math.PI / 180);
1205
+ const cos = Math.cos(rad);
1206
+ const sin = Math.sin(rad);
1207
+
1208
+ const sx = state.x * parentWorld.scaleX;
1209
+ const sy = state.y * parentWorld.scaleY;
1210
+
1211
+ const worldX = parentWorld.x + (sx * cos - sy * sin);
1212
+ const worldY = parentWorld.y + (sx * sin + sy * cos);
1213
+
1214
+ return {
1215
+ x: worldX,
1216
+ y: worldY,
1217
+ scaleX: (state.scale_x ?? 1) * parentWorld.scaleX,
1218
+ scaleY: (state.scale_y ?? 1) * parentWorld.scaleY,
1219
+ rotation: parentWorld.rotation + (state.rotation ?? 0)
1220
+ };
548
1221
  }
549
1222
 
550
- private applyAnimation(anim: Animation, time: number) {
551
- anim.tracks.forEach((track: any) => {
552
- const isPhysicsControlled = this.physicsBodies.has(track.object_id) &&
553
- this.physicsBodies.get(track.object_id)?.isStatic === false;
554
-
1223
+ private applyAnimation(anim: SDKAnimation, time: number) {
1224
+ anim.tracks?.forEach((track: any) => {
1225
+ // Skip physics controlled objects if they are dynamic
1226
+ const isPhysicsControlled = this.physicsEngine &&
1227
+ this.physicsEngine.isDynamic(track.object_id);
1228
+
555
1229
  if (isPhysicsControlled && (['x', 'y', 'rotation'].includes(track.property) || track.property.startsWith('transform.'))) {
556
1230
  return;
557
1231
  }
@@ -574,14 +1248,13 @@ export class ExodeUIEngine {
574
1248
  }
575
1249
 
576
1250
  // Sync State -> Physics Body (for Kinematic/Static animations)
577
- const body = this.physicsBodies.get(track.object_id);
578
- if (body && body.isStatic) {
1251
+ if (this.physicsEngine && !this.physicsEngine.isDynamic(track.object_id)) {
579
1252
  if (track.property === 'x' || track.property === 'transform.x') {
580
- Matter.Body.setPosition(body, { x: value, y: body.position.y });
1253
+ this.physicsEngine.setPosition(track.object_id, value, objState.y);
581
1254
  } else if (track.property === 'y' || track.property === 'transform.y') {
582
- Matter.Body.setPosition(body, { x: body.position.x, y: value });
1255
+ this.physicsEngine.setPosition(track.object_id, objState.x, value);
583
1256
  } else if (track.property === 'rotation' || track.property === 'transform.rotation') {
584
- Matter.Body.setAngle(body, value * (Math.PI / 180));
1257
+ this.physicsEngine.setRotation(track.object_id, value * (Math.PI / 180));
585
1258
  }
586
1259
  }
587
1260
  });
@@ -605,6 +1278,7 @@ export class ExodeUIEngine {
605
1278
  }
606
1279
 
607
1280
  const duration = end.time - bg.time;
1281
+ if (duration <= 0) return end.value;
608
1282
  const t = (time - bg.time) / duration;
609
1283
 
610
1284
  if (typeof bg.value === 'string' && bg.value.startsWith('#')) {
@@ -620,6 +1294,13 @@ export class ExodeUIEngine {
620
1294
  private interpolateColor(c1: string, c2: string, t: number): string {
621
1295
  const parse = (c: string) => {
622
1296
  const hex = c.replace('#', '');
1297
+ if (hex.length === 3) {
1298
+ return {
1299
+ r: parseInt(hex[0] + hex[0], 16),
1300
+ g: parseInt(hex[1] + hex[1], 16),
1301
+ b: parseInt(hex[2] + hex[2], 16)
1302
+ };
1303
+ }
623
1304
  return {
624
1305
  r: parseInt(hex.substring(0, 2), 16),
625
1306
  g: parseInt(hex.substring(2, 4), 16),
@@ -647,11 +1328,28 @@ export class ExodeUIEngine {
647
1328
  return;
648
1329
  }
649
1330
 
650
- canvas.clear(Skia.Color(this.artboard.backgroundColor || '#000000'));
1331
+ try {
1332
+ const bg = this.artboard.backgroundColor || '#000000';
1333
+ if (bg === 'transparent' || bg === 'rgba(0,0,0,0)') {
1334
+ canvas.clear(Skia.Color('rgba(0,0,0,0)'));
1335
+ } else {
1336
+ canvas.clear(Skia.Color(bg));
1337
+ }
1338
+ } catch (e) {
1339
+ canvas.clear(Skia.Color('#000000'));
1340
+ }
1341
+ this._renderCount++;
1342
+ if (this._renderCount % 60 === 0) {
1343
+ console.log(`[ExodeUIEngine] Rendering frame ${this._renderCount}, objects: ${this.artboard.objects?.length}`);
1344
+ }
651
1345
 
652
1346
  const abWidth = this.artboard.width;
653
1347
  const abHeight = this.artboard.height;
654
1348
 
1349
+ if (this._renderCount % 60 === 1) {
1350
+ console.log(`[ExodeUIEngine] Artboard size: ${abWidth}x${abHeight}, Canvas size: ${width}x${height}`);
1351
+ }
1352
+
655
1353
  // Calculate Layout Transform
656
1354
  const transform = this.calculateTransform(
657
1355
  width, height,
@@ -664,16 +1362,22 @@ export class ExodeUIEngine {
664
1362
  canvas.translate(transform.tx, transform.ty);
665
1363
  canvas.scale(transform.scaleX, transform.scaleY);
666
1364
 
1365
+ if (this._renderCount % 60 === 1) {
1366
+ console.log(`[ExodeUIEngine] Viewport Transform: tx=${transform.tx.toFixed(2)}, ty=${transform.ty.toFixed(2)}, scale=${transform.scaleX.toFixed(2)}`);
1367
+ }
1368
+
667
1369
  // Center the Artboard Origin
668
1370
  canvas.translate(abWidth / 2, abHeight / 2);
669
1371
 
670
1372
  // Clip to artboard bounds
671
1373
  const clipPath = Skia.Path.Make();
672
1374
  clipPath.addRect({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight });
673
- canvas.clipPath(clipPath, 1, true);
1375
+ canvas.clipPath(clipPath, 1, true); // 1 = Intersect
674
1376
 
675
- this.artboard.objects.forEach((obj: ShapeObject) => {
676
- this.renderObject(canvas, obj);
1377
+ this.artboard.objects?.forEach((obj: ShapeObject) => {
1378
+ if (!obj.parentId) {
1379
+ this.renderObject(canvas, obj);
1380
+ }
677
1381
  });
678
1382
 
679
1383
  canvas.restore();
@@ -758,106 +1462,555 @@ export class ExodeUIEngine {
758
1462
  return { scaleX, scaleY, tx, ty };
759
1463
  }
760
1464
 
761
- private renderObject(canvas: SkCanvas, obj: ShapeObject) {
1465
+ private renderObject(canvas: any, obj: ShapeObject) {
762
1466
  const state = this.objectStates.get(obj.id);
763
- if (!state) return;
1467
+ if (!state || state.visible === false) return;
764
1468
 
765
1469
  const geometry = state.geometry || obj.geometry;
766
-
767
- const w = (geometry as any).width || 0;
768
- const h = (geometry as any).height || 0;
769
-
770
- const cx = state.x;
771
- const cy = state.y;
1470
+ const w = state.width || (geometry as any).width || 0;
1471
+ const h = state.height || (geometry as any).height || 0;
1472
+ const cx = state.x || 0;
1473
+ const cy = state.y || 0;
1474
+ const rotation = state.rotation || 0;
1475
+ const scaleX = state.scale_x === undefined ? 1 : state.scale_x;
1476
+ const scaleY = state.scale_y === undefined ? 1 : state.scale_y;
772
1477
 
773
1478
  canvas.save();
774
1479
  canvas.translate(cx, cy);
775
- canvas.rotate(state.rotation, 0, 0);
776
- canvas.scale(state.scale_x, state.scale_y);
1480
+ canvas.rotate(rotation * Math.PI / 180, 0, 0);
1481
+ canvas.scale(scaleX, scaleY);
777
1482
 
778
- const style = state.style || obj.style;
1483
+ if (this._renderCount % 60 === 1) {
1484
+ console.log(`[ExodeUIEngine] - Drawing object: ${obj.name || obj.id} (${obj.type}), pos: ${cx.toFixed(1)},${cy.toFixed(1)}`);
1485
+ }
779
1486
 
780
1487
  if (geometry.type === 'Text') {
781
- // Text rendering omitted
1488
+ this.renderText(canvas, obj, w, h);
782
1489
  } else if (geometry.type === 'Image') {
783
- // Image rendering omitted
1490
+ this.renderImage(canvas, obj, w, h);
1491
+ } else if (obj.type === 'Component' && (obj as any).variant === 'button') {
1492
+ this.renderButton(canvas, obj, w, h);
1493
+ } else if (obj.type === 'Component' && (obj as any).variant === 'toggle') {
1494
+ this.renderToggle(canvas, obj, w, h);
1495
+ } else if (obj.type === 'Component' && (obj as any).variant === 'slider') {
1496
+ this.renderSlider(canvas, obj, w, h);
1497
+ } else if (obj.type === 'Component' && (obj as any).variant === 'dropdown') {
1498
+ this.renderDropdown(canvas, obj, w, h);
1499
+ } else if (geometry.type === 'LineGraph') {
1500
+ this.renderLineGraph(canvas, geometry, w, h);
1501
+ } else if (obj.type === 'Component' && (obj as any).variant === 'listview') {
1502
+ this.renderListView(canvas, obj, w, h);
1503
+ } else if (obj.type === 'Component' && (obj as any).variant === 'inputbox') {
1504
+ this.renderInputBox(canvas, obj, w, h);
1505
+ } else if (geometry.type === 'SVG') {
1506
+ this.renderSVG(canvas, obj, w, h);
784
1507
  } else {
785
- // Shapes
786
- const path = Skia.Path.Make();
787
-
788
- if (geometry.type === 'Rectangle') {
789
- const rect = { x: -w/2, y: -h/2, width: w, height: h };
790
- if (geometry.corner_radius) {
791
- path.addRRect(Skia.RRectXY(rect, geometry.corner_radius, geometry.corner_radius));
792
- } else {
793
- path.addRect(rect);
794
- }
795
- } else if (geometry.type === 'Ellipse') {
796
- path.addOval({ x: -w/2, y: -h/2, width: w, height: h });
797
- } else if (geometry.type === 'Triangle') {
798
- path.moveTo(0, -h/2);
799
- path.lineTo(w/2, h/2);
800
- path.lineTo(-w/2, h/2);
801
- path.close();
802
- } else if (geometry.type === 'Star') {
803
- const ir = geometry.inner_radius;
804
- const or = geometry.outer_radius;
805
- const sp = geometry.points || 5;
806
- for (let i = 0; i < sp * 2; i++) {
807
- const a = (i * Math.PI / sp) - (Math.PI / 2);
808
- const rad = i % 2 === 0 ? or : ir;
809
- const px = rad * Math.cos(a);
810
- const py = rad * Math.sin(a);
811
- if (i === 0) path.moveTo(px, py);
812
- else path.lineTo(px, py);
813
- }
814
- path.close();
815
- }
1508
+ // Default to shape rendering
1509
+ this.renderShape(canvas, obj, w, h);
1510
+ }
1511
+
1512
+ // Always check for text property on non-component shapes
1513
+ if (obj.text && (obj.type !== 'Component' || geometry.type === 'Text')) {
1514
+ this.renderText(canvas, obj, w, h);
1515
+ }
1516
+
1517
+ if (obj.children && obj.children.length > 0) {
1518
+ obj.children.forEach(childId => {
1519
+ const childObj = this.artboard?.objects.find(o => o.id === childId);
1520
+ if (childObj) this.renderObject(canvas, childObj);
1521
+ });
1522
+ }
1523
+
1524
+ canvas.restore();
1525
+ }
1526
+
1527
+ private getFont(size: number, family: string = 'System'): SkFont {
1528
+ if (size <= 0) size = 14;
1529
+
1530
+ if (this.fonts.has(size)) return this.fonts.get(size)!;
1531
+
1532
+ try {
1533
+ const fontMgr = Skia.FontMgr.System();
1534
+ let typeface = fontMgr.matchFamilyStyle(family, { weight: 400 });
1535
+
1536
+ if (!typeface) {
1537
+ typeface = fontMgr.matchFamilyStyle("System", { weight: 400 });
1538
+ }
1539
+ if (!typeface) {
1540
+ typeface = fontMgr.matchFamilyStyle("sans-serif", { weight: 400 });
1541
+ }
1542
+ if (!typeface) {
1543
+ typeface = fontMgr.matchFamilyStyle("Arial", { weight: 400 });
1544
+ }
1545
+
1546
+ if (typeface) {
1547
+ const font = Skia.Font(typeface, size);
1548
+ this.fonts.set(size, font);
1549
+ return font;
1550
+ }
1551
+
1552
+ // Fallback to matchFont utility with generic sizing
1553
+ const font = matchFont({ fontSize: size, fontFamily: 'System' });
1554
+ if (font) {
1555
+ this.fonts.set(size, font);
1556
+ return font;
1557
+ }
1558
+
1559
+ // Absolute last resort
1560
+ const defaultFont = Skia.Font(null as any, size);
1561
+ this.fonts.set(size, defaultFont);
1562
+ return defaultFont;
1563
+ } catch (e) {
1564
+ console.error(`[ExodeUIEngine] getFont error: ${e}. Attempting last resort.`);
1565
+ try {
1566
+ const font = matchFont({ fontSize: size });
1567
+ this.fonts.set(size, font);
1568
+ return font;
1569
+ } catch (e2) {
1570
+ console.error(`[ExodeUIEngine] Total font failure: ${e2}`);
1571
+ return Skia.Font(null as any, size);
1572
+ }
1573
+ }
1574
+ }
816
1575
 
817
- if (style.fill) {
818
- const paint = Skia.Paint();
819
- paint.setColor(Skia.Color(style.fill.color));
820
- paint.setAlphaf((state.opacity ?? 1) * (style.fill.opacity ?? 1));
821
- paint.setStyle(PaintStyle.Fill);
1576
+ private renderText(canvas: any, obj: any, w: number, h: number) {
1577
+ const state = this.objectStates.get(obj.id);
1578
+ const geom = state?.geometry || obj.geometry;
1579
+ const text = obj.text || geom.text || '';
1580
+ if (!text) return;
1581
+
1582
+ const paint = Skia.Paint();
1583
+ const colorValue = obj.style?.fill?.color || '#ffffff';
1584
+ try {
1585
+ const skColor = Skia.Color(colorValue);
1586
+ paint.setColor(skColor);
1587
+ } catch (e) {
1588
+ paint.setColor(Skia.Color('#ffffff'));
1589
+ }
1590
+
1591
+ const opacity1 = state?.opacity !== undefined ? state.opacity : 1;
1592
+ const opacity2 = obj.style?.fill?.opacity !== undefined ? obj.style.fill.opacity : 1;
1593
+ paint.setAlphaf(Math.max(0, Math.min(1, opacity1 * opacity2)));
822
1594
 
823
- if (style.shadow && style.shadow.opacity > 0) {
824
- const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
825
- const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
826
- paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
827
- style.shadow.offsetX, style.shadow.offsetY,
828
- style.shadow.blur, style.shadow.blur,
829
- Skia.Color(colorWithAlpha)
830
- ));
1595
+ const fontSize = geom.fontSize || 14;
1596
+ const font = this.getFont(fontSize, geom.fontFamily || obj.fontFamily || 'System');
1597
+
1598
+ const align = geom.textAlign || obj.textAlign || 'center';
1599
+ let x = -w/2;
1600
+
1601
+ // Calculate text width for alignment
1602
+ const textWidth = font.getTextWidth(text);
1603
+ if (align === 'center') {
1604
+ x = -textWidth / 2;
1605
+ } else if (align === 'right') {
1606
+ x = w/2 - textWidth;
1607
+ }
1608
+
1609
+ if (this._renderCount % 60 === 1) {
1610
+ console.log(`[ExodeUIEngine] Drawing text: "${text}" align=${align} width=${textWidth} x=${x} color=${colorValue} alpha=${paint.getAlphaf()} font_size=${font.getSize()}`);
1611
+ }
1612
+
1613
+ // Check baseline (web typically aligns center of text roughly to center if no baseline is provided, but standard is y=fontSize/2 or similar)
1614
+ const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize / 3 : fontSize / 2;
1615
+
1616
+ canvas.drawText(text, x, y, paint, font);
1617
+ }
1618
+
1619
+ private renderImage(canvas: any, obj: any, w: number, h: number) {
1620
+ // Image support requires Skia.Image.MakeFromExternalSource or similar
1621
+ // Placeholder for now
1622
+ const paint = Skia.Paint();
1623
+ paint.setColor(Skia.Color('#374151'));
1624
+ canvas.drawRect({ x: -w/2, y: -h/2, width: w, height: h }, paint);
1625
+ }
1626
+
1627
+ private renderButton(canvas: any, obj: any, w: number, h: number) {
1628
+ const state = this.objectStates.get(obj.id);
1629
+ const opts = state?.options || obj.options || {};
1630
+ const paint = Skia.Paint();
1631
+
1632
+ const isPressed = this.draggingSliderId === obj.id; // Reuse logic for now
1633
+ paint.setColor(Skia.Color(isPressed ? (opts.activeBackgroundColor || '#2563eb') : (opts.backgroundColor || '#3b82f6')));
1634
+
1635
+ const r = opts.cornerRadius ?? 8;
1636
+ canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1637
+
1638
+ const label = opts.label || { text: opts.text || 'Button', fontSize: 14, color: '#ffffff' };
1639
+ if (label && label.text) {
1640
+ const textPaint = Skia.Paint();
1641
+ textPaint.setColor(Skia.Color(label.color || '#ffffff'));
1642
+ const fontSize = label.fontSize || 14;
1643
+ const font = this.getFont(fontSize);
1644
+
1645
+ // DEBUG: Cyan marker for button text
1646
+ const linePaint = Skia.Paint();
1647
+ linePaint.setColor(Skia.Color('cyan'));
1648
+ linePaint.setStrokeWidth(1);
1649
+ canvas.drawLine(-w/4, fontSize/2, w/4, fontSize/2, linePaint);
1650
+
1651
+ if (this._renderCount % 60 === 1) {
1652
+ console.log(`[ExodeUIEngine] Button text: "${label.text}" at ${-w/4},${fontSize/2} font_size=${font.getSize()}`);
831
1653
  }
832
- if (style.blur && style.blur.amount > 0) {
833
- paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
1654
+
1655
+ canvas.drawText(label.text, -w/4, fontSize / 2, textPaint, font);
1656
+ }
1657
+ }
1658
+
1659
+ private renderToggle(canvas: any, obj: any, w: number, h: number) {
1660
+ const state = this.objectStates.get(obj.id);
1661
+ const opts = state?.options || obj.options || {};
1662
+ const checked = opts.checked || false;
1663
+
1664
+ const paint = Skia.Paint();
1665
+ paint.setColor(Skia.Color(checked ? (opts.activeColor || '#3b82f6') : (opts.inactiveColor || '#374151')));
1666
+
1667
+ const r = h / 2;
1668
+ canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1669
+
1670
+ const thumbPaint = Skia.Paint();
1671
+ thumbPaint.setColor(Skia.Color('#ffffff'));
1672
+ const thumbRadius = (h - 8) / 2;
1673
+ const thumbX = checked ? (w / 2 - thumbRadius - 4) : (-w / 2 + thumbRadius + 4);
1674
+ canvas.drawCircle(thumbX, 0, thumbRadius, thumbPaint);
1675
+ }
1676
+
1677
+ private renderSlider(canvas: any, obj: any, w: number, h: number) {
1678
+ const state = this.objectStates.get(obj.id);
1679
+ const opts = state?.options || obj.options || {};
1680
+ const value = opts.value ?? 50;
1681
+ const min = opts.min ?? 0;
1682
+ const max = opts.max ?? 100;
1683
+ const percentage = (value - min) / (max - min);
1684
+
1685
+ const trackPaint = Skia.Paint();
1686
+ trackPaint.setColor(Skia.Color(opts.inactiveColor || '#374151'));
1687
+ const trackHeight = opts.trackHeight || 4;
1688
+ canvas.drawRect({ x: -w/2, y: -trackHeight/2, width: w, height: trackHeight }, trackPaint);
1689
+
1690
+ const activePaint = Skia.Paint();
1691
+ activePaint.setColor(Skia.Color(opts.activeColor || '#3b82f6'));
1692
+ const thumbWidth = opts.thumbWidth ?? 16;
1693
+ const travelW = w - thumbWidth;
1694
+ const thumbX = -w / 2 + (thumbWidth / 2) + (percentage * travelW);
1695
+ canvas.drawRect({ x: -w/2, y: -trackHeight/2, width: thumbX + w/2, height: trackHeight }, activePaint);
1696
+
1697
+ const thumbPaint = Skia.Paint();
1698
+ thumbPaint.setColor(Skia.Color(opts.thumbColor || '#ffffff'));
1699
+ canvas.drawCircle(thumbX, 0, thumbWidth / 2, thumbPaint);
1700
+ }
1701
+
1702
+ private renderDropdown(canvas: any, obj: any, w: number, h: number) {
1703
+ const state = this.objectStates.get(obj.id);
1704
+ const opts = state?.options || obj.options || {};
1705
+ const isOpen = opts.isOpen ?? false;
1706
+
1707
+ const paint = Skia.Paint();
1708
+ paint.setColor(Skia.Color(opts.backgroundColor || '#ffffff'));
1709
+ const r = opts.cornerRadius ?? 6;
1710
+ canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1711
+
1712
+ const displayText = opts.selectedValue || opts.placeholder || 'Select...';
1713
+ const textPaint = Skia.Paint();
1714
+ textPaint.setColor(Skia.Color(opts.color || '#333333'));
1715
+ const fontSize = opts.fontSize || 14;
1716
+ const font = this.getFont(fontSize, 'Helvetica Neue');
1717
+
1718
+ canvas.save();
1719
+ canvas.translate(-w/2 + 10, fontSize / 2);
1720
+ canvas.drawText(String(displayText), 0, 0, textPaint, font);
1721
+ canvas.restore();
1722
+
1723
+ // Chevron
1724
+ const chevronPaint = Skia.Paint();
1725
+ chevronPaint.setColor(Skia.Color(opts.chevronColor || '#9ca3af'));
1726
+ chevronPaint.setStyle(PaintStyle.Stroke);
1727
+ chevronPaint.setStrokeWidth(2);
1728
+ const cx = w/2 - 15;
1729
+ const cy = isOpen ? 2 : 0;
1730
+ if (isOpen) {
1731
+ canvas.drawLine(cx - 4, cy + 2, cx, cy - 2, chevronPaint);
1732
+ canvas.drawLine(cx, cy - 2, cx + 4, cy + 2, chevronPaint);
1733
+ } else {
1734
+ canvas.drawLine(cx - 4, cy - 2, cx, cy + 2, chevronPaint);
1735
+ canvas.drawLine(cx, cy + 2, cx + 4, cy - 2, chevronPaint);
1736
+ }
1737
+
1738
+ if (isOpen && opts.optionsList && opts.optionsList.length > 0) {
1739
+ const dropList = opts.optionsList;
1740
+ const dropdownItemH = opts.itemHeight || 36;
1741
+ const dropdownY = h/2;
1742
+ const dropX = -w/2;
1743
+ const totalH = dropList.length * dropdownItemH;
1744
+
1745
+ const dropBgPaint = Skia.Paint();
1746
+ dropBgPaint.setColor(Skia.Color(opts.listBackgroundColor || '#ffffff'));
1747
+ canvas.drawRRect(Skia.RRectXY({ x: dropX, y: dropdownY, width: w, height: totalH }, 6, 6), dropBgPaint);
1748
+
1749
+ const borderP = Skia.Paint();
1750
+ borderP.setColor(Skia.Color(opts.listBorderColor || '#e5e7eb'));
1751
+ borderP.setStyle(PaintStyle.Stroke);
1752
+ borderP.setStrokeWidth(1);
1753
+ canvas.drawRRect(Skia.RRectXY({ x: dropX, y: dropdownY, width: w, height: totalH }, 6, 6), borderP);
1754
+
1755
+ dropList.forEach((item: any, i: number) => {
1756
+ const itemY = dropdownY + i * dropdownItemH;
1757
+ const isActive = opts.activeItemIndex === i;
1758
+ if (isActive) {
1759
+ const activePaint = Skia.Paint();
1760
+ activePaint.setColor(Skia.Color(opts.listHoverColor || '#eff6ff'));
1761
+ canvas.drawRect({ x: dropX, y: itemY, width: w, height: dropdownItemH }, activePaint);
1762
+ }
1763
+ const itemText = typeof item === 'string' ? item : (item?.label || `Option ${i + 1}`);
1764
+ const itemTextP = Skia.Paint();
1765
+ itemTextP.setColor(Skia.Color(opts.listTextColor || (isActive ? '#2563eb' : '#374151')));
1766
+ canvas.save();
1767
+ canvas.translate(dropX + 12, itemY + dropdownItemH / 2);
1768
+ canvas.drawText(String(itemText), 0, fontSize / 3, itemTextP, font);
1769
+ canvas.restore();
1770
+ });
1771
+ }
1772
+ }
1773
+
1774
+ private renderListView(canvas: any, obj: any, w: number, h: number) {
1775
+ const state = this.objectStates.get(obj.id);
1776
+ const opts = state?.options || obj.options || {};
1777
+ const items = opts.items || [];
1778
+ const scrollOffset = opts.scrollOffset || 0;
1779
+
1780
+ const glassEffect = opts.glassEffect !== false;
1781
+ const cornerR = opts.cornerRadius ?? 12;
1782
+ let listBgColor = opts.listBackgroundColor || opts.backgroundColor || (glassEffect ? 'rgba(31,41,55,0.4)' : '#1f2937');
1783
+ if (listBgColor === 'transparent') listBgColor = 'rgba(0,0,0,0)';
1784
+
1785
+ const getSafeColor = (col: any, fallback: string = '#000000') => {
1786
+ if (!col) return Skia.Color('rgba(0,0,0,0)');
1787
+ if (col === 'transparent') return Skia.Color('rgba(0,0,0,0)');
1788
+
1789
+ let cleanStr = col;
1790
+ if (typeof col === 'object') {
1791
+ cleanStr = col?.color || col?.stops?.[0]?.color || fallback;
834
1792
  }
1793
+ if (typeof cleanStr !== 'string') return Skia.Color(fallback);
835
1794
 
836
- canvas.drawPath(path, paint);
837
- }
838
- if (style.stroke) {
839
- const paint = Skia.Paint();
840
- paint.setColor(Skia.Color(style.stroke.color));
841
- paint.setStrokeWidth(style.stroke.width);
842
- paint.setAlphaf((state.opacity ?? 1) * (style.stroke.opacity ?? 1));
843
- paint.setStyle(PaintStyle.Stroke);
844
-
845
- if (style.shadow && style.shadow.opacity > 0) {
846
- const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
847
- const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
848
- paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
849
- style.shadow.offsetX, style.shadow.offsetY,
850
- style.shadow.blur, style.shadow.blur,
851
- Skia.Color(colorWithAlpha)
852
- ));
853
- }
854
- if (style.blur && style.blur.amount > 0) {
855
- paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
1795
+ const clean = cleanStr.replace(/\s+/g, '');
1796
+ try {
1797
+ return Skia.Color(clean) || Skia.Color(fallback);
1798
+ } catch {
1799
+ return Skia.Color(fallback);
1800
+ }
1801
+ };
1802
+
1803
+ const bgPaint = Skia.Paint();
1804
+ bgPaint.setColor(getSafeColor(listBgColor, '#1f2937'));
1805
+ bgPaint.setStyle(PaintStyle.Fill);
1806
+
1807
+ const rRect = Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, cornerR, cornerR);
1808
+ canvas.drawRRect(rRect, bgPaint);
1809
+
1810
+ if (glassEffect) {
1811
+ const borderPaint = Skia.Paint();
1812
+ borderPaint.setColor(getSafeColor('rgba(255,255,255,0.25)'));
1813
+ borderPaint.setStyle(PaintStyle.Stroke);
1814
+ borderPaint.setStrokeWidth(1);
1815
+ canvas.drawRRect(rRect, borderPaint);
1816
+ }
1817
+
1818
+ canvas.save();
1819
+ // const clipOp = ClipOp ? ClipOp.Intersect : 1;
1820
+ // canvas.clipRRect(rRect, clipOp, true);
1821
+
1822
+ const itemHeight = opts.itemHeight || 50;
1823
+ const itemGap = opts.itemGap ?? 10;
1824
+ const padding = opts.padding ?? 0;
1825
+ const pad = typeof padding === 'number' ? padding : (padding as any)[0] || 0;
1826
+
1827
+ let currentPos = -h/2 + scrollOffset + pad;
1828
+
1829
+ const font = this.getFont(14, 'Helvetica Neue');
1830
+ const accentColor = opts.accentColor || '#3b82f6';
1831
+ const itemTextColor = opts.itemTextColor || '#333333';
1832
+ const itemBgColor = opts.itemBackgroundColor || 'transparent';
1833
+
1834
+ if (this._renderCount <= 2) {
1835
+ // debug logging disabled
1836
+ }
1837
+
1838
+ items.forEach((item: any, i: number) => {
1839
+ const itemY = currentPos;
1840
+ const itemH = itemHeight;
1841
+ const itemW = w;
1842
+
1843
+ if (itemY + itemH > -h/2 && itemY < h/2) {
1844
+ const isHovered = opts.hoveredIndex === i;
1845
+ const isActive = opts.activeItemIndex === i;
1846
+
1847
+ if (isActive) {
1848
+ const activePaint = Skia.Paint();
1849
+ activePaint.setColor(getSafeColor(accentColor, '#3b82f6'));
1850
+ activePaint.setAlphaf(0.8);
1851
+ canvas.drawRect({ x: -w/2, y: itemY, width: itemW, height: itemH }, activePaint);
1852
+ } else if (isHovered) {
1853
+ const hoverPaint = Skia.Paint();
1854
+ hoverPaint.setColor(getSafeColor(glassEffect ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'));
1855
+ canvas.drawRect({ x: -w/2, y: itemY, width: itemW, height: itemH }, hoverPaint);
1856
+ } else if (itemBgColor && itemBgColor !== 'transparent') {
1857
+ const itemBgPaint = Skia.Paint();
1858
+ itemBgPaint.setColor(getSafeColor(itemBgColor));
1859
+ canvas.drawRect({ x: -w/2, y: itemY, width: itemW, height: itemH }, itemBgPaint);
1860
+ }
1861
+
1862
+ if (isActive || isHovered) {
1863
+ const stripePaint = Skia.Paint();
1864
+ stripePaint.setColor(getSafeColor(accentColor, '#3b82f6'));
1865
+ canvas.drawRect({ x: -w/2, y: itemY, width: 3, height: itemH }, stripePaint);
1866
+ }
1867
+
1868
+ const textPaint = Skia.Paint();
1869
+ if (isActive) {
1870
+ textPaint.setColor(getSafeColor('#ffffff'));
1871
+ } else if (glassEffect) {
1872
+ textPaint.setColor(getSafeColor('#ffffff'));
1873
+ textPaint.setAlphaf(0.9);
1874
+ } else {
1875
+ textPaint.setColor(getSafeColor(itemTextColor, '#ffffff'));
1876
+ }
1877
+
1878
+ const text = typeof item === 'string' ? item : (item?.label || item?.value || `Item ${i + 1}`);
1879
+ canvas.save();
1880
+ canvas.translate(-w/2 + 16, itemY + itemH / 2);
1881
+ canvas.drawText(String(text), 0, 14 / 3, textPaint, font);
1882
+ canvas.restore();
856
1883
  }
1884
+ currentPos += itemHeight + itemGap;
1885
+ });
1886
+
1887
+ canvas.restore();
1888
+ }
1889
+
1890
+ private renderInputBox(canvas: any, obj: any, w: number, h: number) {
1891
+ const state = this.objectStates.get(obj.id);
1892
+ const opts = state?.options || obj.options || {};
1893
+
1894
+ const paint = Skia.Paint();
1895
+ paint.setColor(Skia.Color(opts.backgroundColor || '#1f2937'));
1896
+ const r = opts.cornerRadius ?? 8;
1897
+ canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1898
+
1899
+ const textPaint = Skia.Paint();
1900
+ textPaint.setColor(Skia.Color(opts.color || '#ffffff'));
1901
+ const fontSize = opts.fontSize || 14;
1902
+ const font = this.getFont(fontSize);
1903
+ const text = opts.text || '';
1904
+ const display = text || opts.placeholder || 'Enter text...';
1905
+ if (!text) textPaint.setAlphaf(0.5);
1906
+
1907
+ canvas.drawText(display, -w/2 + 15, 5, textPaint, font);
1908
+ }
1909
+
1910
+ private renderSVG(canvas: any, obj: any, w: number, h: number) {
1911
+ const geometry = obj.geometry;
1912
+ const svgContent = geometry.svgContent;
1913
+ if (!svgContent) return;
1914
+
1915
+ // In a real implementation, we'd parse the SVG or use a pre-parsed Skia Path
1916
+ // For now, placeholder or basic path if it's a simple icon
1917
+ const paint = Skia.Paint();
1918
+ paint.setColor(Skia.Color('#ffffff'));
1919
+ paint.setAlphaf(0.8);
1920
+ canvas.drawCircle(0, 0, Math.min(w, h) / 4, paint);
1921
+ }
857
1922
 
1923
+ private renderLineGraph(canvas: any, geom: any, w: number, h: number) {
1924
+ const state = this.objectStates.get(geom.id);
1925
+ const datasets = geom.datasets || [];
1926
+ if (datasets.length === 0) return;
1927
+
1928
+ const paint = Skia.Paint();
1929
+ paint.setStyle(PaintStyle.Stroke);
1930
+ paint.setStrokeWidth(geom.lineWidth || 2);
1931
+
1932
+ datasets.forEach((ds: any) => {
1933
+ const data = ds.data || [];
1934
+ if (data.length < 2) return;
1935
+
1936
+ paint.setColor(Skia.Color(ds.lineColor || '#3b82f6'));
1937
+ const path = Skia.Path.Make();
1938
+
1939
+ const stepX = w / (data.length - 1);
1940
+ const max = Math.max(...data, 1);
1941
+
1942
+ data.forEach((val: number, i: number) => {
1943
+ const x = -w/2 + i * stepX;
1944
+ const y = h/2 - (val / max) * h;
1945
+ if (i === 0) path.moveTo(x, y);
1946
+ else path.lineTo(x, y);
1947
+ });
858
1948
  canvas.drawPath(path, paint);
1949
+ });
1950
+ }
1951
+ private renderShape(canvas: SkCanvas, obj: ShapeObject, w: number, h: number) {
1952
+ const state = this.objectStates.get(obj.id);
1953
+ const geometry = state.geometry || obj.geometry;
1954
+ const style = state.style || obj.style;
1955
+
1956
+ const path = Skia.Path.Make();
1957
+ if (geometry.type === 'Rectangle') {
1958
+ const rect = { x: -w/2, y: -h/2, width: w, height: h };
1959
+ if (state.cornerRadius || geometry.corner_radius) {
1960
+ const cr = state.cornerRadius || geometry.corner_radius;
1961
+ path.addRRect(Skia.RRectXY(rect, cr, cr));
1962
+ } else {
1963
+ path.addRect(rect);
859
1964
  }
1965
+ } else if (geometry.type === 'Ellipse') {
1966
+ path.addOval({ x: -w/2, y: -h/2, width: w, height: h });
1967
+ } else if (geometry.type === 'Triangle') {
1968
+ path.moveTo(0, -h/2);
1969
+ path.lineTo(w/2, h/2);
1970
+ path.lineTo(-w/2, h/2);
1971
+ path.close();
1972
+ } else if (geometry.type === 'Star') {
1973
+ const ir = geometry.inner_radius || 20;
1974
+ const or = geometry.outer_radius || 50;
1975
+ const sp = geometry.points || 5;
1976
+ for (let i = 0; i < sp * 2; i++) {
1977
+ const a = (i * Math.PI / sp) - (Math.PI / 2);
1978
+ const rad = i % 2 === 0 ? or : ir;
1979
+ const px = rad * Math.cos(a);
1980
+ const py = rad * Math.sin(a);
1981
+ if (i === 0) path.moveTo(px, py);
1982
+ else path.lineTo(px, py);
1983
+ }
1984
+ path.close();
1985
+ }
1986
+
1987
+ if (style.fill) {
1988
+ const paint = Skia.Paint();
1989
+ paint.setColor(Skia.Color(style.fill.color || '#000000'));
1990
+ paint.setAlphaf((state.opacity ?? 1) * (style.fill.opacity ?? 1));
1991
+ paint.setStyle(PaintStyle.Fill);
1992
+
1993
+ if (style.shadow && style.shadow.opacity > 0) {
1994
+ const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
1995
+ const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
1996
+ paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
1997
+ style.shadow.offsetX, style.shadow.offsetY,
1998
+ style.shadow.blur, style.shadow.blur,
1999
+ Skia.Color(colorWithAlpha)
2000
+ ));
2001
+ }
2002
+ if (style.blur && style.blur.amount > 0) {
2003
+ paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
2004
+ }
2005
+ canvas.drawPath(path, paint);
2006
+ }
2007
+ if (style.stroke) {
2008
+ const paint = Skia.Paint();
2009
+ paint.setColor(Skia.Color(style.stroke.color));
2010
+ paint.setStrokeWidth(style.stroke.width);
2011
+ paint.setAlphaf((state.opacity ?? 1) * (style.stroke.opacity ?? 1));
2012
+ paint.setStyle(PaintStyle.Stroke);
2013
+ canvas.drawPath(path, paint);
860
2014
  }
861
- canvas.restore();
862
2015
  }
863
2016
  }