pijl 0.1.0__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.
- pijl-0.1.0/LICENSE +21 -0
- pijl-0.1.0/PKG-INFO +63 -0
- pijl-0.1.0/README.md +39 -0
- pijl-0.1.0/pyproject.toml +50 -0
- pijl-0.1.0/pyproject.toml.orig +45 -0
- pijl-0.1.0/src/pijl/__init__.py +4 -0
- pijl-0.1.0/src/pijl/macros.py +129 -0
- pijl-0.1.0/src/pijl/main.py +9 -0
- pijl-0.1.0/src/pijl/parts/__init__.py +27 -0
- pijl-0.1.0/src/pijl/parts/contract.py +130 -0
- pijl-0.1.0/src/pijl/parts/ports.py +35 -0
- pijl-0.1.0/src/pijl/parts/registry.py +138 -0
- pijl-0.1.0/src/pijl/parts/templates/gates/and.py +9 -0
- pijl-0.1.0/src/pijl/parts/templates/gates/nand.py +9 -0
- pijl-0.1.0/src/pijl/parts/templates/gates/not.py +9 -0
- pijl-0.1.0/src/pijl/parts/templates/gates/or.py +9 -0
- pijl-0.1.0/src/pijl/project.py +113 -0
- pijl-0.1.0/src/pijl/sim/__init__.py +3 -0
- pijl-0.1.0/src/pijl/sim/circuit.py +497 -0
- pijl-0.1.0/src/pijl/snapshot.py +36 -0
- pijl-0.1.0/src/pijl/storage.py +315 -0
- pijl-0.1.0/src/pijl/ui/__init__.py +3 -0
- pijl-0.1.0/src/pijl/ui/camera.py +80 -0
- pijl-0.1.0/src/pijl/ui/document.py +181 -0
- pijl-0.1.0/src/pijl/ui/editor.py +1493 -0
- pijl-0.1.0/src/pijl/ui/grid.py +88 -0
- pijl-0.1.0/src/pijl/ui/library.py +127 -0
- pijl-0.1.0/src/pijl/ui/line_edit.py +36 -0
- pijl-0.1.0/src/pijl/ui/menu.py +179 -0
- pijl-0.1.0/src/pijl/ui/paint.py +147 -0
- pijl-0.1.0/src/pijl/ui/picker.py +696 -0
- pijl-0.1.0/src/pijl/ui/prompt.py +189 -0
- pijl-0.1.0/src/pijl/ui/sdf_text.py +310 -0
- pijl-0.1.0/src/pijl/ui/selection.py +59 -0
- pijl-0.1.0/src/pijl/ui/theme.py +117 -0
- pijl-0.1.0/src/pijl/ui/views.py +617 -0
- pijl-0.1.0/src/pijl/ui/wire_edit.py +318 -0
pijl-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 moxxie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pijl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pijl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A visual logic circuit editor and simulator
|
|
5
|
+
Keywords: logic,circuit,simulator,digital,gates
|
|
6
|
+
Author: moxxie
|
|
7
|
+
Author-email: moxxie <moxxie@knolastna.me>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Education
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)
|
|
16
|
+
Requires-Dist: numpy>=2.5.3
|
|
17
|
+
Requires-Dist: pyglet>=2.1.16
|
|
18
|
+
Requires-Dist: scipy>=1.17.0
|
|
19
|
+
Requires-Python: >=3.13
|
|
20
|
+
Project-URL: Homepage, https://github.com/voxathon/pijl
|
|
21
|
+
Project-URL: Repository, https://github.com/voxathon/pijl
|
|
22
|
+
Project-URL: Issues, https://github.com/voxathon/pijl/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pijl
|
|
26
|
+
|
|
27
|
+
A visual logic circuit editor and simulator. Place gates, draw wires, and
|
|
28
|
+
package circuits into reusable macros.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv tool install pijl
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
or with pip:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install pijl
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Run
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pijl
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
On Windows, `pijl-gui` launches the editor without a console window.
|
|
49
|
+
|
|
50
|
+
Projects are saved under `%APPDATA%\pijl` on Windows (set `PIJL_DATA` to use a
|
|
51
|
+
different location).
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
uv sync
|
|
57
|
+
uv run pytest
|
|
58
|
+
uv run pijl
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
pijl-0.1.0/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# pijl
|
|
2
|
+
|
|
3
|
+
A visual logic circuit editor and simulator. Place gates, draw wires, and
|
|
4
|
+
package circuits into reusable macros.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
uv tool install pijl
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
or with pip:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install pijl
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Run
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pijl
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
On Windows, `pijl-gui` launches the editor without a console window.
|
|
25
|
+
|
|
26
|
+
Projects are saved under `%APPDATA%\pijl` on Windows (set `PIJL_DATA` to use a
|
|
27
|
+
different location).
|
|
28
|
+
|
|
29
|
+
## Development
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv sync
|
|
33
|
+
uv run pytest
|
|
34
|
+
uv run pijl
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pijl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A visual logic circuit editor and simulator"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"logic",
|
|
11
|
+
"circuit",
|
|
12
|
+
"simulator",
|
|
13
|
+
"digital",
|
|
14
|
+
"gates",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Education",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"numpy>=2.5.3",
|
|
26
|
+
"pyglet>=2.1.16",
|
|
27
|
+
"scipy>=1.17.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "moxxie"
|
|
32
|
+
email = "moxxie@knolastna.me"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/voxathon/pijl"
|
|
36
|
+
Repository = "https://github.com/voxathon/pijl"
|
|
37
|
+
Issues = "https://github.com/voxathon/pijl/issues"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
pijl = "pijl:main"
|
|
41
|
+
|
|
42
|
+
[project.gui-scripts]
|
|
43
|
+
pijl-gui = "pijl:main"
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["uv_build>=0.12.20,<0.13.0"]
|
|
47
|
+
build-backend = "uv_build"
|
|
48
|
+
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
dev = ["pytest>=9.1.1"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pijl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A visual logic circuit editor and simulator"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "moxxie", email = "moxxie@knolastna.me" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
keywords = ["logic", "circuit", "simulator", "digital", "gates"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Education",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"numpy>=2.5.3",
|
|
23
|
+
"pyglet>=2.1.16",
|
|
24
|
+
"scipy>=1.17.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/voxathon/pijl"
|
|
29
|
+
Repository = "https://github.com/voxathon/pijl"
|
|
30
|
+
Issues = "https://github.com/voxathon/pijl/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
pijl = "pijl:main"
|
|
34
|
+
|
|
35
|
+
[project.gui-scripts]
|
|
36
|
+
pijl-gui = "pijl:main"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.12.20,<0.13.0"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=9.1.1",
|
|
45
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Macros as part types: what lets a saved board be placed on another board.
|
|
2
|
+
|
|
3
|
+
A MacroType is built from a macro's saved board (its body). Its pins are the
|
|
4
|
+
body's IN / OUT ports, top to bottom by position (ties: left to right, then
|
|
5
|
+
uid). A pin is *identified* by its port's uid (that's what save files use, so
|
|
6
|
+
moving ports around inside a macro never rewires the boards using it); its
|
|
7
|
+
index only says where it's drawn. A port's label is the pin's name; unlabeled
|
|
8
|
+
ones are numbered by position.
|
|
9
|
+
|
|
10
|
+
The circuit never evaluates a macro: it expands the body into hidden parts and
|
|
11
|
+
joins the nets through the ports (see Circuit._expand). So a macro costs exactly
|
|
12
|
+
what its gates cost and adds no delay.
|
|
13
|
+
|
|
14
|
+
MacroBook loads definitions on demand and caches them. A macro that (directly or
|
|
15
|
+
through others) contains itself can't load; it shows up as a missing kind.
|
|
16
|
+
Catalog is what the circuit and the file format look kinds up in: part scripts
|
|
17
|
+
plus macros.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
from .parts import Look, PartType, Registry
|
|
25
|
+
from .snapshot import MACRO, Snapshot
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CycleError(KeyError):
|
|
29
|
+
"""A macro that contains itself."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MacroType(PartType):
|
|
33
|
+
def __init__(self, name: str, body: Snapshot, catalog: Catalog) -> None:
|
|
34
|
+
self.name = name
|
|
35
|
+
self.kind = MACRO + name
|
|
36
|
+
self.title = name
|
|
37
|
+
self.body = body
|
|
38
|
+
self.look = Look(label="below", swatch="MACRO_SWATCH", body="MACRO_BODY", pin_labels=True)
|
|
39
|
+
# Every kind inside must exist (KeyError / CycleError otherwise): a body that
|
|
40
|
+
# can't be built fully isn't a macro that can be placed.
|
|
41
|
+
types = {uid: catalog.get(d[0]) for uid, d in body.parts.items()}
|
|
42
|
+
self.in_ids = _ports(body, types, "in")
|
|
43
|
+
self.out_ids = _ports(body, types, "out")
|
|
44
|
+
self.ins = _names(body, self.in_ids)
|
|
45
|
+
self.outs = _names(body, self.out_ids)
|
|
46
|
+
# the macros used directly in the body
|
|
47
|
+
self.uses = frozenset(d[0][len(MACRO):] for d in body.parts.values() if d[0].startswith(MACRO))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ports(body: Snapshot, types: dict[int, PartType], side: str) -> tuple[int, ...]:
|
|
51
|
+
"""Uids of the body's ports on one side, top to bottom (world y points up)."""
|
|
52
|
+
found = [(uid, d) for uid, d in body.parts.items() if types[uid].port == side]
|
|
53
|
+
found.sort(key=lambda item: (-item[1][3], item[1][2], item[0]))
|
|
54
|
+
return tuple(uid for uid, _ in found)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _names(body: Snapshot, ids: tuple[int, ...]) -> tuple[str, ...]:
|
|
58
|
+
return tuple(body.parts[uid][1] or str(i + 1) for i, uid in enumerate(ids))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MacroBook:
|
|
62
|
+
"""Macro definitions by name, loaded when first needed. `load(name)` gives a
|
|
63
|
+
macro's body Snapshot, or raises (KeyError / OSError / ValueError) if it can't."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, catalog: Catalog, load: Callable[[str], Snapshot]) -> None:
|
|
66
|
+
self.catalog = catalog
|
|
67
|
+
self.load = load
|
|
68
|
+
self._types: dict[str, MacroType] = {}
|
|
69
|
+
self._loading: list[str] = [] # names being loaded right now (a repeat means a cycle)
|
|
70
|
+
|
|
71
|
+
def get(self, name: str) -> MacroType:
|
|
72
|
+
t = self._types.get(name)
|
|
73
|
+
if t is not None:
|
|
74
|
+
return t
|
|
75
|
+
if name in self._loading:
|
|
76
|
+
raise CycleError(f"{name} contains itself ({' -> '.join(self._loading[self._loading.index(name):])} -> {name})")
|
|
77
|
+
self._loading.append(name)
|
|
78
|
+
try: # both steps can need other macros (nested ones), so both count as loading
|
|
79
|
+
try:
|
|
80
|
+
body = self.load(name)
|
|
81
|
+
except CycleError:
|
|
82
|
+
raise
|
|
83
|
+
except (KeyError, OSError, ValueError) as e:
|
|
84
|
+
raise KeyError(f"macro {name!r}: {e.args[0] if isinstance(e, KeyError) and e.args else e}") from None
|
|
85
|
+
t = MacroType(name, body, self.catalog)
|
|
86
|
+
finally:
|
|
87
|
+
self._loading.pop()
|
|
88
|
+
self._types[name] = t
|
|
89
|
+
return t
|
|
90
|
+
|
|
91
|
+
def forget(self) -> None:
|
|
92
|
+
"""Definitions changed on disk (a macro was saved): load them again when needed.
|
|
93
|
+
Parts already on the board keep the definition they were made from."""
|
|
94
|
+
self._types.clear()
|
|
95
|
+
|
|
96
|
+
def contains(self, outer: str, inner: str) -> bool:
|
|
97
|
+
"""Does macro `outer` have `inner` inside it, at any depth? (Unloadable: no.)"""
|
|
98
|
+
seen, todo = set(), [outer]
|
|
99
|
+
while todo:
|
|
100
|
+
name = todo.pop()
|
|
101
|
+
try:
|
|
102
|
+
uses = self.get(name).uses
|
|
103
|
+
except KeyError:
|
|
104
|
+
continue
|
|
105
|
+
if inner in uses:
|
|
106
|
+
return True
|
|
107
|
+
todo += [u for u in uses if u not in seen]
|
|
108
|
+
seen.update(uses)
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Catalog:
|
|
113
|
+
"""Every kind of part that exists: the part scripts' types plus macros ("macro:<name>")."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, registry: Registry, load: Callable[[str], Snapshot]) -> None:
|
|
116
|
+
self.registry = registry
|
|
117
|
+
self.book = MacroBook(self, load)
|
|
118
|
+
|
|
119
|
+
def get(self, kind: str) -> PartType:
|
|
120
|
+
if kind.startswith(MACRO):
|
|
121
|
+
return self.book.get(kind[len(MACRO):])
|
|
122
|
+
return self.registry.get(kind)
|
|
123
|
+
|
|
124
|
+
def __contains__(self, kind: str) -> bool:
|
|
125
|
+
try:
|
|
126
|
+
self.get(kind)
|
|
127
|
+
except KeyError:
|
|
128
|
+
return False
|
|
129
|
+
return True
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Part types: what kinds of parts exist and how each one behaves.
|
|
2
|
+
|
|
3
|
+
Everything except IN/OUT (see ports.py) comes from part scripts. The ones that
|
|
4
|
+
ship with pijl live in templates/ and are copied into every new project; the
|
|
5
|
+
project's copy is what gets loaded (see project.py). No pyglet in here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from functools import cache
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .contract import API, Ctx, Look, PartType, part
|
|
14
|
+
from .registry import Registry, fresh_props, load
|
|
15
|
+
|
|
16
|
+
TEMPLATES = Path(__file__).parent / "templates"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@cache
|
|
20
|
+
def builtin_registry() -> Registry:
|
|
21
|
+
"""The ports plus the shipped templates, loaded straight from the package.
|
|
22
|
+
For tests and anything else that has no project."""
|
|
23
|
+
return load(TEMPLATES)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["API", "Ctx", "Look", "PartType", "Registry", "TEMPLATES", "builtin_registry",
|
|
27
|
+
"fresh_props", "load", "part"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The part-script contract: what a part script provides, and what the engine promises it.
|
|
2
|
+
|
|
3
|
+
A part script is a .py file in a project's parts folder (see registry.py for how
|
|
4
|
+
they're found). It declares `API = 1` and a `register(reg)` function that calls
|
|
5
|
+
`reg.add(...)` with PartTypes -- as many as it likes:
|
|
6
|
+
|
|
7
|
+
from pijl.parts import part
|
|
8
|
+
|
|
9
|
+
API = 1
|
|
10
|
+
|
|
11
|
+
def register(reg):
|
|
12
|
+
reg.add(part("NAND", ins=("a", "b"), outs=("out",), eval=lambda a, b: ~(a & b)))
|
|
13
|
+
|
|
14
|
+
`part(...)` is shorthand for a pure PartType. Anything else subclasses PartType
|
|
15
|
+
and overrides whichever hooks it needs; every hook is optional.
|
|
16
|
+
|
|
17
|
+
What the engine promises:
|
|
18
|
+
- eval runs once per kind per step, on arrays: element i of every input array
|
|
19
|
+
(and of the result) belongs to ctx.parts[i]. Inputs are numpy bool arrays, so
|
|
20
|
+
use & | ^ ~, not `and`/`or`/`not`. Return a tuple with one value per output
|
|
21
|
+
pin (just the value if there's one pin); each value is an array or list with
|
|
22
|
+
one entry per instance, or a scalar, which is copied to every instance.
|
|
23
|
+
- Every hook runs on the main thread. Start your own threads if you need them;
|
|
24
|
+
just don't touch the UI from them.
|
|
25
|
+
- A hook that raises disables its kind (in that circuit) and reports the error.
|
|
26
|
+
Nothing else stops.
|
|
27
|
+
- open() runs when a real instance appears -- never for the ghosts that follow
|
|
28
|
+
the cursor before placement -- and every opened instance gets a close().
|
|
29
|
+
- Parts that aren't `pure` are never skipped, cached or reordered; only live
|
|
30
|
+
(opened) instances of them are evaluated.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from ..sim.circuit import Part
|
|
41
|
+
|
|
42
|
+
API = 1 # bump when this contract changes in a way old scripts can't follow
|
|
43
|
+
|
|
44
|
+
LABEL_SIDES = ("below", "left", "right")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Look:
|
|
49
|
+
"""How the editor draws a part. Colors are names from ui/theme.py."""
|
|
50
|
+
narrow: bool = False # IO-width body with smaller text
|
|
51
|
+
label: str = "below" # where the user's label goes: below, left, right
|
|
52
|
+
lit: tuple[str, str] | None = None # body (off, on) colors, following the part's first pin
|
|
53
|
+
swatch: str = "PART_BODY" # its color in the part picker
|
|
54
|
+
body: str = "PART_BODY" # body (fill, border) colors, unless `lit` says otherwise
|
|
55
|
+
pin_labels: bool = True # name tags next to the pins (Tab picks hidden / hover / always)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PartType:
|
|
59
|
+
"""One kind of part. Class attributes are the defaults; instances may set their own."""
|
|
60
|
+
kind: str = "" # stable id: save files refer to parts by it
|
|
61
|
+
title: str = "" # what the body says, if not the kind
|
|
62
|
+
ins: tuple[str, ...] = () # input pin names, top to bottom
|
|
63
|
+
outs: tuple[str, ...] = () # output pin names, top to bottom
|
|
64
|
+
props: dict[str, Any] = {} # per-instance settings (JSON values); each instance gets a copy
|
|
65
|
+
pure: bool = False # outputs depend only on inputs: the engine may optimize it
|
|
66
|
+
category: str = "" # default collection in the part picker ("" = loose)
|
|
67
|
+
look: Look = Look()
|
|
68
|
+
port: str | None = None # "in" / "out": macro ports. Engine-only, see ports.py
|
|
69
|
+
|
|
70
|
+
def eval(self, ctx: Ctx, *ins):
|
|
71
|
+
"""Every step: input arrays in, output arrays (or scalars) out."""
|
|
72
|
+
|
|
73
|
+
def open(self, part: Part) -> None:
|
|
74
|
+
"""A real instance appeared."""
|
|
75
|
+
|
|
76
|
+
def close(self, part: Part) -> None:
|
|
77
|
+
"""An opened instance is going away (deleted, undone, app closing)."""
|
|
78
|
+
|
|
79
|
+
def frame(self, ctx: Ctx) -> None:
|
|
80
|
+
"""Once per frame, outside the step loop, for the live instances."""
|
|
81
|
+
|
|
82
|
+
def click(self, part: Part) -> None:
|
|
83
|
+
"""The user clicked a placed instance. Overriding this makes the part clickable
|
|
84
|
+
(a click then no longer selects it)."""
|
|
85
|
+
|
|
86
|
+
def has(self, hook: str) -> bool:
|
|
87
|
+
"""Does this type override `hook`? Hooks it doesn't override are never called."""
|
|
88
|
+
return getattr(type(self), hook) is not getattr(PartType, hook)
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
return f"<PartType {self.kind}>"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Ctx:
|
|
96
|
+
"""What a hook sees: a batch of instances of one kind."""
|
|
97
|
+
parts: list[Part]
|
|
98
|
+
tick: int = 0 # simulation steps since the circuit was created
|
|
99
|
+
time: float = field(default_factory=time.monotonic)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def n(self) -> int:
|
|
103
|
+
return len(self.parts)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def props(self) -> list[dict[str, Any]]:
|
|
107
|
+
return [p.props for p in self.parts]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def state(self) -> list[dict[str, Any]]:
|
|
111
|
+
"""Per-instance scratch space, kept for as long as the instance lives."""
|
|
112
|
+
return [p.state for p in self.parts]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class FunctionPart(PartType):
|
|
116
|
+
"""A pure part whose eval is a plain function of its inputs. Made by part()."""
|
|
117
|
+
pure = True
|
|
118
|
+
|
|
119
|
+
def __init__(self, kind: str, ins, outs, fn: Callable, category: str, look: Look) -> None:
|
|
120
|
+
self.kind, self.ins, self.outs = kind, tuple(ins), tuple(outs)
|
|
121
|
+
self.fn, self.category, self.look = fn, category, look
|
|
122
|
+
|
|
123
|
+
def eval(self, ctx: Ctx, *ins):
|
|
124
|
+
return self.fn(*ins)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def part(kind: str, ins=(), outs=(), eval: Callable = None, *, category: str = "",
|
|
128
|
+
look: Look = Look()) -> PartType:
|
|
129
|
+
"""A pure part from a function: `eval` takes one array per input pin."""
|
|
130
|
+
return FunctionPart(kind, ins, outs, eval, category, look)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""IN and OUT: the two parts the engine itself defines.
|
|
2
|
+
|
|
3
|
+
Inside a macro they are its pins (the flattener removes them and joins the nets
|
|
4
|
+
on either side), so their meaning can't be up to a script. On the top-level
|
|
5
|
+
board they're a switch and an LED. No script may define a part with a `port`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .contract import Look, PartType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Switch(PartType):
|
|
14
|
+
kind = "IN"
|
|
15
|
+
outs = ("out",)
|
|
16
|
+
port = "in"
|
|
17
|
+
category = "I/O"
|
|
18
|
+
look = Look(narrow=True, label="left", lit=("SWITCH_OFF", "SWITCH_ON"), swatch="SWITCH_ON",
|
|
19
|
+
pin_labels=False) # one pin: a tag would only say "out"
|
|
20
|
+
|
|
21
|
+
def click(self, part) -> None:
|
|
22
|
+
# No eval: the output keeps whatever the user last set it to.
|
|
23
|
+
pin = part.outputs[0]
|
|
24
|
+
pin.state = not pin.state
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Led(PartType):
|
|
28
|
+
kind = "OUT"
|
|
29
|
+
ins = ("in",)
|
|
30
|
+
port = "out"
|
|
31
|
+
category = "I/O"
|
|
32
|
+
look = Look(narrow=True, label="right", lit=("LED_OFF", "LED_ON"), swatch="LED_ON", pin_labels=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
PORTS: tuple[PartType, ...] = (Switch(), Led())
|