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.
Files changed (37) hide show
  1. pijl-0.1.0/LICENSE +21 -0
  2. pijl-0.1.0/PKG-INFO +63 -0
  3. pijl-0.1.0/README.md +39 -0
  4. pijl-0.1.0/pyproject.toml +50 -0
  5. pijl-0.1.0/pyproject.toml.orig +45 -0
  6. pijl-0.1.0/src/pijl/__init__.py +4 -0
  7. pijl-0.1.0/src/pijl/macros.py +129 -0
  8. pijl-0.1.0/src/pijl/main.py +9 -0
  9. pijl-0.1.0/src/pijl/parts/__init__.py +27 -0
  10. pijl-0.1.0/src/pijl/parts/contract.py +130 -0
  11. pijl-0.1.0/src/pijl/parts/ports.py +35 -0
  12. pijl-0.1.0/src/pijl/parts/registry.py +138 -0
  13. pijl-0.1.0/src/pijl/parts/templates/gates/and.py +9 -0
  14. pijl-0.1.0/src/pijl/parts/templates/gates/nand.py +9 -0
  15. pijl-0.1.0/src/pijl/parts/templates/gates/not.py +9 -0
  16. pijl-0.1.0/src/pijl/parts/templates/gates/or.py +9 -0
  17. pijl-0.1.0/src/pijl/project.py +113 -0
  18. pijl-0.1.0/src/pijl/sim/__init__.py +3 -0
  19. pijl-0.1.0/src/pijl/sim/circuit.py +497 -0
  20. pijl-0.1.0/src/pijl/snapshot.py +36 -0
  21. pijl-0.1.0/src/pijl/storage.py +315 -0
  22. pijl-0.1.0/src/pijl/ui/__init__.py +3 -0
  23. pijl-0.1.0/src/pijl/ui/camera.py +80 -0
  24. pijl-0.1.0/src/pijl/ui/document.py +181 -0
  25. pijl-0.1.0/src/pijl/ui/editor.py +1493 -0
  26. pijl-0.1.0/src/pijl/ui/grid.py +88 -0
  27. pijl-0.1.0/src/pijl/ui/library.py +127 -0
  28. pijl-0.1.0/src/pijl/ui/line_edit.py +36 -0
  29. pijl-0.1.0/src/pijl/ui/menu.py +179 -0
  30. pijl-0.1.0/src/pijl/ui/paint.py +147 -0
  31. pijl-0.1.0/src/pijl/ui/picker.py +696 -0
  32. pijl-0.1.0/src/pijl/ui/prompt.py +189 -0
  33. pijl-0.1.0/src/pijl/ui/sdf_text.py +310 -0
  34. pijl-0.1.0/src/pijl/ui/selection.py +59 -0
  35. pijl-0.1.0/src/pijl/ui/theme.py +117 -0
  36. pijl-0.1.0/src/pijl/ui/views.py +617 -0
  37. 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,4 @@
1
+ def main() -> None:
2
+ from .ui import run
3
+
4
+ run()
@@ -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,9 @@
1
+ from pijl.ui import run
2
+
3
+
4
+ def main():
5
+ run()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -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())