File size: 45,407 Bytes
b6a38d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
DefineClass.XControl = {
	__parents = { "XWindow", "FXObject" },

	properties = {
		{ category = "Interaction", id = "Enabled", editor = "bool", default = true, },
		{ category = "Interaction", id = "Target", editor = "text", default = "", },
		{ category = "FX", id = "FXMouseIn", editor = "text", default = "", },
		{ category = "FX", id = "FXPress", editor = "text", default = "", },
		{ category = "FX", id = "FXPressDisabled", editor = "text", default = "", },
		{ category = "Visual", id = "FocusedBorderColor", name = "Focused border color", editor = "color", default = RGB(0, 0, 0), },
		{ category = "Visual", id = "FocusedBackground", name = "Focused background", editor = "color", default = RGBA(0, 0, 0, 0), },
		{ category = "Visual", id = "DisabledBorderColor", name = "Disabled border color", editor = "color", default = RGB(0, 0, 0), },
		{ category = "Visual", id = "DisabledBackground", name = "Disabled background", editor = "color", default = RGBA(0, 0, 0, 0), },
		-- read only
		{ category = "FX", id = "Particles", read_only = true, editor = "string_list", default = false, }
	},
	enabled = true,
	IdNode = true,
	HandleMouse = true,
	particles = false,
}

------ BEGIN PARTICLES CODE

DefineClass.UIParticleInstance = {
	__parents = {"PropertyObject"},
	
	id = false,
	parsys_name = false,
	foreground = true,
	lifetime = -1,
	transfer_to_parent = false,
	stop_on_transfer = true,
	offset = point(0, 0),
	owner = false,
	delete_owner = false,
	halign = "middle",
	valign = "middle",
	keep_alive = false,
	polyline = false,
	params = false,
	dynamic_params = false,
}

local function align_position(alignment, rstart, rend)
	if alignment == "begin" then
		return rstart
	elseif alignment == "end" then
		return rend
	elseif alignment == "middle" then
		return (rstart + rend) / 2
	else
		assert("Invalid alignment")
		return rstart
	end
end

local function calc_particle_origin(control, particle)
	local box = control.content_box
	local posx = align_position(particle.halign, box:minx(), box:maxx())
	local posy = align_position(particle.valign, box:miny(), box:maxy())
	return posx, posy
end

function UIParticleInstance:ApplyDynamicParams()
	local proto = self.parsys_name
	local dynamic_params = ParGetDynamicParams(proto)
	if not next(dynamic_params) then
		self.dynamic_params = nil
		return
	end
	self.dynamic_params = dynamic_params
	local set_value = self.SetParamDef
	for k, v in pairs(dynamic_params) do
		set_value(self, v, v.default_value)
	end
end 

local UIParticleSetDynamicDataString = UIL.UIParticleSetDynamicDataString
function UIParticleInstance:SetPointsAsPolyline(pts)
	self.polyline = pstr("")
	for _, pt in ipairs(pts or empty_table) do
		self.polyline:AppendVertex(pt)
	end
	UIParticleSetDynamicDataString(self.id, 0, self.polyline)
end


function UIParticleInstance:SetParam(param, value)
	local dynamic_params = self.dynamic_params
	local def = dynamic_params and rawget(dynamic_params, param)
	if def then
		self:SetParamDef(def, value)
	end
end

function UIParticleInstance:SetParamDef(def, value)
	local ptype = def.type
	if ptype == "number" then
		UIParticleSetDynamicDataString(self.id, def.index, value)
	elseif ptype == "color" then
		UIParticleSetDynamicDataString(self.id, def.index, value)
	elseif ptype == "point" then
		local x, y, z = value:xyz()
		local idx = def.index
		UIParticleSetDynamicDataString(self.id, idx, x)
		UIParticleSetDynamicDataString(self.id, idx + 1, y)
		UIParticleSetDynamicDataString(self.id, idx + 2, z or 0)
	elseif ptype == "bool" then
		UIParticleSetDynamicDataString(self.id, def.index, value and 1 or 0)
	end
end

function UIParticleInstance:UpdateBordersPolyline()
	local bbox = self.owner.box
	local pts = {
		point(bbox:minx(), bbox:miny()),
		point(bbox:maxx(), bbox:miny()),
		point(bbox:maxx(), bbox:maxy()),
		point(bbox:minx(), bbox:maxy()),
	}

	local x, y = calc_particle_origin(self.owner, self)
	local origin = point(x, y)
	for idx, _ in ipairs(pts) do
		local diff = (pts[idx] - origin)
		pts[idx] = point(diff:x() * guim, diff:y() * -guim)
	end

	self:SetPointsAsPolyline(pts)

	self:SetParam("width", bbox:sizex() * 1000)
	self:SetParam("height", bbox:sizey() * 1000)	
end

local HasUIParticles = UIL.HasUIParticles
local StopUIParticlesEmitter = UIL.StopUIParticlesEmitter
local function ParticleLifetimeFunc(particle, lifetime)
	if lifetime >= 0 then
		Sleep(lifetime)
		StopUIParticlesEmitter(particle.id)
	end

	local last_tick_had_particles = true
	Sleep(1000)
	while true do
		local has_particles = HasUIParticles(particle.id) or particle.keep_alive
		if not has_particles and not last_tick_had_particles then
			break
		end
		last_tick_had_particles = has_particles
		Sleep(1000)
	end

	assert(particle.owner)
	particle.owner:KillParSystem(particle.id, "leave_lifetimethread")
end

function XControl:OnBoxChanged()
	for _, particle in ipairs(self.particles) do
		particle:UpdateBordersPolyline()
	end
end

function XControl:AddParSystem(id, name, instance)
	self.particles = self.particles or {}
	instance = instance or UIParticleInstance:new({})
	
	assert(name)
	if not id then
		id = UIL.PlaceUIParticles(name)
	end
	assert(id)
	assert(instance.owner == false)
	instance.id = id
	instance.parsys_name = name
	instance.owner = self
	instance:ApplyDynamicParams()

	instance.lifetime_thread = CreateRealTimeThread(ParticleLifetimeFunc, instance, instance.lifetime)
	table.insert(self.particles, instance)

	self:Invalidate()
	instance:UpdateBordersPolyline()
	return id
end

function XControl:StopParticle(particle, force)
	if type(particle) ~= "table" then
		particle = table.find_value(self.particles, "id", particle)
		if not particle then return end
	end
	particle.keep_alive = false
	if force then
		self:KillParSystem(particle.id)
	else
		DeleteThread(particle.lifetime_thread)
		particle.lifetime_thread = CreateRealTimeThread(ParticleLifetimeFunc, particle, 0)
	end
end

function XControl:KillParticlesWithName(name)
	if not self.particles then return end
	for _, particle in ipairs(self.particles) do
		if particle.parsys_name == name then
			self:KillParSystem(particle.id)
		end
	end
end

function XControl:GetParticleName(id)
	if not self.particles then return end
	local particle = table.find_value(self.particles, "id", id)
	if not particle then return end
	return particle.parsys_name
end

function XControl:TransferParticleUp(particle)
	assert(table.find(self.particles, particle))
	local parent = self.parent
	local top_level_end_of_life_window = self
	while parent and (parent.window_state ~= "open" or IsKindOf(parent, "XContentTemplate")) do
		top_level_end_of_life_window = parent
		parent = parent.parent
	end

	if not parent then return end
	-- TODO: Insert the child at the right position in the parent and figure out why it results in asserts on main menu

	--print("Transferting to ", parent.Id, parent.class, top_level_end_of_life_window.Id, top_level_end_of_life_window.class)
	--local child_index = table.find(parent, top_level_end_of_life_window)
	--assert(child_index)

	local particle_holder = XControl:new({}, parent)
	--table.remove_value(parent, particle_holder)
	--table.insert(parent, child_index, particle_holder)

	particle_holder.particles = {}
	table.insert(particle_holder.particles, particle)
	particle.offset = particle.owner.content_box:min() - point(calc_particle_origin(particle_holder, particle)) + particle.offset
	particle.owner = particle_holder
	particle.delete_owner = true
	particle.foreground = true
	table.remove_value(self.particles, particle)
	if #self.particles == 0 then
		self.particles = false
	end
end

function XControl:KillParSystem(id, leave_lifetimethread)
	if not self.particles then return end

	local idx = table.find(self.particles, "id", id)
	assert(idx)
	local particle = self.particles[idx]
	assert(particle.owner == self)
	if not leave_lifetimethread then
		DeleteThread(particle.lifetime_thread)
	end
	UIL.DeleteUIParticles(particle.id)
	table.remove(self.particles, idx)
	if #self.particles == 0 then
		self.particles = false
	end
	if particle.delete_owner then
		self:delete()
	end
	particle.keep_alive = false
	self:Invalidate()
end
 
function XControl:HasParticle(id)
	if not self.particles then return false end
	if not table.find(self.particles, "id", id) then return false end
	return true
end

if Platform.developer then
	function XControl:DbgPlayFX(...)
		local index = self.particles and #(self.particles) or 1
		self:PlayFX(...)
		if not self.particles then
			return
		end
		for i = index, #self.particles do
			local particle = self.particles[i]
			if particle.lifetime == -1 then
				particle.keep_alive = true
			end
		end
	end
end

function XControl:ParticlesOnDone()
	local particles = self.particles
	if particles then
		for i = #particles, 1, -1 do
			local particle = particles[i]
			if particle.transfer_to_parent and UIL.ShouldWaitForHasUIParticles(particle.id) then
				if particle.stop_on_transfer then
					self:StopParticle(particle)
				end
				self:TransferParticleUp(particle)
			else
				self:KillParSystem(particle.id)
			end
		end
	end
end

function XControl:Done()
	self:ParticlesOnDone()
end

function GetUIParticleAlignmentItems(horizontal)
	return {
		{ value = "begin", text = horizontal and "left" or "top" },
		{ value = "middle", text = "center" },
		{ value = "end", text = horizontal and "right" or "bottom" },
	}
end

function XControl:DrawParticles(foreground)
	for key, particle in ipairs(self.particles) do
		if particle.foreground == foreground then
			local scale = self.scale:x()
			UIL.DrawParticles(particle.id, point(calc_particle_origin(self, particle)) + particle.offset, scale, scale, 0)
		end
	end
end

function XControl:DrawBackground()
	XWindow.DrawBackground(self)
	self:DrawParticles(false)
end

function XControl:DrawChildren(clip_box)
	XWindow.DrawChildren(self, clip_box)
	self:DrawParticles(true)
end

function XControl:GetParticles()
	return self.particles and table.map(self.particles, "parsys_name")
end

------ END OF PARTICLES CODE

function XControl:SetEnabled(enabled, force)
	local old = self.enabled
	self.enabled = enabled and true or false
	if self.enabled == old and not force then return end
	for _, win in ipairs(self) do
		if win:IsKindOf("XControl") then
			win:SetEnabled(enabled)
		end
	end
	self:Invalidate()
end

function XControl:GetEnabled()
	return self.enabled
end

function XControl:PlayFX(fx, moment, pos)
	if fx and fx ~= "" then
		PlayFX(fx, moment or "start", self, self.Id, pos)	
	end
end

function XControl:OnSetFocus(focus)
	self:Invalidate()
	XWindow.OnSetFocus(self, focus)
end

function XControl:OnKillFocus()
	self:Invalidate()
	XWindow.OnKillFocus(self)
end

function XControl:CalcBackground()
	if not self.enabled then return self.DisabledBackground end
	local FocusedBackground, Background = self.FocusedBackground, self.Background
	if FocusedBackground == Background then return Background end
	return self:IsFocused() and FocusedBackground or Background
end

function XControl:CalcBorderColor()
	if not self.enabled then return self.DisabledBorderColor end
	local FocusedBorderColor, BorderColor = self.FocusedBorderColor, self.BorderColor
	if FocusedBorderColor == BorderColor then return BorderColor end
	return self:IsFocused() and FocusedBorderColor or BorderColor
end

function XControl:OnSetRollover(rollover)
	XWindow.OnSetRollover(self, rollover)
	self:PlayHoverFX(rollover)
end

if FirstLoad then
	LastUIFXPos = false
end

function XControl:TryMarkUIFX(event)
	-- mark LastUIFXPos only if there is an actual event
	if event and event ~= "" then
		local pt = terminal.GetMousePos()
		if self:MouseInWindow(pt) and pt == LastUIFXPos then
			return
		end
		LastUIFXPos = pt
	end
	return true
end

function XControl:PlayActionFX(forced)
	local event = (self.enabled or forced) and self.FXPress or self.FXPressDisabled
	self:TryMarkUIFX(event)
	self:PlayFX(event)
	return true
end

function XControl:PlayHoverFX(rollover)
	if not self.enabled or rollover and not self:TryMarkUIFX(self.FXMouseIn) then
		return false -- avoid playing hover FX right after other FX
	end
	self:PlayFX(self.FXMouseIn, rollover and "start" or "end")
	return true
end

function XControl:OnMouseButtonDown(pos, button)
	if button == "L" then
		self:PlayActionFX()
	end
end


----- XContextControl

DefineClass.XContextControl = {
	__parents = { "XContextWindow", "XControl", },
	ContextUpdateOnOpen = true,
}


----- XFontControl

DefineClass.XFontControl = {
	__parents = { "XControl" },
	
	properties = {
		category = "Visual",
		{ id = "TextStyle", editor = "preset_id", default = "GedDefault", invalidate = "measure", preset_class = "TextStyle", editor_preview = true, },
		{ id = "TextFont", editor = "text", default = "", invalidate = "measure", no_edit = true, },
		{ id = "TextColor", editor = "color", default = RGB(32, 32, 32), invalidate = "measure", no_edit = true, },
		{ id = "RolloverTextColor", editor = "color", default = RGB(0, 0, 0), invalidate = "measure", no_edit = true, },
		{ id = "DisabledTextColor", editor = "color", default = RGBA(32, 32, 32, 128), invalidate = "measure", no_edit = true, },
		{ id = "DisabledRolloverTextColor", editor = "color", default = RGBA(40, 40, 40, 128), invalidate = "measure", no_edit = true, },
		{ id = "ShadowType", editor = "choice", default = "shadow", items = {"shadow", "extrude", "outline"}, invalidate = "measure", no_edit = true, },
		{ id = "ShadowSize", editor = "number", default = 0, invalidate = "measure", no_edit = true, },
		{ id = "ShadowColor", editor = "color", default = RGBA(0, 0, 0, 48), invalidate = "measure", no_edit = true, },
		{ id = "ShadowDir", editor = "point", default = point(1,1), invalidate = "measure", no_edit = true, },
		
		{ id = "DisabledShadowColor", editor = "color", default = RGBA(0, 0, 0, 48), invalidate = "measure", no_edit = true, },
	},

	font_id = false,
	font_height = 10,
	font_linespace = 0,
	font_baseline = 8,
}

function XFontControl:Init()
	self:SetTextStyle(self.TextStyle)
end

function XFontControl:SetTextStyle(style, force)
	self.TextStyle = style ~= "" and style or nil
	local text_style = TextStyles[style]
	if style == "" or not text_style then return end
	self:SetTextFont(style, force)
	self:SetTextColor(text_style.TextColor)
	self:SetRolloverTextColor(text_style.RolloverTextColor)
	self:SetDisabledTextColor(text_style.DisabledTextColor)
	self:SetShadowType(text_style.ShadowType)
	self:SetShadowSize(text_style.ShadowSize)
	self:SetShadowColor(text_style.ShadowColor)
	self:SetShadowDir(text_style.ShadowDir)
	self:SetDisabledShadowColor(text_style.DisabledShadowColor)
	self:SetDisabledRolloverTextColor(text_style.DisabledRolloverTextColor)
end

function XFontControl:SetTextFont(font, force)
	if self.TextFont == font and not force then return end
	self.TextFont = font
	self.font_id = false
	self:InvalidateMeasure()
	self:Invalidate()
end

function XFontControl:OnScaleChanged(scale)
	self.font_id = false
end

function XFontControl:CalcTextColor()
	return self.enabled and 
		(self.rollover and self.RolloverTextColor or self.TextColor) or
		(self.rollover and self.DisabledRolloverTextColor or self.DisabledTextColor)
end

function XFontControl:OnSetRollover(rollover)
	local invalidate
	if self.enabled then
		invalidate = self.RolloverTextColor ~= self.TextColor
	else
		invalidate = self.DisabledRolloverTextColor ~= self.DisabledTextColor
	end
	if invalidate then
		self:Invalidate()
	end
	
	XControl.OnSetRollover(self, rollover)
end

function XFontControl:GetFontId()
	local font_id = self.font_id
	if not font_id then
		local text_style = TextStyles[self:GetTextStyle()]
		if not text_style then
			assert(false, string.format("Invalid text style '%s'", self:GetTextStyle()))
			return
		end
		font_id, self.font_height, self.font_baseline = text_style:GetFontIdHeightBaseline(self.scale:y())
		self.font_id = font_id
	end
	return font_id
end

function XFontControl:GetFontHeight()
	self:GetFontId()
	return self.font_height
end

function XFontControl:SetFontProps(font_control)
	local style = font_control:GetTextStyle()
	if style ~= "" and TextStyles[style] then
		self:SetTextStyle(style)
		return
	end
	self:SetTextFont(font_control:GetTextFont())
	self:SetTextColor(font_control:GetTextColor())
	self:SetRolloverTextColor(font_control:GetRolloverTextColor())
	self:SetDisabledTextColor(font_control:GetDisabledTextColor())
	self:SetShadowType(font_control:GetShadowType())
	self:SetShadowSize(font_control:GetShadowSize())
	self:SetShadowColor(font_control:GetShadowColor())
	self:SetShadowDir(font_control:GetShadowDir())
	self:SetDisabledShadowColor(font_control:GetDisabledShadowColor())
	self:SetDisabledRolloverTextColor(font_control:GetDisabledRolloverTextColor())
end


----- XTranslateText

DefineClass.XTranslateText = {
	__parents = { "XFontControl", "XContextControl" },
	
	properties = {
		{ category = "General", id = "Translate", editor = "bool", default = false, },
		{ category = "General", id = "Text", editor = "text", default = "", translate = function (obj) return obj:GetProperty("Translate") end, },
		{ category = "General", id = "UpdateTimeLimit", name = "Update limit", editor = "number", default = 0, },
	},
	ContextUpdateOnOpen = false,
	text = "",
	last_update_time = 0,
}

function XTranslateText:OnTextChanged(text)
end

function XTranslateText:SetText(text)
	if type(text) == "number" then text = tostring(text) end
	self.Text = text or nil
	text = text or ""
	assert(self.Translate or type(text) == "string") -- passing a T value with Translate == false?
	assert(not self.Translate or IsT(text)) -- passing a text value with Translate == true?
	if text ~= "" and (self.Translate or IsT(text)) then
		text = _InternalTranslate(text, self.context)
	end
	if self.text ~= text then
		self:OnTextChanged(text)
		self.text = text
		self.last_update_time = RealTime()
		self:InvalidateMeasure()
		self:Invalidate()
	end
end

function XTranslateText:OnContextUpdate(context)
	local limit = self.UpdateTimeLimit
	if limit == 0 or (RealTime() - self.last_update_time) >= limit then
		self:SetText(self.Text)
	elseif not self:GetThread("ContextUpdate") then
		self:CreateThread("ContextUpdate", function(self)
			Sleep(self.last_update_time + self.UpdateTimeLimit - RealTime())
			self:OnContextUpdate()
		end, self)
	end
end

function XTranslateText:OnXTemplateSetProperty(prop_id, old_value)
	-- toggle text properties between Ts and strings when Translate is edited
	if prop_id == "Translate" then
		self:UpdateLocalizedProperty("Text", self.Translate)
		ObjModified(self)
	end
end

function RecursiveUpdateTTexts(root)
	if IsKindOf(root, "XTranslateText") and root.Translate and IsT(root:GetText()) then
		root:SetText(root:GetText())
		root:SetTextStyle(root:GetTextStyle(), "force")
	end
	for i = 1, #root do
		RecursiveUpdateTTexts(root[i])
	end
end

function OnMsg.TranslationChanged()
	ClearTextStyleCache()
	RecursiveUpdateTTexts(terminal.desktop)
end

----- XEditableText

DefineClass.XEditableText = {
	__parents = { "XFontControl", "XContextControl" },
	
	properties = {
		{ category = "General", id = "Translate", name = "Translated text", editor = "bool", default = false,
			help = "Enabled for texts that the developers enter and that need to go into the translation tables.\n\nGetText will return a T value with a localization ID.",
		},
		{ category = "General", id = "UserText", name = "User text", editor = "bool", default = false,
			help = "Enable for user-entered texts that need to be filtered for profanity.\n\nGetText will return a special T value with extra data such as source user ID, language, etc.",
		},
		{ category = "General", id = "UserTextType", editor = "choice", default = "unknown", items = {"name", "chat", "game_content", "unknown"}, no_edit = function(obj) return not obj.UserText end,
			help = "The user text is filtered in a different way, depending on this value; supported by Steam only.",
		},
		{ category = "General", id = "Text", editor = "text", translate = function(self) return self.Translate end, default = "", },
		{ category = "General", id = "OnTextChanged", editor = "func", params = "self"},
	},
	
	text = "",
	text_translation_id = false,
}

function XEditableText:SetText(text)
	if self.Translate then
		assert(IsT(text))
		self.text_translation_id = TGetID(text) or nil
		text = type(text) == "string" and text or TDevModeGetEnglishText(text, "deep", "no_assert")
	elseif self.UserText then
		assert(IsUserText(text))
		text = type(text) == "string" and text or TDevModeGetEnglishText(text, "deep", "no_assert")
	end
	self:SetTranslatedText(text)
end

function XEditableText:SetTranslatedText(text, notify)
	if self.text ~= text then
		assert(type(text) == "string")
		self.text = IsT(text) and TDevModeGetEnglishText(text) or text
		if notify ~= false then
			self:OnTextChanged()
		end
		self:InvalidateMeasure()
		self:Invalidate()
	end
end

function XEditableText:GetText()
	local text = self.text
	if text == "" or (not self.Translate and not self.UserText) then
		return text
	elseif self.UserText then
		return CreateUserText(self.text, self.UserTextType)
	end
	local id = self.text_translation_id or RandomLocId()
	self.text_translation_id = id
	text = text:gsub("\r?\n", "\n")
	return T{id, text}
end

function XEditableText:GetTranslatedText()
	return self.text
end

function XEditableText:OnTextChanged()
end


----- XPopup

xpopup_anchor_types = {"none", "custom", "drop", "drop-right", "smart", "left", "right", "top", "bottom", "center-top", "center-bottom", "bottom-right", "bottom-left", "top-left", "top-right", "right-center", "left-center" , "mouse", "live-mouse"}
DefineClass.XPopup = {
	__parents = { "XControl" },
	properties = {
		{ category = "General", id = "Anchor", editor = "rect", default = box(0, 0, 0, 0), },
		{ category = "General", id = "AnchorType", editor = "choice", default = "none", items = xpopup_anchor_types },
	},
	LayoutMethod = "VList",
	Dock = "ignore",
	Background = RGB(240, 240, 240),
	FocusedBackground = RGB(240, 240, 240),
	BorderWidth = 1,
	BorderColor = RGB(128, 128, 128),
	FocusedBorderColor = RGB(128, 128, 128),
	
	popup_parent = false,
}

function XPopup:GetSafeAreaBox()
	return GetSafeAreaBox()
end

function XPopup:GetCustomAnchor(x, y, width, height, anchor)
	return anchor:minx(), anchor:miny(), width, height
end

function XPopup:UpdateLayout()
	local margins_x1, margins_y1, margins_x2, margins_y2 = ScaleXY(self.scale, self.Margins:xyxy())
	local anchor = self:GetAnchor()
	local safe_area_x1, safe_area_y1, safe_area_x2, safe_area_y2 = self:GetSafeAreaBox()
	local x, y = self.box:minxyz()
	local width, height = self.measure_width - margins_x1 - margins_x2, self.measure_height - margins_y1 - margins_y2
	local a_type = self.AnchorType
	if a_type == "smart" then
		local space = anchor:minx() - safe_area_x1 - width - margins_x2
		a_type = "left"
		if space < safe_area_x2 - anchor:maxx() - width - margins_x1 then
			space = safe_area_x2 - anchor:maxx() - width - margins_x1
			a_type = "right"
		end
		if space < anchor:miny() - safe_area_y1 - height - margins_y2 then
			space = anchor:miny() - safe_area_y1 - height - margins_y2
			a_type = "top"
		end
		if space < safe_area_y2 - anchor:maxy() - height - margins_y1 then
			space = safe_area_y2 - anchor:maxy() - height - margins_y1
			a_type = "bottom"
		end
	end
	if a_type == "live-mouse" then
		local pos = terminal.GetMousePos()
		anchor = sizebox(pos, UIL.MeasureImage(GetMouseCursor()))
		a_type = "bottom"
	end
	if a_type == "mouse" then
		x, y = anchor:x(), anchor:y()
	elseif a_type == "left" then
		x = anchor:minx() - width - margins_x2
		y = anchor:miny() - margins_y1
	elseif a_type == "right" then
		x = anchor:maxx() + margins_x1
		y = anchor:miny() - margins_y1
	elseif a_type == "top" then
		x = anchor:minx() - margins_x1
		y = anchor:miny() - height - margins_y2
	elseif a_type == "bottom" then
		x = anchor:minx() - margins_x1
		y = anchor:maxy() + margins_y2
	end
	if a_type == "center-top" then
		x = anchor:minx() + ((anchor:maxx() - anchor:minx())  -  width)/2
		y = anchor:miny() - height - margins_y2
	end
	if a_type == "center-bottom" then
		x = anchor:minx() + ((anchor:maxx() - anchor:minx())  -  width)/2
		y = anchor:maxy() + margins_y2
	end
	if a_type == "bottom-right" then
		x = anchor:maxx() + margins_x1
		y = anchor:maxy() - height - margins_y2
	end	
	if a_type == "bottom-left" then
		x = anchor:minx() - width - margins_x2
		y = anchor:maxy() - height - margins_y2
	end	
	if a_type == "right-center" then
		x = anchor:maxx() + margins_x1
		y = anchor:miny() + ((anchor:maxy() - anchor:miny())  -  height)/2
	end
	if a_type == "left-center" then
		x = anchor:minx() - width - margins_x2
		y = anchor:miny() + ((anchor:maxy() - anchor:miny())  -  height)/2
	end
	if a_type == "top-right" then
		x = anchor:maxx() + margins_x1
		y = anchor:miny()- margins_y1
	end
	if a_type == "top-left" then
		x = anchor:minx() - width - margins_x2
		y = anchor:miny()- margins_y1
	end
	if a_type == "drop" then
		x, y = anchor:minx(), anchor:maxy()
		width = Max(anchor:sizex(), width)
	end
	if a_type == "drop-right" then
		x, y = anchor:minx() + anchor:sizex() - width, anchor:maxy()
		width = Max(anchor:sizex(), width)
	end
	if a_type == "custom" then
		x, y, width, height = self:GetCustomAnchor(x, y, width, height, anchor)
	end
	-- fit window to safe area
	if x + width + margins_x2 > safe_area_x2 then
		x = safe_area_x2 - width - margins_x2
	elseif x < safe_area_x1 then
		x = safe_area_x1
	end
	if y + height + margins_y2 > safe_area_y2 then
		y = safe_area_y2 - height - margins_y2
	elseif y < safe_area_y1 then
		y = safe_area_y1
	end
	-- layout
	self:SetBox(x, y, width, height)
	return XControl.UpdateLayout(self)
end

function XPopup:OnKillFocus(new_focus)
	if self.window_state ~= "open" then 
		XWindow.OnKillFocus(self)
		return
	end
	-- close all popups up to the common parent in the popup chain
	local popup = self
	while IsKindOf(popup, "XPopup") and not (new_focus and popup:IsWithinPopupChain(new_focus)) do
		popup:Close()
		popup = popup.popup_parent
	end
	XWindow.OnKillFocus(self)
end

function XPopup:IsWithinPopupChain(child)
	local popup = child:IsKindOf("XPopup") and child or GetParentOfKind(child, "XPopup")
	while popup do
		if popup == self then return true end
		popup = GetParentOfKind(popup.popup_parent, "XPopup")
	end
end

function XPopup:OnMouseButtonDown(pt, button)
	if button == "L" then
		self:SetFocus()
		return "break"
	end
end


----- XPopupList

DefineClass.XPopupList = {
	__parents = { "XPopup" },
	properties = {
		{ category = "General", id = "MinItems", editor = "number", default = 5, },
		{ category = "General", id = "MaxItems", editor = "number", default = 25, },
		{ category = "General", id = "AutoFocus", editor = "bool", default = true, },
	},
	IdNode = true,
}

function XPopupList:Init()
	XSleekScroll:new({
		Id = "idScroll",
		Target = "idContainer",
		Dock = "right",
		Margins = box(1, 1, 1, 1),
		AutoHide = true,
		MinThumbSize = 30,
	}, self)
	XScrollArea:new({
		Id = "idContainer",
		Dock = "box",
		LayoutMethod = "VList",
		VScroll = "idScroll",
	}, self)
	self.idContainer.EnumFocusChildren = function(this, f)
		for _, win in ipairs(this) do
			local order = win:GetFocusOrder()
			if order then
				f(win, order:xy())
			else
				win:EnumFocusChildren(f)
			end
		end
	end
end

function XPopupList:Open(...)
	if self.AutoFocus then
		self.idContainer:SetFocus()
	end
	XPopup.Open(self, ...)
end

function XPopupList:UpdateLayout()
	local a_type = self.AnchorType
	if a_type ~= "drop" and a_type ~= "drop-right" then
		return XPopup.UpdateLayout(self)
	end

	local margins_x1, margins_y1, margins_x2, margins_y2 = ScaleXY(self.scale, self.Margins:xyxy())
	local anchor = self.Anchor
	local safe_area_x1, safe_area_y1, safe_area_x2, safe_area_y2 = GetSafeAreaBox()
	local width, height =  Max(anchor:sizex(),self.measure_width - margins_x1 - margins_x2), self.measure_height - margins_y1 - margins_y2
	
	local x, y = anchor:minx(), anchor:maxy()	
	
	if a_type == "drop-right" then
		x = anchor:minx() + anchor:sizex() - width
	end
	
	-- fit window to safe area
	if x + width + margins_x2 > safe_area_x2 then
		x = safe_area_x2 - width - margins_x2
	elseif x < safe_area_x1 then
		x = safe_area_x1
	end
	
	local items = self.idContainer
	local popup_max_y = y + height + margins_y2 
	local space_y = safe_area_y2 - y
	local fail = false
	if (safe_area_y2 - popup_max_y)<0 then
		-- try to reduce items count
		local vspace = self.idContainer.LayoutVSpacing
		y = anchor:maxy()		
		local size = margins_y1 + margins_y2 - vspace
		for i = 1, Min(#items, self.MaxItems) do
			local newsize = size + vspace + items[i].measure_height
			if newsize > space_y then
				fail = i<=self.MinItems 
				break
			end	
			size = newsize
		end
		if not fail then
			height = size
		end
		
		-- try to place over the control
		if fail then
			y = anchor:miny()
			local popup_min_y = y - height - margins_y1 
			local space_y = y - safe_area_y1
			if (popup_min_y - safe_area_y1)<0 then			
				-- try to reduce items count
				fail = false
				size = margins_y1 + margins_y2 + items[1].measure_height
				for i = 2, Min(#items, self.MaxItems) do
					local newsize = size + vspace + items[i].measure_height
					if newsize > space_y then
						fail = i<=self.MinItems
						break
					end	
					size = newsize
				end
				height = size
			end
			y = y - height
		end
	end
	-- layout	
	if fail then
		if y + height + margins_y2 > safe_area_y2 then
			y = safe_area_y2 - height - margins_y2
		elseif y < safe_area_y1 then
			y = safe_area_y1
		end
	end
	self:SetBox(x, y, width, height)
	return XControl.UpdateLayout(self)
end

function XPopupList:Measure(preferred_width, preferred_height)
	local width, height = XPopup.Measure(self, preferred_width, preferred_height)
	local items = self.idContainer
	if #items > self.MaxItems then
		local item_height = (self.MaxItems - 1) * self.idContainer.LayoutVSpacing
		for i = 1, self.MaxItems do
			item_height = item_height + items[i].measure_height
		end
		self.idContainer.MouseWheelStep = items[1].measure_height * 2
		return width, Min(height, item_height)
	end
	return width, height
end

function XPopupList:OnShortcut(shortcut, source, ...)
	if shortcut == "Escape" or shortcut == "ButtonB" then
		self:Close()
		return "break"
	end
	local relation = XShortcutToRelation[shortcut]
	if shortcut == "Down" or shortcut == "Up" or relation == "down" or relation == "up" then
		local focus = self.desktop.keyboard_focus
		local order = focus and focus:GetFocusOrder()
		if shortcut == "Down" or relation == "down" then
			focus = self.idContainer:GetRelativeFocus(order or point(0, 0), "next")
		else
			focus = self.idContainer:GetRelativeFocus(order or point(1000000000, 1000000000), "prev")
		end
		if focus then
			self.idContainer:ScrollIntoView(focus)
			focus:SetFocus()
		end
		return "break"
	end
end


----- XPropControl

DefineClass.XPropControl = {
	__parents = { "XContextControl" },
	properties = {
		{ category = "Scroll", id = "BindTo", name = "Bind to property", editor = "text", default = "", },
		
	},
	prop_meta = false,
	value = false,
}

function XPropControl:Init(parent, context)
	self.prop_meta = ResolveValue(context, "prop_meta")
end

function XPropControl:SetBindTo(prop_id, prop_meta)
	self.BindTo = prop_id
	if not prop_meta then
		ForEachObjInContext(self.context, function(obj, self, prop_id)
			prop_meta = prop_meta or IsKindOf(obj, "PropertyObject") and obj:GetPropertyMetadata(prop_id)
		end, self, prop_id)
	end
	self.prop_meta = prop_meta
end

function XPropControl:OnPropUpdate(context, prop_meta, value)
end

function XPropControl:GetPropName()
	local prop_meta = self.prop_meta
	return prop_meta and prop_meta.name or ""
end

function XPropControl:UpdatePropertyNames(prop_meta)
	local name = self:ResolveId("idName")
	if name then
		name:SetText(prop_meta.name or prop_meta.id)
	end
	if prop_meta.help and editor ~= "help" then
		self:SetRolloverText(prop_meta.help)
	end
end

function XPropControl:OnContextUpdate(context)
	local prop_id = self.BindTo
	local prop_meta = self.prop_meta
	if context and (prop_id ~= "" or prop_meta) then
		if prop_meta then
			prop_id = prop_meta.id
			self:UpdatePropertyNames(prop_meta)
		end
		local value = ResolveValue(context, prop_id)
		if value ~= rawget(self, "value") then
			self.value = value
			self:OnPropUpdate(context, prop_meta, value)
		end
	end
	XContextControl.OnContextUpdate(self, context)
end


----- XProgress

DefineClass.XProgress = {
	__parents = { "XPropControl" },
	
	properties = {
		{ category = "Progress", id = "Horizontal", name = "Horizontal", editor = "bool", default = true },
		{ category = "Progress", id = "Progress", name = "Progress", editor = "number", default = 0 },
		{ category = "Progress", id = "MaxProgress", name = "Max progress", editor = "number", default = 100, invalidate = "measure", },
		{ category = "Progress", id = "MinProgressSize", name = "Size at progress 0", editor = "number", default = 0 },
		{ category = "Progress", id = "ProgressClip", name = "Clip window", editor = "bool", default = false, invalidate = true, },
	},
}

function XProgress:OnPropUpdate(context, prop_meta, value)
	assert(type(value) == "number")
	if type(value) == "number" then
		if prop_meta then
			local scale = prop_meta.scale
			scale = type(scale) == "string" and const.Scale[scale] or scale or 1
			local min = prop_eval(prop_meta.min, context, prop_meta) or 0
			local max = prop_eval(prop_meta.max, context, prop_meta)
			self:SetMaxProgress(max and (max - min) / scale or self.MaxProgress)
			self:SetProgress((value - min) / scale)
		else
			self:SetProgress(value)
		end
	end
end

function XProgress:SetProgress(value)
	if self.Progress == value then return end
	self.Progress = value
	if self.ProgressClip then
		self:Invalidate()
	else
		self:InvalidateMeasure()
	end
end

function XProgress:MeasureSizeAdjust(max_width, max_height)
	local old_width = max_width

	local docked_x, docked_y = 0, 0
	for _, win in ipairs(self) do
		local dock = win.Dock
		if dock then
			win:UpdateMeasure(max_width, max_height)
			if dock == "left" or dock == "right"  then
				docked_x =  docked_x + win.measure_width
			elseif dock == "top" or dock == "bottom" then
				docked_y = docked_y + win.measure_height
			end
		end
	end
	
	local max = Max(1, self.MaxProgress)
	local progress = self.ProgressClip and max or Clamp(self.Progress, 0, max)
	if self.Horizontal then
		max_width = max_width - docked_x
		local min = ScaleXY(self.scale, self.MinProgressSize)
		max_width = min + (max_width - min) * progress / max
		max_width = max_width + docked_x
	else
		max_height = max_height - docked_y
		local _, min = ScaleXY(self.scale, 0, self.MinProgressSize)
		max_height = min + (max_height - min) * progress / max
		max_height = max_height + docked_y
	end
	
	return max_width, max_height
end

----- XAspectWindow

DefineClass.XAspectWindow = {
	__parents = { "XWindow" },
	properties = {
		{ category = "General", id = "Aspect", name = "Aspect", editor = "combo", default = point(16, 9), items = {
			{ name = "21:9 movie (64:27)", value = point(64, 27)}, 
			{ name = "2:1 Univisium", value = point(2, 1)}, 
			{ name = "16:9 HD", value = point(16, 9)}, 
			{ name = "5:3", value = point(5, 3)}, 
			{ name = "1.618:1 golden ratio", value = point(1618, 1000)}, 
			{ name = "3:2 35mm film", value = point(3, 2)}, 
			{ name = "4:3 legacy TV/monitor", value = point(4, 3)}, 
			{ name = "1:1", value = point(1, 1)}, 
			{ name = "1:2", value = point(1, 2)}, 
			{ name = "1:3", value = point(1, 3)}, 
			{ name = "1:4", value = point(1, 4)}, 
			{ name = "1:5", value = point(1, 5)}, 
		}},
		{ category = "General", id = "UseAllSpace", name = "Use available space", editor = "bool", default = true, },
		{ category = "General", id = "Fit", name = "Fit", editor = "choice", default = "smallest", items = {"none", "width", "height", "smallest", "largest"}, },
	}
}

local box0 = box(0, 0, 0, 0)
function XAspectWindow:SetLayoutSpace(x, y, width, height)
	local fit = self.Fit
	if fit ~= "none" then
		assert(self.Margins == box0)
		assert(self.Padding == box0)
		local aspect_x, aspect_y = self.Aspect:xy()
		local h_align = self.HAlign
		if fit == "smallest" or fit == "largest" then
			local space_is_wider = width * aspect_y >= height * aspect_x
			fit = space_is_wider == (fit == "largest") and "width" or "height"
		end
		if fit == "width" then
			local h = width * aspect_y / aspect_x
			local v_align = self.VAlign
			if v_align == "top" then
			elseif v_align == "center" or v_align == "stretch" then
				y = y + (height - h) / 2
			elseif v_align == "bottom" then
				y = y + (height - h)
			end
			height = h
		elseif fit == "height" then
			local w = height * aspect_x / aspect_y
			local h_align = self.HAlign
			if h_align == "left" then
			elseif h_align == "center" or h_align == "stretch" then
				x = x + (width - w) / 2
			elseif h_align == "right" then
				x = x + (width - w)
			end
			width = w
		end
		self:SetBox(x, y, width, height)
		return
	end
	XWindow.SetLayoutSpace(self, x, y, width, height)
end

function XAspectWindow:Measure(max_width, max_height)
	local aspect_x, aspect_y = self.Aspect:xy()
	local m_width = Min(max_width, max_height * aspect_x / aspect_y)
	local m_height = Min(max_height, max_width * aspect_y / aspect_x)
	local width, height = XWindow.Measure(self, m_width, m_height)
	local min_width = Max(width, height * aspect_x / aspect_y)
	local min_height = Max(height, width * aspect_y / aspect_x)
	if self.UseAllSpace then
		return Max(min_width, m_width), Max(min_height, m_height)
	end
	return min_width, min_height
end


----- XVirtualContent
--
-- Use to embed in XList; does not spawn the controls from its XTemplate until it is visible,
-- thus making lists with 1000s elements perform decently.

function NewXVirtualContent(parent, context, xtemplate, width, height, refresh_interval, min_width, min_height)
	local obj = {
		MinWidth = min_width or width or 10,
		MaxWidth = width or 1000000,
		MinHeight = min_height or height or 10,
		MaxHeight = height or 1000000,
		desktop = false,
		parent = false,
		children = false,
		window_state = false,
		box = empty_box,
		content_box = empty_box,
		scale = XWindow.scale,
		xtemplate = xtemplate,
		context = context or false,
		measure_update = true,
		layout_update = true,
		outside_parent = true,
		RefreshInterval = refresh_interval,
	}
	return XVirtualContent:new(obj, parent, context)
end

DefineClass.XVirtualContent = {
	__parents = { "XControl" },
	xtemplate = false,
	spawned = false,
	selected = false,
	RefreshInterval = false,
}

local function UpdateContext(win)
	for _, child in ipairs(win) do
		if IsKindOf(child, "XContextWindow") then
			child:OnContextUpdate(child.context)
		end
		UpdateContext(child)
	end
end

function XVirtualContent:SpawnChildren()
	XTemplateSpawn(self.xtemplate, self, self.context)
	if self.RefreshInterval then
		self:CreateThread("UpdateContext", function(self)
			while true do
				Sleep(self.RefreshInterval)
				UpdateContext(self)
			end
		end, self)
	end
end

function XVirtualContent:UpdateMeasure(max_width, max_height)
	-- once measured, don't update measure if the control goes outside the parent (it would be measured wrong without child controls anyway)
	if not self.spawned and (self.measure_width ~= 0 or self.measure_height ~= 0) then
		self.measure_update = false
		return
	end
	XControl.UpdateMeasure(self, max_width, max_height)
end

function XVirtualContent:SetOutsideParent(outside_parent)
	XWindow.SetOutsideParent(self, outside_parent)
	self:SetSpawned(not outside_parent)
end

function XVirtualContent:SetSpawned(spawn)
	if self.spawned == spawn then return end
	if not spawn and self.parent.force_keep_items_spawned then return end
	self.spawned = spawn
	
	self.Invalidate = empty_func
	self:DeleteChildren()
	if spawn then
		self:SpawnChildren()
		for _, win in ipairs(self) do
			win:Open()
		end
		self:UpdateMeasure(self.parent.content_box:size():xy())
		self:UpdateLayout()
	else
		self:DeleteThread("UpdateContext")
	end
	self.Invalidate = nil
	if spawn then
		local scrollarea = GetParentOfKind(self, "XScrollArea")
		if scrollarea then
			scrollarea:InvalidateMeasure()
		end
		self:SetChildSelected()
		Msg("XWindowRecreated", self)
	end
	if self.desktop:GetKeyboardFocus() == self then
		self:SetFocus()
	end
end

function XVirtualContent:SetSelected(selected)
	self.selected = selected
	self:SetChildSelected()
end

function XVirtualContent:SetChildSelected()
	local child = self[1]
	if child then
		child:ResolveRelativeFocusOrder(self.FocusOrder)
		if child:HasMember("SetSelected") then
			child:SetSelected(self.selected)
		end
	end
end

function XVirtualContent:SetFocus()
	XControl.SetFocus(self[1] or self)
end


----- XSizeConstrainedWindow

--XSizeConstrainedWindows are XWindows that will scale down,
--if they would otherwise exceed their maximum space when measuring.
--Note: avoid assigning margins, as they get scaled as well.
DefineClass.XSizeConstrainedWindow = {
	__parents = { "XWindow" },
}

local one = point(1000, 1000)
function XSizeConstrainedWindow:UpdateMeasure(max_width, max_height)
	if not self.measure_update then return end
	
	--Normal measure (allow the content to fit within the max space)
	XWindow.UpdateMeasure(self, max_width, max_height)
	
	--If the window has exceeded any of it's maximum space contraints
	if self.measure_width > max_width or self.measure_height > max_height then
		--Before measuring again, the scale must be cleared
		local scale_x, scale_y = self.scale:xy()
		local scale_ratio = MulDivRound(scale_y, 1000, scale_x)
		self:SetScaleModifier(one)
		XWindow.UpdateMeasure(self, max_width, max_height)
		
		--Figure out which side should be contrained (width or height)
		local space_ratio = MulDivRound(max_height, 1000, max_width)
		local measure_ratio = MulDivRound(self.measure_height, 1000, self.measure_width)
		local width_contrained = measure_ratio < space_ratio
		
		--Determine a new scale, such that the contrained side will be as big as the max space in that dimension
		local content_width, content_height = ScaleXY(self.parent.scale, self.measure_width, self.measure_height)
		if width_contrained then
			scale_x = MulDivRound(self.parent.scale:x(), max_width, content_width)
			scale_y = MulDivRound(scale_x, scale_ratio, 1000)
		else
			scale_y = MulDivRound(self.parent.scale:y(), max_height, content_height)
			scale_x = MulDivRound(scale_y, 1000, scale_ratio)
		end
		
		self:SetScaleModifier(point(scale_x, scale_y))
		XWindow.UpdateMeasure(self, max_width, max_height)
	end
end

function CreateNumberEditor(parent, id, up_pressed, down_pressed, no_buttons)
	local panel = XWindow:new({ Dock = "box" }, parent)
	local button_panel = XWindow:new({
		Id = "idNumberEditor",
		Dock = "right",
	}, panel)
	local function get_button_multiplier()
		if terminal.IsKeyPressed(const.vkControl) then
			return 10
		elseif terminal.IsKeyPressed(const.vkShift) then
			return 100
		else
			return 1
		end
	end
	local button_rollover_text = "Use LMB, Ctrl+LMB, or Shift+LMB to change the value."
	local top_btn = not no_buttons and XTextButton:new({
		Dock = "top",
		OnPress = function(button) up_pressed(get_button_multiplier()) end,
		Padding = box(1, 2, 1, 1),
		Icon = "CommonAssets/UI/arrowup-40.tga",
		IconScale = point(500, 500),
		IconColor = RGB(0, 0, 0),
		FoldWhenHidden = true,
		DisabledIconColor = RGBA(0, 0, 0, 128),
		Background = RGBA(0, 0, 0, 0),
		DisabledBackground = RGBA(0, 0, 0, 0),
		RolloverBackground = RGB(204, 232, 255),
		PressedBackground = RGB(121, 189, 241),
		RolloverTemplate = "GedPropRollover",
		RolloverText = button_rollover_text,
		RolloverAnchor = "center-top",
	}, button_panel)
	
	local bottom_btn = not no_buttons and XTextButton:new({
		Dock = "bottom",
		OnPress = function(button) down_pressed(get_button_multiplier()) end,
		Padding = box(1, 1, 1, 2),
		Icon = "CommonAssets/UI/arrowdown-40.tga",
		IconScale = point(500, 500),
		IconColor = RGB(0, 0, 0),
		FoldWhenHidden = true,
		DisabledIconColor = RGBA(0, 0, 0, 128),
		Background = RGBA(0, 0, 0, 0),
		DisabledBackground = RGBA(0, 0, 0, 0),
		RolloverBackground = RGB(204, 232, 255),
		PressedBackground = RGB(121, 189, 241),
		RolloverTemplate = "GedPropRollover",
		RolloverText = button_rollover_text,
		RolloverAnchor = "center-bottom",
	}, button_panel)

	local edit = XNumberEdit:new({
		Id = id,
		Dock = "box",
		OnShortcut = function(control, shortcut, ...)
			if shortcut == "Up" then
				up_pressed(1)
			elseif shortcut == "Down" then
				down_pressed(1)
			elseif shortcut == "Ctrl-Up" then
				up_pressed(10)
			elseif shortcut == "Ctrl-Down" then
				down_pressed(10)
			elseif shortcut == "Ctrl-Left" then
				up_pressed(100)
			elseif shortcut == "Ctrl-Right" then
				down_pressed(100)
			else
				return XNumberEdit.OnShortcut(control, shortcut, ...)
			end
			return "break"
		end,
		top_btn = top_btn or nil,
		bottom_btn = bottom_btn or nil,
		OnMouseWheelForward = function() if terminal.IsKeyPressed(const.vkControl) then   up_pressed(1) return "break" end end,
		OnMouseWheelBack    = function() if terminal.IsKeyPressed(const.vkControl) then down_pressed(1) return "break" end end,
		RolloverTemplate = "GedPropRollover",
		RolloverText = "Use arrow keys, Ctrl+arrows, or Ctrl+MouseWheel to change the value.",
	}, panel)
	
	return edit, top_btn, bottom_btn
end