typescratch 1.6.0__tar.gz → 1.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {typescratch-1.6.0 → typescratch-1.6.2}/PKG-INFO +1 -1
  2. {typescratch-1.6.0 → typescratch-1.6.2}/pyproject.toml +1 -1
  3. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/__init__.py +1 -1
  4. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/blocks.py +46 -5
  5. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/decompiler.py +50 -0
  6. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch.egg-info/PKG-INFO +1 -1
  7. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch.egg-info/SOURCES.txt +1 -0
  8. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/__init__.py +1 -1
  9. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/ast_nodes.py +36 -0
  10. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/blocks.py +13 -5
  11. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/cli.py +20 -0
  12. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/codegen.py +129 -3
  13. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/decompiler.py +47 -0
  14. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/lexer.py +3 -0
  15. typescratch-1.6.2/typescratch_pypi_release/typescratch_src/package_manager.py +127 -0
  16. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/parser.py +129 -8
  17. {typescratch-1.6.0 → typescratch-1.6.2}/LICENSE +0 -0
  18. {typescratch-1.6.0 → typescratch-1.6.2}/README.md +0 -0
  19. {typescratch-1.6.0 → typescratch-1.6.2}/setup.cfg +0 -0
  20. {typescratch-1.6.0 → typescratch-1.6.2}/setup.py +0 -0
  21. {typescratch-1.6.0 → typescratch-1.6.2}/tests/test_basic.py +0 -0
  22. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/__main__.py +0 -0
  23. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/assets.py +0 -0
  24. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/ast_nodes.py +0 -0
  25. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/cli.py +0 -0
  26. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/codegen.py +0 -0
  27. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/errors.py +0 -0
  28. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/ide.py +0 -0
  29. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/lexer.py +0 -0
  30. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/logo.svg +0 -0
  31. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/logo_ascii.txt +0 -0
  32. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/meow.txt +0 -0
  33. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/package_manager.py +0 -0
  34. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/parser.py +0 -0
  35. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/sb3.py +0 -0
  36. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch/scratchcat_official.svg +0 -0
  37. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch.egg-info/dependency_links.txt +0 -0
  38. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch.egg-info/entry_points.txt +0 -0
  39. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch.egg-info/requires.txt +0 -0
  40. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch.egg-info/top_level.txt +0 -0
  41. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/setup.py +0 -0
  42. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/__main__.py +0 -0
  43. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/assets.py +0 -0
  44. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/errors.py +0 -0
  45. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/ide.py +0 -0
  46. {typescratch-1.6.0 → typescratch-1.6.2}/typescratch_pypi_release/typescratch_src/sb3.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typescratch
3
- Version: 1.6.0
3
+ Version: 1.6.2
4
4
  Summary: A programming language that compiles to Scratch 3 (.sb3) files
5
5
  Author: TypeScratch
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "typescratch"
7
- version = "1.6.0"
7
+ version = "1.6.2"
8
8
  description = "A programming language that compiles to Scratch 3 (.sb3) files"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -46,7 +46,7 @@ from .codegen import Codegen
46
46
  from .sb3 import write_sb3, write_sb3_bytes
47
47
 
48
48
 
49
- __version__ = "1.6.0"
49
+ __version__ = "1.6.2"
50
50
 
51
51
 
52
52
  def compile_source(src: str, filename: str = "<tysh>", debug: bool = False,
@@ -153,25 +153,51 @@ STACK_BLOCKS = {
153
153
  "args": [],
154
154
  },
155
155
  "goToFront": {
156
+ "opcode": "looks_gotofrontback",
157
+ "fields": [("FRONT_BACK", "front")],
158
+ },
159
+ "goToBack": {
160
+ "opcode": "looks_gotofrontback",
161
+ "fields": [("FRONT_BACK", "back")],
162
+ },
163
+ "goFront": {
164
+ "opcode": "looks_gotofrontback",
165
+ "fields": [("FRONT_BACK", "front")],
166
+ },
167
+ "goBack": {
168
+ "opcode": "looks_gotofrontback",
169
+ "fields": [("FRONT_BACK", "back")],
170
+ },
171
+ "goForwardLayers": {
156
172
  "opcode": "looks_goforwardbackwardlayers",
157
173
  "fields": [("FRONTBACK", "front")],
158
- "args": [("NUM", "number", "args", True)], # optional - default 1
174
+ "args": [("NUM", "number", "args")],
159
175
  },
160
- "goToBack": {
176
+ "goBackLayers": {
161
177
  "opcode": "looks_goforwardbackwardlayers",
162
178
  "fields": [("FRONTBACK", "back")],
163
- "args": [("NUM", "number", "args", True)],
179
+ "args": [("NUM", "number", "args")],
164
180
  },
165
- "goBackLayers": {
181
+ "goFwdLyrs": {
182
+ "opcode": "looks_goforwardbackwardlayers",
183
+ "fields": [("FRONTBACK", "front")],
184
+ "args": [("NUM", "number", "args")],
185
+ },
186
+ "goBwdLyrs": {
166
187
  "opcode": "looks_goforwardbackwardlayers",
167
188
  "fields": [("FRONTBACK", "back")],
168
189
  "args": [("NUM", "number", "args")],
169
190
  },
170
- "goForwardLayers": {
191
+ "goFwdLayers": {
171
192
  "opcode": "looks_goforwardbackwardlayers",
172
193
  "fields": [("FRONTBACK", "front")],
173
194
  "args": [("NUM", "number", "args")],
174
195
  },
196
+ "goBwdLayers": {
197
+ "opcode": "looks_goforwardbackwardlayers",
198
+ "fields": [("FRONTBACK", "back")],
199
+ "args": [("NUM", "number", "args")],
200
+ },
175
201
 
176
202
  # ---- Sound ----
177
203
  "playSound": {
@@ -552,8 +578,23 @@ REPORTERS = {
552
578
  # Looks
553
579
  "costumeName": {"opcode": "looks_costumename", "returns": "string"},
554
580
  "costumeNumber": {"opcode": "looks_costumenumber", "returns": "number"},
581
+ "costumeNumberName": {
582
+ "opcode": "looks_costumenumbername",
583
+ "fields": [("NUMBER_NAME", None)],
584
+ "returns": "any",
585
+ },
586
+ "cstmNumName": {
587
+ "opcode": "looks_costumenumbername",
588
+ "fields": [("NUMBER_NAME", None)],
589
+ "returns": "any",
590
+ },
555
591
  "backdropName": {"opcode": "looks_backdropname", "returns": "string"},
556
592
  "backdropNumber": {"opcode": "looks_backdropnumber", "returns": "number"},
593
+ "backdropNumberName": {
594
+ "opcode": "looks_backdropnumbername",
595
+ "fields": [("NUMBER_NAME", None)],
596
+ "returns": "any",
597
+ },
557
598
  "size": {"opcode": "looks_size", "returns": "number"},
558
599
 
559
600
  # Sound
@@ -478,6 +478,22 @@ class Decompiler:
478
478
  self._decompile_generic_block(blocks, block, ts_key, spec, indent)
479
479
  return
480
480
 
481
+ # Looks: go to front/back
482
+ if op == "looks_gotofrontback":
483
+ fb = block.get("fields", {}).get("FRONT_BACK", ["front"])[0]
484
+ self.output.append(f"{indent}goTo{fb.capitalize()}")
485
+ return
486
+
487
+ # Looks: go forward/backward layers
488
+ if op == "looks_goforwardbackwardlayers":
489
+ fb = block.get("fields", {}).get("FRONTBACK", ["front"])[0]
490
+ num = self._decompile_input(blocks, block, "NUM")
491
+ if fb == "front":
492
+ self.output.append(f"{indent}goForwardLayers({num})")
493
+ else:
494
+ self.output.append(f"{indent}goBackLayers({num})")
495
+ return
496
+
481
497
  # Unknown block - output as comment
482
498
  self.output.append(f"{indent}// TODO: unknown block {op}")
483
499
 
@@ -725,6 +741,12 @@ class Decompiler:
725
741
  return "costumeName"
726
742
  if op == "looks_costumenumber":
727
743
  return "costumeNumber"
744
+ if op == "looks_costumenumbername":
745
+ field = block.get("fields", {}).get("NUMBER_NAME", [""])[0]
746
+ return f"costumeNumberName({field})"
747
+ if op == "looks_backdropnumbername":
748
+ field = block.get("fields", {}).get("NUMBER_NAME", [""])[0]
749
+ return f"backdropNumberName({field})"
728
750
 
729
751
  # Sound
730
752
  if op == "sound_volume":
@@ -793,6 +815,34 @@ class Decompiler:
793
815
  args_str = ", ".join(str(a) for a in all_args)
794
816
  return f"{ts_key}({args_str})" if all_args else ts_key
795
817
 
818
+ # Menu shadow blocks (used as field values in dropdowns)
819
+ if op == "motion_goto_menu":
820
+ return block.get("fields", {}).get("TO", [""])[0]
821
+ if op == "motion_pointtowards_menu":
822
+ return block.get("fields", {}).get("TOWARDS", [""])[0]
823
+ if op == "motion_glideto_menu":
824
+ return block.get("fields", {}).get("TO", [""])[0]
825
+ if op == "sensing_of_object_menu":
826
+ return block.get("fields", {}).get("OBJECT", [""])[0]
827
+ if op == "sensing_touchingobjectmenu":
828
+ return block.get("fields", {}).get("TOUCHINGOBJECTMENU", [""])[0]
829
+ if op == "sensing_distancetomenu":
830
+ return block.get("fields", {}).get("DISTANCETOMENU", [""])[0]
831
+ if op == "sensing_keyoptions":
832
+ return block.get("fields", {}).get("KEY_OPTION", [""])[0]
833
+ if op == "control_create_clone_of_menu":
834
+ return block.get("fields", {}).get("CLONE_OPTION", [""])[0]
835
+ if op == "looks_backdrops":
836
+ return block.get("fields", {}).get("BACKDROP", [""])[0]
837
+ if op == "looks_costume":
838
+ return block.get("fields", {}).get("COSTUME", [""])[0]
839
+ if op == "sound_sounds_menu":
840
+ return block.get("fields", {}).get("SOUND_MENU", [""])[0]
841
+ if op == "sound_beats_menu":
842
+ return block.get("fields", {}).get("BEAT", [""])[0]
843
+ if op == "sound_effects_menu":
844
+ return block.get("fields", {}).get("EFFECT", [""])[0]
845
+
796
846
  # Unknown reporter
797
847
  return f"/* {op} */"
798
848
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typescratch
3
- Version: 1.6.0
3
+ Version: 1.6.2
4
4
  Summary: A programming language that compiles to Scratch 3 (.sb3) files
5
5
  Author: TypeScratch
6
6
  License: Apache-2.0
@@ -39,5 +39,6 @@ typescratch_pypi_release/typescratch_src/decompiler.py
39
39
  typescratch_pypi_release/typescratch_src/errors.py
40
40
  typescratch_pypi_release/typescratch_src/ide.py
41
41
  typescratch_pypi_release/typescratch_src/lexer.py
42
+ typescratch_pypi_release/typescratch_src/package_manager.py
42
43
  typescratch_pypi_release/typescratch_src/parser.py
43
44
  typescratch_pypi_release/typescratch_src/sb3.py
@@ -46,7 +46,7 @@ from .codegen import Codegen
46
46
  from .sb3 import write_sb3, write_sb3_bytes
47
47
 
48
48
 
49
- __version__ = "1.6.0"
49
+ __version__ = "1.6.2"
50
50
 
51
51
 
52
52
  def compile_source(src: str, filename: str = "<tysh>", debug: bool = False,
@@ -361,3 +361,39 @@ class ListReporterRef:
361
361
  method: str # 'item' | 'length' | 'contains' | 'itemNum' | 'contents'
362
362
  args: List[Any]
363
363
  line: int = 0
364
+
365
+
366
+ @dataclass
367
+ class TernaryExpr:
368
+ """Ternary expression: [if (cond) true_val else false_val]
369
+
370
+ Desugared at codegen time into if/else that sets a temp variable.
371
+ """
372
+ cond: Any
373
+ true_expr: Any
374
+ false_expr: Any
375
+ line: int = 0
376
+
377
+
378
+ @dataclass
379
+ class SoundImport:
380
+ """sound name from "path.wav" - imports a sound file."""
381
+ name: str
382
+ path: str
383
+ line: int = 0
384
+
385
+
386
+ @dataclass
387
+ class CostumeImport:
388
+ """costume name from "path.png" - imports a costume file."""
389
+ name: str
390
+ path: str
391
+ line: int = 0
392
+
393
+
394
+ @dataclass
395
+ class LocalVarDecl:
396
+ """private x = value - declares a local variable inside a custom block."""
397
+ name: str
398
+ value: Any
399
+ line: int = 0
@@ -153,14 +153,17 @@ STACK_BLOCKS = {
153
153
  "args": [],
154
154
  },
155
155
  "goToFront": {
156
- "opcode": "looks_goforwardbackwardlayers",
157
- "fields": [("FRONTBACK", "front")],
158
- "args": [("NUM", "number", "args", True)], # optional - default 1
156
+ "opcode": "looks_gotofrontback",
157
+ "fields": [("FRONT_BACK", "front")],
159
158
  },
160
159
  "goToBack": {
160
+ "opcode": "looks_gotofrontback",
161
+ "fields": [("FRONT_BACK", "back")],
162
+ },
163
+ "goForwardLayers": {
161
164
  "opcode": "looks_goforwardbackwardlayers",
162
- "fields": [("FRONTBACK", "back")],
163
- "args": [("NUM", "number", "args", True)],
165
+ "fields": [("FRONTBACK", "front")],
166
+ "args": [("NUM", "number", "args")],
164
167
  },
165
168
  "goBackLayers": {
166
169
  "opcode": "looks_goforwardbackwardlayers",
@@ -552,6 +555,11 @@ REPORTERS = {
552
555
  # Looks
553
556
  "costumeName": {"opcode": "looks_costumename", "returns": "string"},
554
557
  "costumeNumber": {"opcode": "looks_costumenumber", "returns": "number"},
558
+ "costumeNumberName": {
559
+ "opcode": "looks_costumenumbername",
560
+ "fields": [("NUMBER_NAME", None)],
561
+ "returns": "any",
562
+ },
555
563
  "backdropName": {"opcode": "looks_backdropname", "returns": "string"},
556
564
  "backdropNumber": {"opcode": "looks_backdropnumber", "returns": "number"},
557
565
  "size": {"opcode": "looks_size", "returns": "number"},
@@ -51,6 +51,14 @@ def main(argv: Optional[List[str]] = None) -> int:
51
51
 
52
52
  sub.add_parser("meow", help="Print the Scratch Cat in ASCII art")
53
53
 
54
+ p_install = sub.add_parser("install", help="Install a package from GitHub")
55
+ p_install.add_argument("repo", help="github user/repo or user/repo@tag")
56
+
57
+ p_uninstall = sub.add_parser("uninstall", help="Remove an installed package")
58
+ p_uninstall.add_argument("name", help="package name")
59
+
60
+ sub.add_parser("list", help="List installed packages")
61
+
54
62
  sub.add_parser("version", help="Print version")
55
63
 
56
64
  args = parser.parse_args(argv)
@@ -80,6 +88,18 @@ def main(argv: Optional[List[str]] = None) -> int:
80
88
  if args.cmd == "meow":
81
89
  return _meow()
82
90
 
91
+ if args.cmd == "install":
92
+ from .package_manager import install
93
+ return install(args.repo)
94
+
95
+ if args.cmd == "uninstall":
96
+ from .package_manager import uninstall
97
+ return uninstall(args.name)
98
+
99
+ if args.cmd == "list":
100
+ from .package_manager import list_packages
101
+ return list_packages()
102
+
83
103
  if args.cmd in ("build", "run"):
84
104
  try:
85
105
  out_path = _file(args.source, out=args.out, debug=args.debug)
@@ -476,7 +476,7 @@ class Codegen:
476
476
  "rotationCenterY": rcy,
477
477
  })
478
478
  else:
479
- # Sprite costume
479
+ # Sprite costume - use img= if specified
480
480
  path = target.img
481
481
  data, _ignored_md5ext, fmt, rcx, rcy = self._load_asset(path, is_stage=False)
482
482
  asset_id = _md5(data)
@@ -491,11 +491,73 @@ class Codegen:
491
491
  "rotationCenterX": rcx,
492
492
  "rotationCenterY": rcy,
493
493
  })
494
+ # Additional costumes from `costume name from "path.png"` imports
495
+ for cinfo in getattr(target, "_costume_imports", []):
496
+ cname = cinfo["name"]
497
+ cpath = cinfo["path"]
498
+ cdata, _cmd5, cfmt, crcx, crcy = self._load_asset(cpath, is_stage=False)
499
+ casset_id = _md5(cdata)
500
+ cmd5ext = f"{casset_id}.{cfmt}"
501
+ self.assets[cmd5ext] = cdata
502
+ costumes.append({
503
+ "assetId": casset_id,
504
+ "name": cname,
505
+ "bitmapResolution": 1 if cfmt == "svg" else 2,
506
+ "md5ext": cmd5ext,
507
+ "dataFormat": cfmt,
508
+ "rotationCenterX": crcx,
509
+ "rotationCenterY": crcy,
510
+ })
494
511
  return costumes
495
512
 
496
513
  def gen_sounds(self, target: A.Target) -> List[dict]:
497
- # No sound declaration syntax yet - emit an empty list
498
- return []
514
+ """Generate sound assets from `sound name from "path.wav"` imports."""
515
+ sounds = []
516
+ for sinfo in getattr(target, "_sound_imports", []):
517
+ name = sinfo["name"]
518
+ path = sinfo["path"]
519
+ full = path
520
+ if self.asset_root and not os.path.isabs(path):
521
+ full = os.path.join(self.asset_root, path)
522
+ if os.path.isfile(full):
523
+ with open(full, "rb") as f:
524
+ data = f.read()
525
+ # Determine format
526
+ if full.lower().endswith(".wav"):
527
+ fmt = "wav"
528
+ elif full.lower().endswith(".mp3"):
529
+ fmt = "mp3"
530
+ else:
531
+ fmt = "wav"
532
+ asset_id = _md5(data)
533
+ md5ext = f"{asset_id}.{fmt}"
534
+ self.assets[md5ext] = data
535
+ sounds.append({
536
+ "assetId": asset_id,
537
+ "name": name,
538
+ "dataFormat": fmt,
539
+ "format": "",
540
+ "rate": 48000,
541
+ "sampleCount": 0,
542
+ "md5ext": md5ext,
543
+ })
544
+ else:
545
+ # File not found - use silent fallback
546
+ from . import assets as ASSETS2
547
+ data = ASSETS2.SILENT_WAV
548
+ asset_id = _md5(data)
549
+ md5ext = f"{asset_id}.wav"
550
+ self.assets[md5ext] = data
551
+ sounds.append({
552
+ "assetId": asset_id,
553
+ "name": name,
554
+ "dataFormat": "wav",
555
+ "format": "",
556
+ "rate": 48000,
557
+ "sampleCount": 0,
558
+ "md5ext": md5ext,
559
+ })
560
+ return sounds
499
561
 
500
562
  def _load_asset(self, path: Optional[str], is_stage: bool) -> Tuple[bytes, str, str, int, int]:
501
563
  """Returns (data, md5ext, format, rotationCenterX, rotationCenterY)."""
@@ -676,6 +738,11 @@ class Codegen:
676
738
  parent_id, stmt.line)
677
739
  if isinstance(stmt, A.BlockCall):
678
740
  return self.gen_block_call(stmt, parent_id)
741
+ if isinstance(stmt, A.LocalVarDecl):
742
+ return self.gen_var_assign(
743
+ A.VarAssign(name=stmt.name, value=stmt.value, line=stmt.line),
744
+ parent_id
745
+ )
679
746
  raise CompileError(f"unknown statement type: {type(stmt).__name__}", stmt.line)
680
747
 
681
748
  def gen_simple_stack(self, opcode: str, inputs_spec, parent_id, line) -> Tuple[str, str]:
@@ -1485,6 +1552,8 @@ class Codegen:
1485
1552
  return None, block_id
1486
1553
  if isinstance(expr, A.ListReporterRef):
1487
1554
  return self._gen_list_reporter(expr)
1555
+ if isinstance(expr, A.TernaryExpr):
1556
+ return self._gen_ternary(expr)
1488
1557
  if isinstance(expr, A.BuiltinRef):
1489
1558
  # Builtin reporter (e.g. answer, mouseX)
1490
1559
  spec = B.REPORTERS.get(expr.name)
@@ -1544,6 +1613,63 @@ class Codegen:
1544
1613
  # default: string literal
1545
1614
  return A.StringLit(value=name, line=ref.line)
1546
1615
 
1616
+ def _gen_ternary(self, expr: A.TernaryExpr) -> Tuple[Optional[list], Optional[str]]:
1617
+ """Generate a ternary expression [if (cond) x else y].
1618
+
1619
+ This is desugared into: a temp variable, set by an if/else.
1620
+ We create the if/else blocks inline and return a VarRef to the temp.
1621
+ """
1622
+ # Create a temp variable
1623
+ tmp_name = f"_ternary_{gen_id()[:8]}"
1624
+ self._declare_var(tmp_name, 0)
1625
+ vid = self.current_vars[tmp_name]
1626
+
1627
+ # Coerce the condition
1628
+ cond = self._coerce_condition(expr.cond)
1629
+ shadow_cond, sub_cond = self.gen_input_value(cond, "boolean")
1630
+ cond_input = self._pack_input("boolean", shadow_cond, sub_cond)
1631
+
1632
+ # Generate the true branch: set tmp = true_expr
1633
+ true_shadow, true_sub = self.gen_input_value(expr.true_expr, "any")
1634
+ true_block_id = gen_id()
1635
+ self.current_blocks[true_block_id] = {
1636
+ "opcode": "data_setvariableto",
1637
+ "next": None, "parent": None,
1638
+ "inputs": {"VALUE": self._pack_input("any", true_shadow, true_sub)},
1639
+ "fields": {"VARIABLE": [tmp_name, vid]},
1640
+ "shadow": False, "topLevel": False,
1641
+ }
1642
+
1643
+ # Generate the false branch: set tmp = false_expr
1644
+ false_shadow, false_sub = self.gen_input_value(expr.false_expr, "any")
1645
+ false_block_id = gen_id()
1646
+ self.current_blocks[false_block_id] = {
1647
+ "opcode": "data_setvariableto",
1648
+ "next": None, "parent": None,
1649
+ "inputs": {"VALUE": self._pack_input("any", false_shadow, false_sub)},
1650
+ "fields": {"VARIABLE": [tmp_name, vid]},
1651
+ "shadow": False, "topLevel": False,
1652
+ }
1653
+
1654
+ # Generate the if/else block
1655
+ if_block_id = gen_id()
1656
+ self.current_blocks[if_block_id] = {
1657
+ "opcode": "control_if_else",
1658
+ "next": None, "parent": None,
1659
+ "inputs": {
1660
+ "CONDITION": cond_input,
1661
+ "SUBSTACK": [2, true_block_id],
1662
+ "SUBSTACK2": [2, false_block_id],
1663
+ },
1664
+ "fields": {},
1665
+ "shadow": False, "topLevel": False,
1666
+ }
1667
+ self.current_blocks[true_block_id]["parent"] = if_block_id
1668
+ self.current_blocks[false_block_id]["parent"] = if_block_id
1669
+
1670
+ # Return a variable reference to the temp
1671
+ return [SHADOW_VAR, tmp_name, vid], None
1672
+
1547
1673
  def _gen_list_reporter(self, expr: A.ListReporterRef) -> Tuple[Optional[list], Optional[str]]:
1548
1674
  """Generate a list reporter block (data_itemoflist, data_lengthoflist,
1549
1675
  data_listcontainsitem, data_itemnumoflist, data_listcontents).
@@ -478,6 +478,22 @@ class Decompiler:
478
478
  self._decompile_generic_block(blocks, block, ts_key, spec, indent)
479
479
  return
480
480
 
481
+ # Looks: go to front/back
482
+ if op == "looks_gotofrontback":
483
+ fb = block.get("fields", {}).get("FRONT_BACK", ["front"])[0]
484
+ self.output.append(f"{indent}goTo{fb.capitalize()}")
485
+ return
486
+
487
+ # Looks: go forward/backward layers
488
+ if op == "looks_goforwardbackwardlayers":
489
+ fb = block.get("fields", {}).get("FRONTBACK", ["front"])[0]
490
+ num = self._decompile_input(blocks, block, "NUM")
491
+ if fb == "front":
492
+ self.output.append(f"{indent}goForwardLayers({num})")
493
+ else:
494
+ self.output.append(f"{indent}goBackLayers({num})")
495
+ return
496
+
481
497
  # Unknown block - output as comment
482
498
  self.output.append(f"{indent}// TODO: unknown block {op}")
483
499
 
@@ -725,6 +741,9 @@ class Decompiler:
725
741
  return "costumeName"
726
742
  if op == "looks_costumenumber":
727
743
  return "costumeNumber"
744
+ if op == "looks_costumenumbername":
745
+ field = block.get("fields", {}).get("NUMBER_NAME", [""])[0]
746
+ return f"costumeNumberName({field})"
728
747
 
729
748
  # Sound
730
749
  if op == "sound_volume":
@@ -793,6 +812,34 @@ class Decompiler:
793
812
  args_str = ", ".join(str(a) for a in all_args)
794
813
  return f"{ts_key}({args_str})" if all_args else ts_key
795
814
 
815
+ # Menu shadow blocks (used as field values in dropdowns)
816
+ if op == "motion_goto_menu":
817
+ return block.get("fields", {}).get("TO", [""])[0]
818
+ if op == "motion_pointtowards_menu":
819
+ return block.get("fields", {}).get("TOWARDS", [""])[0]
820
+ if op == "motion_glideto_menu":
821
+ return block.get("fields", {}).get("TO", [""])[0]
822
+ if op == "sensing_of_object_menu":
823
+ return block.get("fields", {}).get("OBJECT", [""])[0]
824
+ if op == "sensing_touchingobjectmenu":
825
+ return block.get("fields", {}).get("TOUCHINGOBJECTMENU", [""])[0]
826
+ if op == "sensing_distancetomenu":
827
+ return block.get("fields", {}).get("DISTANCETOMENU", [""])[0]
828
+ if op == "sensing_keyoptions":
829
+ return block.get("fields", {}).get("KEY_OPTION", [""])[0]
830
+ if op == "control_create_clone_of_menu":
831
+ return block.get("fields", {}).get("CLONE_OPTION", [""])[0]
832
+ if op == "looks_backdrops":
833
+ return block.get("fields", {}).get("BACKDROP", [""])[0]
834
+ if op == "looks_costume":
835
+ return block.get("fields", {}).get("COSTUME", [""])[0]
836
+ if op == "sound_sounds_menu":
837
+ return block.get("fields", {}).get("SOUND_MENU", [""])[0]
838
+ if op == "sound_beats_menu":
839
+ return block.get("fields", {}).get("BEAT", [""])[0]
840
+ if op == "sound_effects_menu":
841
+ return block.get("fields", {}).get("EFFECT", [""])[0]
842
+
796
843
  # Unknown reporter
797
844
  return f"/* {op} */"
798
845
 
@@ -76,6 +76,9 @@ KEYWORDS = {
76
76
  "mod": "MOD",
77
77
  "true": "TRUE",
78
78
  "false": "FALSE",
79
+ "sound": "SOUND_KW",
80
+ "costume": "COSTUME_KW",
81
+ "private": "PRIVATE_KW",
79
82
  }
80
83
 
81
84
  # Attribute prefixes that bind tightly to '='
@@ -0,0 +1,127 @@
1
+ """TypeScratch Package Manager - install libraries from GitHub.
2
+
3
+ Usage:
4
+ typescratch install <github-user/repo>
5
+ typescratch install <github-user/repo>@<tag>
6
+ typescratch uninstall <package-name>
7
+ typescratch list
8
+
9
+ Packages are cloned into ./typescratch_packages/<repo-name>/
10
+ and can be fused with: fuse "typescratch_packages/<repo-name>/main.tysh"
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import shutil
16
+ import subprocess
17
+ from typing import Optional
18
+
19
+
20
+ PACKAGES_DIR = "typescratch_packages"
21
+
22
+
23
+ def install(repo: str) -> int:
24
+ """Install a package from GitHub.
25
+
26
+ repo can be:
27
+ user/repo
28
+ user/repo@tag
29
+ https://github.com/user/repo
30
+ """
31
+ # Parse the repo spec
32
+ tag = None
33
+ if "@" in repo:
34
+ repo, tag = repo.rsplit("@", 1)
35
+
36
+ # Clean up URL form
37
+ if repo.startswith("https://github.com/"):
38
+ repo = repo[len("https://github.com/"):]
39
+
40
+ # Build the clone URL
41
+ clone_url = f"https://github.com/{repo}.git"
42
+
43
+ # Extract repo name
44
+ repo_name = repo.split("/")[-1] if "/" in repo else repo
45
+
46
+ # Create packages dir
47
+ if not os.path.exists(PACKAGES_DIR):
48
+ os.makedirs(PACKAGES_DIR)
49
+
50
+ dest = os.path.join(PACKAGES_DIR, repo_name)
51
+
52
+ # If already exists, remove it
53
+ if os.path.exists(dest):
54
+ print(f"Package {repo_name} already installed, updating...")
55
+ shutil.rmtree(dest)
56
+
57
+ # Clone
58
+ print(f"Installing {repo_name} from {clone_url}...")
59
+ cmd = ["git", "clone", "--depth", "1"]
60
+ if tag:
61
+ cmd.extend(["--branch", tag])
62
+ cmd.extend([clone_url, dest])
63
+
64
+ try:
65
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
66
+ if result.returncode != 0:
67
+ print(f"Error: git clone failed: {result.stderr}", file=sys.stderr)
68
+ return 1
69
+ except FileNotFoundError:
70
+ print("Error: git is not installed. Install git first.", file=sys.stderr)
71
+ return 1
72
+ except subprocess.TimeoutExpired:
73
+ print("Error: git clone timed out.", file=sys.stderr)
74
+ return 1
75
+
76
+ # Remove .git directory to save space
77
+ git_dir = os.path.join(dest, ".git")
78
+ if os.path.exists(git_dir):
79
+ shutil.rmtree(git_dir)
80
+
81
+ print(f"Installed {repo_name} to {dest}/")
82
+
83
+ # Check for a main.tysh file
84
+ main_file = os.path.join(dest, "main.tysh")
85
+ if os.path.isfile(main_file):
86
+ print(f"\nTo use this package, add this to your .tysh file:")
87
+ print(f' fuse "{PACKAGES_DIR}/{repo_name}/main.tysh"')
88
+ else:
89
+ # List .tysh files
90
+ tysh_files = [f for f in os.listdir(dest) if f.endswith(".tysh")]
91
+ if tysh_files:
92
+ print(f"\nAvailable .tysh files:")
93
+ for f in tysh_files:
94
+ print(f' fuse "{PACKAGES_DIR}/{repo_name}/{f}"')
95
+ else:
96
+ print(f"\nNo .tysh files found in package.")
97
+
98
+ return 0
99
+
100
+
101
+ def uninstall(name: str) -> int:
102
+ """Remove an installed package."""
103
+ dest = os.path.join(PACKAGES_DIR, name)
104
+ if not os.path.exists(dest):
105
+ print(f"Package {name} is not installed.", file=sys.stderr)
106
+ return 1
107
+ shutil.rmtree(dest)
108
+ print(f"Uninstalled {name}")
109
+ return 0
110
+
111
+
112
+ def list_packages() -> int:
113
+ """List installed packages."""
114
+ if not os.path.exists(PACKAGES_DIR):
115
+ print("No packages installed.")
116
+ return 0
117
+ packages = [d for d in os.listdir(PACKAGES_DIR)
118
+ if os.path.isdir(os.path.join(PACKAGES_DIR, d))]
119
+ if not packages:
120
+ print("No packages installed.")
121
+ return 0
122
+ print(f"Installed packages ({len(packages)}):")
123
+ for p in sorted(packages):
124
+ dest = os.path.join(PACKAGES_DIR, p)
125
+ tysh_files = [f for f in os.listdir(dest) if f.endswith(".tysh")]
126
+ print(f" {p} ({len(tysh_files)} .tysh files)")
127
+ return 0
@@ -157,6 +157,22 @@ class Parser:
157
157
  self.skip_eol()
158
158
  continue
159
159
 
160
+ # Sound import: sound name from "path.wav"
161
+ if t.type == "SOUND_KW" and self._is_at_line_start():
162
+ if current_sprite is None:
163
+ self.err("sound import with no sprite context")
164
+ self._parse_sound_import(current_sprite)
165
+ self.skip_eol()
166
+ continue
167
+
168
+ # Costume import: costume name from "path.png"
169
+ if t.type == "COSTUME_KW" and self._is_at_line_start():
170
+ if current_sprite is None:
171
+ self.err("costume import with no sprite context")
172
+ self._parse_costume_import(current_sprite)
173
+ self.skip_eol()
174
+ continue
175
+
160
176
  self.err(f"unexpected top-level token {t.type} ({t.value!r})")
161
177
 
162
178
  # Make sure there's always a stage
@@ -252,7 +268,6 @@ class Parser:
252
268
  break
253
269
  # Stop at top-level `s "..."` or `b "..."` (next sprite/backdrop)
254
270
  if t.type == "NAME" and t.value in ("s", "b") and self._is_at_line_start():
255
- # Peek ahead: is the next token a name/string? Then it's a declarator.
256
271
  if self.peek(1).type in ("STRING", "NAME", "TEXT"):
257
272
  break
258
273
  if t.type == "WHEN":
@@ -264,6 +279,14 @@ class Parser:
264
279
  if t.type == "NAME" and t.value == "list" and self._is_at_line_start():
265
280
  self._parse_list_decl(sprite)
266
281
  continue
282
+ # Sound import: sound name from "path.wav"
283
+ if t.type == "SOUND_KW" and self._is_at_line_start():
284
+ self._parse_sound_import(sprite)
285
+ continue
286
+ # Costume import: costume name from "path.png"
287
+ if t.type == "COSTUME_KW" and self._is_at_line_start():
288
+ self._parse_costume_import(sprite)
289
+ continue
267
290
  # otherwise: stop - we're at the next target / eof
268
291
  break
269
292
 
@@ -459,6 +482,42 @@ class Parser:
459
482
  self.eat("RBRACKET", "']'")
460
483
  return A.Hat(kind="greater_than", args=[what, val], line=line)
461
484
 
485
+ def _parse_ternary_value(self):
486
+ """Parse a single value for a ternary expression.
487
+
488
+ This is like _parse_atom but stops at 'else' (doesn't consume it
489
+ as part of a bare string).
490
+ """
491
+ t = self.peek()
492
+ if t.type == "ELSE":
493
+ self.err("expected a value before 'else' in ternary")
494
+ if t.type == "STRING":
495
+ self.next()
496
+ return A.StringLit(value=t.value, line=t.line)
497
+ if t.type == "NUM":
498
+ self.next()
499
+ val = float(t.value) if "." in t.value else int(t.value)
500
+ return A.NumberLit(value=val, line=t.line)
501
+ if t.type == "HEXCOLOR":
502
+ self.next()
503
+ return A.ColorLit(value=t.value, line=t.line)
504
+ if t.type == "LPAREN":
505
+ self.next()
506
+ self.skip_eol()
507
+ expr = self._parse_expr()
508
+ self.skip_eol()
509
+ self.eat("RPAREN", "')'")
510
+ return expr
511
+ if t.type in ("NAME", "TEXT"):
512
+ # Parse a single bare name or function call, but DON'T
513
+ # accumulate following words (since "else" could be next)
514
+ name = self.next().value
515
+ if self.at("LPAREN"):
516
+ args = self._parse_paren_args()
517
+ return A.FuncCall(name=name, args=args, line=t.line)
518
+ return A.BareRef(name=name, line=t.line)
519
+ self.err(f"unexpected token in ternary value: {t.type} ({t.value!r})")
520
+
462
521
  def _read_bracket_text(self) -> str:
463
522
  """Read text inside [ ] - can be multi-word. Returns the joined string.
464
523
 
@@ -542,10 +601,6 @@ class Parser:
542
601
  return A.StopStatement(kind=kind, line=line)
543
602
  if t.type == "BROADCAST":
544
603
  self.next()
545
- # Three forms:
546
- # broadcast [msg] (Scratch-style menu)
547
- # broadcast(msg) (function-style)
548
- # broadcast msg (bare)
549
604
  msg: Any
550
605
  if self.at("LBRACKET"):
551
606
  self.next()
@@ -559,7 +614,6 @@ class Parser:
559
614
  else:
560
615
  msg = self._parse_expr()
561
616
  wait = False
562
- # check for "and wait"
563
617
  if self.at("AND") or (self.at("NAME", "TEXT") and self.peek().value == "and"):
564
618
  self.next()
565
619
  if not (self.at("WAIT") or (self.at("NAME", "TEXT") and self.peek().value == "wait")):
@@ -567,6 +621,15 @@ class Parser:
567
621
  self.next()
568
622
  wait = True
569
623
  return A.BroadcastStatement(msg=msg, wait=wait, line=line)
624
+
625
+ # private x = value (local variable inside a custom block)
626
+ if t.type == "PRIVATE_KW":
627
+ self.next()
628
+ var_name = self._read_name_or_string()
629
+ self.eat("ASSIGN", "'='")
630
+ val = self._parse_expr()
631
+ self.current_vars.add(var_name)
632
+ return A.LocalVarDecl(name=var_name, value=val, line=line)
570
633
  if t.type == "DEF":
571
634
  # def at statement level (inside a script - unusual but allowed)
572
635
  # We need a sprite context; create a dummy def container
@@ -920,12 +983,28 @@ class Parser:
920
983
  # [expr] - used for menu choices, but also a grouping
921
984
  self.next()
922
985
  self.skip_eol()
986
+ # Check for ternary: [if (cond) x else y]
987
+ if self.at("IF"):
988
+ self.next()
989
+ cond = self._parse_expr()
990
+ # Parse true_expr - but stop at ELSE
991
+ # We need to parse a single atom for the true value
992
+ # because _parse_expr would consume "else" as a bare word
993
+ true_expr = self._parse_ternary_value()
994
+ # expect "else"
995
+ if not self.at("ELSE"):
996
+ self.err("expected 'else' in ternary [if (cond) x else y]")
997
+ self.next()
998
+ false_expr = self._parse_ternary_value()
999
+ self.skip_eol()
1000
+ self.eat("RBRACKET", "']'")
1001
+ return A.TernaryExpr(cond=cond, true_expr=true_expr, false_expr=false_expr, line=t.line)
923
1002
  expr = self._parse_expr()
924
1003
  self.skip_eol()
925
1004
  self.eat("RBRACKET", "']'")
926
1005
  return expr
927
1006
 
928
- if t.type in ("NAME", "TEXT"):
1007
+ if t.type in ("NAME", "TEXT", "SOUND_KW", "COSTUME_KW", "PRIVATE_KW"):
929
1008
  # Accumulate consecutive bare words into a string.
930
1009
  # If the next token is LPAREN, this is a function call.
931
1010
  # If the next token is DOT, this is namespace access.
@@ -979,7 +1058,8 @@ class Parser:
979
1058
  "WAIT", "WAIT_UNTIL", "STOP", "BROADCAST", "IF", "ELSE", "ELIF",
980
1059
  "REPEAT", "REPEAT_UNTIL", "FOREVER", "WHILE", "WHEN",
981
1060
  "FOR_EACH", "ALL_AT_ONCE",
982
- "DEF", "TRUE", "FALSE", "EXTENSION")
1061
+ "DEF", "TRUE", "FALSE", "EXTENSION",
1062
+ "SOUND_KW", "COSTUME_KW", "PRIVATE_KW")
983
1063
  words = [name]
984
1064
  while self.at(*WORD_LIKE):
985
1065
  words.append(str(self.next().value))
@@ -1045,6 +1125,47 @@ class Parser:
1045
1125
  ))
1046
1126
 
1047
1127
  # ---- list declarations ----
1128
+ def _parse_sound_import(self, sprite: A.Target) -> None:
1129
+ """Parse: sound name from "path.wav" """
1130
+ self.next() # consume 'sound'
1131
+ name = self._read_name_or_string()
1132
+ # expect "from"
1133
+ if not (self.at("NAME", "TEXT") and self.peek().value == "from"):
1134
+ self.err("expected 'from' in: sound name from \"path.wav\"")
1135
+ self.next()
1136
+ path_tok = self.peek()
1137
+ if path_tok.type == "STRING":
1138
+ path = self.next().value
1139
+ elif path_tok.type == "PATH":
1140
+ path = self.next().value
1141
+ elif path_tok.type in ("NAME", "TEXT"):
1142
+ path = self.next().value
1143
+ else:
1144
+ self.err("expected file path after 'from'")
1145
+ if not hasattr(sprite, "_sound_imports"):
1146
+ sprite._sound_imports = []
1147
+ sprite._sound_imports.append({"name": name, "path": path})
1148
+
1149
+ def _parse_costume_import(self, sprite: A.Target) -> None:
1150
+ """Parse: costume name from "path.png" """
1151
+ self.next() # consume 'costume'
1152
+ name = self._read_name_or_string()
1153
+ if not (self.at("NAME", "TEXT") and self.peek().value == "from"):
1154
+ self.err("expected 'from' in: costume name from \"path.png\"")
1155
+ self.next()
1156
+ path_tok = self.peek()
1157
+ if path_tok.type == "STRING":
1158
+ path = self.next().value
1159
+ elif path_tok.type == "PATH":
1160
+ path = self.next().value
1161
+ elif path_tok.type in ("NAME", "TEXT"):
1162
+ path = self.next().value
1163
+ else:
1164
+ self.err("expected file path after 'from'")
1165
+ if not hasattr(sprite, "_costume_imports"):
1166
+ sprite._costume_imports = []
1167
+ sprite._costume_imports.append({"name": name, "path": path})
1168
+
1048
1169
  def _parse_list_decl(self, sprite: A.Target) -> None:
1049
1170
  # The "list" keyword has already been peeked but not consumed.
1050
1171
  list_tok = self.peek()
File without changes
File without changes
File without changes
File without changes