pycodecad 1.0.0__py3-none-any.whl

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.
pycodecad/cli.py ADDED
@@ -0,0 +1,241 @@
1
+ """Command line: open the window, or check/render/export a script without it.
2
+
3
+ pycodecad file.py [--read-only] [--no-run] window on the file's folder, file.py runs (created if missing)
4
+ pycodecad folder/ window on a folder (its part runs: see workspace.find_main)
5
+ pycodecad examples [folder] copy the examples there once (default ./pycodecad-examples), open it
6
+ pycodecad check file.py run it, print the result as JSON
7
+ pycodecad render file.py out.png [--views iso | iso,front,top | window] [--size 800x600] [--frame N]
8
+ pycodecad export file.py out.stl [--profile bambu] also .3mf .step .glb .brep
9
+ pycodecad context file.py print the AI context
10
+
11
+ check, render and export take --set KEY=VALUE (repeatable) for the parameters of expose():
12
+ KEY is "param" or "function.param".
13
+
14
+ Ctrl+C (or SIGTERM/SIGHUP) stops the running script and exits with code 130.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import contextlib
20
+ import json
21
+ import os
22
+ import shutil
23
+ import signal
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ from . import __version__
28
+
29
+ COMMANDS = ("check", "render", "export", "context")
30
+
31
+
32
+ class Usage(Exception):
33
+ """A command line that cannot work: the message is printed and pycodecad exits with 1."""
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ argv = sys.argv[1:] if argv is None else argv
38
+ for stream in (sys.stdout, sys.stderr): # a console that is not UTF-8 (Windows): never fail to print
39
+ if hasattr(stream, "reconfigure"):
40
+ stream.reconfigure(errors="backslashreplace") # pyright: ignore[reportAttributeAccessIssue]
41
+ stops = [number for number in (signal.SIGTERM, getattr(signal, "SIGHUP", None)) if number is not None]
42
+ previous = {number: signal.signal(number, interrupt) for number in stops}
43
+ try:
44
+ from .cad import remove_stale_temp
45
+
46
+ remove_stale_temp()
47
+ if argv and argv[0] in COMMANDS:
48
+ return command(argv[0], argv[1:])
49
+ if argv and argv[0] == "examples":
50
+ return examples(argv[1:])
51
+ return window(argv)
52
+ except KeyboardInterrupt: # the run (if any) is already killed
53
+ print("pycodecad: interrupted", file=sys.stderr)
54
+ return 130
55
+ except (OSError, Usage) as exc:
56
+ from .sidecar import error_text
57
+
58
+ print(f"pycodecad: {error_text(exc) if isinstance(exc, OSError) else exc}", file=sys.stderr)
59
+ return 1
60
+ finally:
61
+ for number, handler in previous.items():
62
+ signal.signal(number, handler)
63
+
64
+
65
+ def interrupt(number, frame) -> None:
66
+ raise KeyboardInterrupt
67
+
68
+
69
+ def window(argv: list[str]) -> int:
70
+ parser = argparse.ArgumentParser(prog="pycodecad", description="Code-CAD window for build123d scripts. "
71
+ "Other commands: pycodecad {check,render,export,context,examples} --help")
72
+ parser.add_argument("file", help="script to run (created if it does not exist), or a folder")
73
+ parser.add_argument("--read-only", action="store_true", help="an order form: parameters, Run and Export, no "
74
+ "code; scripts are never written")
75
+ parser.add_argument("--no-run", action="store_true", help="do not run the script when the window opens")
76
+ parser.add_argument("--screenshot", metavar="PNG", help="save a picture of the window after the first run and quit")
77
+ parser.add_argument("--version", action="version", version=f"pycodecad {__version__}")
78
+ options = parser.parse_args(argv)
79
+ from .app import open_window
80
+
81
+ open_window(options.file, screenshot=options.screenshot, read_only=options.read_only, run=not options.no_run)
82
+ return 0
83
+
84
+
85
+ def examples(argv: list[str]) -> int:
86
+ parser = argparse.ArgumentParser(prog="pycodecad examples", description="Copy the examples into a folder the "
87
+ "first time, then open the window on it (an existing folder is opened as it is).")
88
+ parser.add_argument("folder", nargs="?", default="pycodecad-examples", help="default ./pycodecad-examples")
89
+ parser.add_argument("--no-run", action="store_true", help="do not run the script when the window opens")
90
+ options = parser.parse_args(argv)
91
+ target = Path(options.folder)
92
+ if not target.exists():
93
+ shutil.copytree(bundled_examples(), target, ignore=shutil.ignore_patterns("__pycache__", ".pycodecad"))
94
+ print(f"Copied the examples to {target.resolve()}")
95
+ from .app import open_window
96
+
97
+ open_window(str(target), run=not options.no_run)
98
+ return 0
99
+
100
+
101
+ def bundled_examples() -> Path:
102
+ """The examples inside the installed package, or next to the sources in a checkout."""
103
+ for folder in (Path(__file__).parent / "examples", Path(__file__).parents[2] / "examples"):
104
+ if folder.is_dir():
105
+ return folder
106
+ raise Usage("the examples are not installed with this copy of pycodecad")
107
+
108
+
109
+ def command(name: str, argv: list[str]) -> int:
110
+ parser = argparse.ArgumentParser(prog=f"pycodecad {name}", allow_abbrev=False)
111
+ parser.add_argument("file", help="the script")
112
+ if name == "render":
113
+ parser.add_argument("output", help="PNG file to write")
114
+ parser.add_argument("--views", default="iso", help="iso (default), front, back, left, right, top or "
115
+ "bottom; several make a labeled grid (iso,front,top,right); window is the camera of "
116
+ "the open window")
117
+ parser.add_argument("--size", help="WIDTHxHEIGHT of each view (default 800x600, or the window's)")
118
+ parser.add_argument("--frame", type=int, metavar="N", help="frame N (1, 2, ...) of an animation made "
119
+ "with frame(); by default the scene at the end of the script")
120
+ if name == "export":
121
+ parser.add_argument("output", help="file to write: .stl .3mf .step .glb or .brep")
122
+ from .files import PROFILES
123
+
124
+ parser.add_argument("--profile", choices=PROFILES, help="3MF flavor (default generic)")
125
+ if name != "context":
126
+ parser.add_argument("--set", action="append", default=[], type=setting, metavar="KEY=VALUE",
127
+ help="value of an exposed parameter (\"param\" or \"function.param\"); repeatable")
128
+ options = parser.parse_args(argv)
129
+ script = Path(options.file).resolve()
130
+ from .sidecar import read_script
131
+
132
+ picture = render_plan(script, options) if name == "render" else None
133
+ if name == "export":
134
+ check_export(options)
135
+ code = read_script(script)
136
+ from . import runner
137
+
138
+ export = str(Path(options.output).resolve()) if name == "export" else None
139
+ values = dict(getattr(options, "set", []))
140
+ run = runner.Run(code, str(script), export=export, profile=getattr(options, "profile", None) or "generic",
141
+ values=values)
142
+ try:
143
+ result = run.wait()
144
+ except KeyboardInterrupt:
145
+ run.kill()
146
+ with contextlib.suppress(TimeoutError):
147
+ run.wait(5) # its last-run file says "Stopped"
148
+ raise
149
+ if name == "check":
150
+ print(json.dumps(runner.report(str(script), result), indent=2))
151
+ return 1 if result.error else 0
152
+ if name == "context":
153
+ from .context import ai_context
154
+
155
+ print(ai_context(script, result))
156
+ return 0
157
+ if result.stdout and name == "export":
158
+ print(result.stdout, end="")
159
+ if result.error:
160
+ print(result.error, file=sys.stderr)
161
+ return 1
162
+ frame = getattr(options, "frame", None)
163
+ if frame is not None and not 1 <= frame <= len(result.frames):
164
+ raise Usage(f"--frame {frame}: the script made {len(result.frames)} frames (frame() calls)")
165
+ if not result.shown and not frame:
166
+ raise Usage("nothing shown: call show() in the script")
167
+ if name == "export":
168
+ print(f"Wrote {result.exported}")
169
+ return 0
170
+ assert picture is not None # name == "render": planned before running
171
+ return render(result.frames[frame - 1] if frame else result.shown, options.output, *picture)
172
+
173
+
174
+ def setting(text: str) -> tuple[str, str]:
175
+ """--set KEY=VALUE: the value stays a string, converted by expose() to the parameter's type."""
176
+ key, equals, value = text.partition("=")
177
+ if not equals or not key.strip():
178
+ raise argparse.ArgumentTypeError(f"must look like KEY=VALUE (width=80 or box.width=80), not {text!r}")
179
+ return key.strip(), value
180
+
181
+
182
+ def check_output(output: str) -> None:
183
+ folder = Path(output).resolve().parent
184
+ if not folder.is_dir():
185
+ raise Usage(f"the folder of {output} does not exist: {folder}")
186
+
187
+
188
+ def check_export(options) -> None:
189
+ from .files import FORMATS
190
+
191
+ kind = Path(options.output).suffix.lower().lstrip(".")
192
+ if kind not in FORMATS:
193
+ raise Usage(f"cannot export {options.output}: use one of .{', .'.join(FORMATS)}")
194
+ if options.profile and kind != "3mf":
195
+ raise Usage("--profile is only for .3mf files")
196
+ check_output(options.output)
197
+
198
+
199
+ def render_plan(script: Path, options) -> tuple[list, tuple[int, int]]:
200
+ """The views (names or the window's Camera) and the size of each, checked before running."""
201
+ from .camera import check_view
202
+ from .renderer import check_size
203
+ from .sidecar import load_camera
204
+
205
+ if Path(options.output).suffix.lower() != ".png":
206
+ raise Usage(f"render writes PNG pictures: {options.output} must end in .png")
207
+ check_output(options.output)
208
+ size = (800, 600)
209
+ if options.size:
210
+ try:
211
+ width, height = (int(value) for value in options.size.lower().split("x"))
212
+ size = (width, height)
213
+ check_size(size)
214
+ except ValueError:
215
+ raise Usage(f"--size must look like 800x600 (1 to 8192 pixels per side), not {options.size}") from None
216
+ views: list = options.views.split(",")
217
+ for view in views:
218
+ try:
219
+ check_view(view, "window")
220
+ except ValueError as exc:
221
+ raise Usage(str(exc)) from None
222
+ if "window" in views:
223
+ if len(views) > 1:
224
+ raise Usage("--views window shows the window camera alone: do not combine it with other views")
225
+ try:
226
+ camera, window_size = load_camera(script)
227
+ except (OSError, ValueError):
228
+ raise Usage(f"no window camera saved for {script.name} yet: open it with "
229
+ f"`pycodecad {script.name}` and move the view") from None
230
+ views = [camera]
231
+ if options.size is None:
232
+ size = window_size
233
+ return views, size
234
+
235
+
236
+ def render(shown: list, output: str, views: list, size: tuple[int, int]) -> int:
237
+ from .renderer import png_bytes, render_views
238
+
239
+ Path(output).write_bytes(png_bytes(render_views(shown, views, size)))
240
+ print(f"Wrote {Path(output).resolve()}")
241
+ return 0
pycodecad/context.py ADDED
@@ -0,0 +1,178 @@
1
+ """The "AI context": a text to paste into an AI assistant so it can build and iterate on the part.
2
+
3
+ Used by the "Copy AI context" button and by `pycodecad context file.py`.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import shlex
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from . import __version__
13
+
14
+ SKIP = {"__pycache__", "venv", ".venv", "node_modules"}
15
+ MAX_FILES = 60
16
+ WINDOWS = sys.platform == "win32"
17
+
18
+ GUIDE = """\
19
+ ## How pycodecad runs a script
20
+ - `{pycodecad} {file}` runs it in the window; `python {file}` runs it without one (show(), clear() and frame()
21
+ then do nothing).
22
+ - A part always starts from its own folder, so it works on any PC: keep the script, its helper
23
+ modules and its assets together in one folder and use relative paths, never absolute ones
24
+ (/home/...). With plain `python`, relative paths start from the current directory: for files
25
+ that must also work that way use `Path(__file__).parent / "name"`.
26
+ - Every run (window, check, render, export) writes its result to .pycodecad/{name}.last-run.json
27
+ next to the script (same JSON as `check`): read it to see the latest error.
28
+
29
+ ## Workflow
30
+ - Edit the file directly. The window runs the script once when it opens, then never reloads or
31
+ runs by itself: it shows "Changed on disk" and the owner presses Reload and Run.
32
+ - Check your work from a terminal in that folder (each command runs the script fresh, no window):
33
+ {pycodecad} check {file} # JSON: ok, error, where (file:line), traceback, stdout, objects
34
+ {pycodecad} render {file} out.png --views iso,front,top,right # then look at out.png
35
+ {pycodecad} render {file} out.png --views window # exactly what the owner sees
36
+ {pycodecad} render {file} out.png --set width=80 # other values for expose()
37
+ {pycodecad} export {file} {stl} # also .3mf .step .glb .brep; --profile bambu (Bambu Studio 3MF)
38
+ - Helper modules: write helper.py next to the script and `import helper` (fresh on every run).
39
+
40
+ ## pycodecad API (docs: https://github.com/offerrall/pycodecad/tree/v{version}/docs)
41
+ `from pycodecad import show, clear, frame, import_mesh, expose`
42
+ The script says what is in the scene with show(); the window, check, render and export all look
43
+ at that same scene. Nothing is shown without show().
44
+ - show(*objs, name=None, color=None): add shapes/builders/lists to the scene. color: "#RRGGBB",
45
+ a name ("red", "gold"...) or an RGB triple.
46
+ - clear(): empty the scene.
47
+ - frame(): save the scene now as the next frame of an animation (30/s; the window plays them).
48
+ Build parts once and move them with Pos/Rot per frame (not recomputed); hold = call frame() again.
49
+ `{pycodecad} render {file} out.png --frame N` draws frame N (1-based); check reports "frames".
50
+ - import_mesh(path, solid=False, max_faces=5000): STL/3MF as a fast mesh for show/export;
51
+ solid=True gives a build123d Solid for booleans (small closed meshes only).
52
+ - expose(fn): calls fn and returns its result; the window shows a control per parameter (Run
53
+ applies them). Only int/float/bool/str parameters with a default, annotated only with pytypehint
54
+ Min, Max, Step, Slider, Label, Description: `def box(width: Annotated[float, Min(20.0),
55
+ Max(200.0), Slider()] = 60.0)`, then `show(expose(box))`. Without the window the defaults are
56
+ used, or `--set width=80` (or `box.width=80`) on check/render/export.
57
+
58
+ ## build123d essentials (docs: https://build123d.readthedocs.io), units mm, Z up
59
+ ```python
60
+ from build123d import *
61
+ from pycodecad import show
62
+
63
+ # Algebra style: shapes are values; + union, - cut, & intersect; Pos/Rot place them.
64
+ plate = Box(60, 40, 5, align=(Align.CENTER, Align.CENTER, Align.MIN)) # sits on Z=0
65
+ plate -= Pos(20, 10, 0) * Cylinder(3, 20) # through hole
66
+ plate = fillet(plate.edges().filter_by(Axis.Z), radius=4)
67
+
68
+ # Builder style: 2D sketch on a plane, then extrude.
69
+ with BuildPart() as bracket:
70
+ with BuildSketch(Plane.XY):
71
+ RectangleRounded(40, 20, 3)
72
+ with Locations((-12, 0), (12, 0)):
73
+ Circle(2.5, mode=Mode.SUBTRACT)
74
+ extrude(amount=4)
75
+ chamfer(bracket.edges().group_by(Axis.Z)[-1], length=0.8) # top edges
76
+
77
+ show(Pos(0, 50, 0) * plate, name="plate", color="#F4B02A")
78
+ show(bracket, name="bracket")
79
+ ```
80
+ - Primitives are centered on their location by default; use `align=` to put a face on a plane.
81
+ - Edge/face selection: `.edges().filter_by(Axis.Z)`, `.faces().sort_by(Axis.Z)[-1]` (top face),
82
+ `.group_by(Axis.Z)[-1]`. Apply fillets/chamfers last; a radius too big for a wall fails.
83
+ - Other useful pieces: Text("ABC", font_size=8) in a BuildSketch, revolve(), loft(), sweep(),
84
+ PolarLocations(r, n), GridLocations(dx, dy, nx, ny), mirror(about=Plane.YZ), offset(), import_svg().
85
+ """
86
+
87
+
88
+ def quote(text: str) -> str:
89
+ """A literal argument for PowerShell on Windows, or a POSIX shell elsewhere."""
90
+ return "'" + text.replace("'", "''") + "'" if WINDOWS else shlex.quote(text)
91
+
92
+
93
+ def pycodecad_command() -> str:
94
+ """How to call this pycodecad from a terminal."""
95
+ script = Path(sys.executable).with_name("pycodecad.exe" if WINDOWS else "pycodecad")
96
+ if script.exists():
97
+ if shutil.which("pycodecad") == str(script):
98
+ return "pycodecad"
99
+ command = quote(str(script))
100
+ else:
101
+ command = f"{quote(sys.executable)} -m pycodecad"
102
+ return "& " + command if WINDOWS else command
103
+
104
+
105
+ def size_text(size: float) -> str:
106
+ if size < 1024:
107
+ return f"{size:.0f} B"
108
+ return f"{size / 1024:.1f} KB" if size < 1024**2 else f"{size / 1024**2:.1f} MB"
109
+
110
+
111
+ def folder_listing(folder: Path) -> list[str]:
112
+ """Files of the folder and its subfolders (two levels), with sizes."""
113
+ lines = []
114
+ for path in sorted(folder.glob("*")) + sorted(folder.glob("*/*")):
115
+ relative = path.relative_to(folder)
116
+ if any(part.startswith(".") or part in SKIP for part in relative.parts) or not path.is_file():
117
+ continue
118
+ lines.append(f" {relative.as_posix()} ({size_text(path.stat().st_size)})")
119
+ if len(lines) > MAX_FILES:
120
+ lines = lines[:MAX_FILES] + [f" ... and {len(lines) - MAX_FILES} more"]
121
+ return lines
122
+
123
+
124
+ def state_lines(state) -> list[str]:
125
+ """State: anything with shown, error, error_file, error_line, warnings and stdout (Workspace or runner.Result)."""
126
+ lines = []
127
+ for obj in state.shown:
128
+ bbox = obj.bbox()
129
+ size = " x ".join(f"{hi - lo:.4g}" for lo, hi in zip(*bbox)) if bbox else "empty"
130
+ position = ", ".join(f"{(lo + hi) / 2:.4g}" for lo, hi in zip(*bbox)) if bbox else "-"
131
+ volume = f", volume {obj.volume:.6g} mm³" if obj.volume is not None else ""
132
+ lines.append(f"- {obj.name}: size {size} mm, center ({position}){volume}")
133
+ if not lines:
134
+ lines.append("- no objects")
135
+ if state.error:
136
+ where = f" in {state.error_file}, line {state.error_line}" if state.error_file else ""
137
+ lines.append(f"Error{where}:\n```\n{state.error.strip()[-2000:]}\n```")
138
+ for warning in state.warnings:
139
+ lines.append(f"Warning: {warning}")
140
+ if state.stdout.strip():
141
+ lines.append(f"Script output:\n```\n{state.stdout.strip()[-1500:]}\n```")
142
+ return lines
143
+
144
+
145
+ def parameter_lines(exposed) -> list[str]:
146
+ """The parameters of the exposed functions (params.Exposed) with the values the run used."""
147
+ lines = []
148
+ for group in exposed:
149
+ lines.append(f"- {group.function}():")
150
+ for param in group.params:
151
+ limits = "length " if param.kind == "str" and (param.min is not None or param.max is not None) else ""
152
+ if param.min is not None or param.max is not None:
153
+ low = "" if param.min is None else f"{param.min:g}"
154
+ high = "" if param.max is None else f"{param.max:g}"
155
+ limits += f"{low}..{high}"
156
+ extras = [param.kind, limits, param.step is not None and f"step {param.step:g}",
157
+ param.slider and "slider", param.label and f"label {param.label!r}",
158
+ param.value != param.default and f"default {param.default!r}"]
159
+ text = f" - {param.name} = {param.value!r} ({', '.join(filter(None, extras))})"
160
+ lines.append(text + (f": {param.description}" if param.description else ""))
161
+ return lines
162
+
163
+
164
+ def ai_context(path: Path, state=None) -> str:
165
+ """The whole text. path: the script; state: see state_lines."""
166
+ head = ["# pycodecad: code-CAD with build123d",
167
+ f"A pycodecad window shows in 3D what `{path.name}` builds.",
168
+ f"Folder: {path.parent}", f"File: {path}", "Files in the folder:", *folder_listing(path.parent)]
169
+ if WINDOWS:
170
+ head += ["", "Run the commands below in PowerShell."]
171
+ if state is not None:
172
+ head += ["", "## Current state", *state_lines(state)]
173
+ exposed = getattr(state, "parameters", None) or []
174
+ if exposed:
175
+ head += ["", "## Exposed parameters (values of the last run)", *parameter_lines(exposed)]
176
+ commands = dict(file=quote(path.name), name=path.name, stl=quote(path.stem + ".stl"),
177
+ pycodecad=pycodecad_command(), version=__version__)
178
+ return "\n".join(head) + "\n\n" + GUIDE.format(**commands)
pycodecad/editor.py ADDED
@@ -0,0 +1,256 @@
1
+ """The code editor widget: Python highlighting and keyboard/mouse editing, drawn with ImGui."""
2
+ from __future__ import annotations
3
+
4
+ import builtins
5
+ import io
6
+ import keyword
7
+ import tokenize
8
+ from dataclasses import replace
9
+ from functools import lru_cache
10
+
11
+ import glfw
12
+ from slimgui import imgui
13
+
14
+ from . import textedit
15
+ from .imgui_backend import clipboard_text
16
+ from .textedit import Editor
17
+
18
+ COLORS = {
19
+ "text": (0.84, 0.87, 0.91, 1.0),
20
+ "keyword": (0.79, 0.57, 0.95, 1.0),
21
+ "builtin": (0.42, 0.77, 0.96, 1.0),
22
+ "string": (0.66, 0.82, 0.52, 1.0),
23
+ "number": (0.96, 0.71, 0.42, 1.0),
24
+ "comment": (0.48, 0.59, 0.65, 1.0),
25
+ "decorator": (0.95, 0.78, 0.42, 1.0),
26
+ "definition": (0.98, 0.85, 0.56, 1.0),
27
+ "cad": (0.32, 0.84, 0.75, 1.0),
28
+ }
29
+ CAD_NAMES = frozenset("""
30
+ Box Cylinder Sphere Cone Torus Wedge BuildPart BuildSketch BuildLine
31
+ Part Sketch Solid Face Wire Edge Vertex Compound Location Locations
32
+ PolarLocations GridLocations HexLocations Plane Axis Vector Color
33
+ Align Mode GeomType Rectangle RectangleRounded Circle Ellipse Polygon
34
+ RegularPolygon Polyline Line Spline Bezier RadiusArc CenterArc ThreePointArc
35
+ fillet chamfer extrude revolve loft sweep offset mirror scale split
36
+ make_face make_hull add subtract intersect Pos Rot show clear import_mesh render
37
+ """.split())
38
+ BUILTINS = frozenset(dir(builtins))
39
+ SELECTION = (0.25, 0.48, 0.78, 0.45)
40
+ CURRENT_LINE = (0.8, 0.85, 1.0, 0.045)
41
+ ERROR_LINE = (0.8, 0.2, 0.2, 0.23)
42
+
43
+
44
+ @lru_cache(maxsize=16)
45
+ def highlight(text: str) -> tuple[tuple[tuple[str, str], ...], ...]:
46
+ """Per line, (text, kind) spans covering the whole line; works on incomplete code too."""
47
+ lines = text.split("\n")
48
+ kinds = [["text"] * len(line) for line in lines]
49
+
50
+ def paint(start: tuple[int, int], end: tuple[int, int], kind: str) -> None:
51
+ for row in range(max(1, start[0]), min(len(lines), end[0]) + 1):
52
+ lo = start[1] if row == start[0] else 0
53
+ hi = min(end[1] if row == end[0] else len(lines[row - 1]), len(lines[row - 1]))
54
+ kinds[row - 1][lo:hi] = [kind] * max(0, hi - lo)
55
+
56
+ definition = decorator = False
57
+ try:
58
+ for token in tokenize.generate_tokens(io.StringIO(text).readline):
59
+ kind = "text"
60
+ if token.type == tokenize.NAME:
61
+ if definition:
62
+ kind, definition = "definition", False
63
+ elif keyword.iskeyword(token.string) or keyword.issoftkeyword(token.string):
64
+ kind, definition = "keyword", token.string in ("class", "def")
65
+ elif decorator:
66
+ kind = "decorator"
67
+ elif token.string in CAD_NAMES:
68
+ kind = "cad"
69
+ elif token.string in BUILTINS:
70
+ kind = "builtin"
71
+ elif token.type == tokenize.STRING or tokenize.tok_name[token.type].startswith("FSTRING"):
72
+ kind = "string"
73
+ elif token.type == tokenize.NUMBER:
74
+ kind = "number"
75
+ elif token.type == tokenize.COMMENT:
76
+ kind = "comment"
77
+ elif token.string == "@" and not lines[token.start[0] - 1][:token.start[1]].strip():
78
+ decorator, kind = True, "decorator"
79
+ if token.type in (tokenize.NEWLINE, tokenize.NL) or token.string == "(":
80
+ decorator = False
81
+ paint(token.start, token.end, kind)
82
+ except (tokenize.TokenError, SyntaxError) as exc:
83
+ if isinstance(exc, tokenize.TokenError) and "multi-line string" in exc.args[0]:
84
+ paint(exc.args[1], (len(lines), len(lines[-1])), "string")
85
+ result = []
86
+ for line, styles in zip(lines, kinds):
87
+ spans = []
88
+ start = 0
89
+ while start < len(line):
90
+ end = start + 1
91
+ while end < len(line) and styles[end] == styles[start]:
92
+ end += 1
93
+ spans.append((line[start:end], styles[start]))
94
+ start = end
95
+ result.append(tuple(spans))
96
+ return tuple(result)
97
+
98
+
99
+ def _column(line: str, x: float, char_width: float) -> int:
100
+ """Character index under a horizontal pixel offset (tabs are 4 columns wide)."""
101
+ target = max(0.0, x / char_width)
102
+ visual = 0
103
+ for index, char in enumerate(line):
104
+ following = visual + (4 - visual % 4 if char == "\t" else 1)
105
+ if target < (visual + following) / 2:
106
+ return index
107
+ visual = following
108
+ return len(line)
109
+
110
+
111
+ MOVES = { # key -> (textedit key, with Ctrl)
112
+ glfw.KEY_LEFT: ("left", "word_left"), glfw.KEY_RIGHT: ("right", "word_right"),
113
+ glfw.KEY_UP: ("up", "up"), glfw.KEY_DOWN: ("down", "down"),
114
+ glfw.KEY_HOME: ("home", "doc_start"), glfw.KEY_END: ("end", "doc_end"),
115
+ glfw.KEY_PAGE_UP: ("page_up", "page_up"), glfw.KEY_PAGE_DOWN: ("page_down", "page_down"),
116
+ glfw.KEY_BACKSPACE: ("backspace", "word_backspace"), glfw.KEY_DELETE: ("delete", "word_delete"),
117
+ }
118
+
119
+
120
+ def copy_text(text: str) -> None:
121
+ """Put text on the system clipboard."""
122
+ glfw.set_clipboard_string(None, text) # pyright: ignore[reportArgumentType] # GLFW takes None; its stub does not
123
+
124
+
125
+ def _keyboard(editor: Editor, events: list, page_lines: int, focused: bool = True) -> Editor:
126
+ """Apply typed text and editing keys (only when focused) and call the window's actions (see
127
+ ui.shortcuts), all in the order they arrived."""
128
+ for event in events:
129
+ if callable(event):
130
+ event(editor)
131
+ elif focused and isinstance(event, str):
132
+ editor = textedit.insert(editor, event, typing=True)
133
+ if callable(event) or not focused or isinstance(event, str):
134
+ continue
135
+ key, mods, repeat = event
136
+ ctrl, shift = bool(mods & glfw.MOD_CONTROL), bool(mods & glfw.MOD_SHIFT)
137
+ if key in MOVES:
138
+ editor = textedit.key(editor, MOVES[key][ctrl], shift, page_lines)
139
+ elif key == glfw.KEY_TAB:
140
+ editor = textedit.key(editor, "untab" if shift else "tab")
141
+ elif key in (glfw.KEY_ENTER, glfw.KEY_KP_ENTER) and not ctrl:
142
+ editor = textedit.key(editor, "enter")
143
+ elif not ctrl:
144
+ continue
145
+ elif key == glfw.KEY_Z:
146
+ editor = textedit.redo(editor) if shift else textedit.undo(editor)
147
+ elif key == glfw.KEY_Y:
148
+ editor = textedit.redo(editor)
149
+ elif key == glfw.KEY_V:
150
+ editor = textedit.insert(editor, clipboard_text())
151
+ elif repeat:
152
+ continue
153
+ elif key == glfw.KEY_A:
154
+ editor = textedit.key(editor, "select_all")
155
+ elif key == glfw.KEY_C:
156
+ lo, hi = textedit.copy_range(editor)
157
+ copy_text(editor.text[lo:hi])
158
+ elif key == glfw.KEY_X:
159
+ editor, text = textedit.cut(editor)
160
+ copy_text(text)
161
+ return editor
162
+
163
+
164
+ def view(editor: Editor, size: tuple[float, float], events: list, error_line: int | None,
165
+ wheel_scroll: bool = True) -> Editor:
166
+ """Draw the visible lines and handle input (when focused). Returns the (maybe) edited editor.
167
+ wheel_scroll False: the mouse wheel does not scroll (Ctrl+wheel zooms instead)."""
168
+ flags = imgui.WindowFlags.HORIZONTAL_SCROLLBAR
169
+ if not wheel_scroll:
170
+ flags |= imgui.WindowFlags.NO_SCROLL_WITH_MOUSE
171
+ visible = imgui.begin_child("editor", size, imgui.ChildFlags.NONE, flags)
172
+ try:
173
+ if not visible:
174
+ return _keyboard(editor, events, 1, focused=False)
175
+ lines = editor.text.split("\n")
176
+ spans = highlight(editor.text)
177
+ height = imgui.get_text_line_height_with_spacing()
178
+ char_width = imgui.calc_text_size("M" * 64)[0] / 64 # one character is rounded up; zoomed sizes are fractional
179
+ gutter = char_width * (len(str(len(lines))) + 2)
180
+ origin = imgui.get_cursor_screen_pos()
181
+ available = imgui.get_content_region_avail()
182
+ longest = max(len(line.expandtabs(4)) for line in lines)
183
+ content_width = max(gutter + (longest + 2) * char_width, available[0])
184
+ imgui.invisible_button("##source", (content_width, max(len(lines) * height, available[1])))
185
+ hovered, active = imgui.is_item_hovered(), imgui.is_item_active()
186
+ io_state = imgui.get_io()
187
+
188
+ # Mouse: click places the cursor, drag selects (scrolling at the borders), 2/3 clicks select word/line.
189
+ clicked = hovered and imgui.is_mouse_clicked(imgui.MouseButton.LEFT)
190
+ dragging = active and imgui.is_mouse_dragging(imgui.MouseButton.LEFT)
191
+ if clicked or dragging:
192
+ imgui.set_window_focus()
193
+ x, y = io_state.mouse_pos
194
+ row = max(0, min(len(lines) - 1, int((y - origin[1]) / height)))
195
+ column = _column(lines[row], x - origin[0] - gutter, char_width)
196
+ clicks = max(1, imgui.get_mouse_clicked_count(imgui.MouseButton.LEFT)) if clicked else 1
197
+ editor = textedit.click(editor, row, column, extend=io_state.key_shift or not clicked, clicks=clicks)
198
+ if dragging:
199
+ (left, top), (width, window_height) = imgui.get_window_pos(), imgui.get_window_size()
200
+ if not top <= y <= top + window_height:
201
+ imgui.set_scroll_y(max(0.0, imgui.get_scroll_y() + (y - top if y < top else y - top - window_height)))
202
+ if not left <= x <= left + width:
203
+ imgui.set_scroll_x(max(0.0, imgui.get_scroll_x() + (x - left if x < left else x - left - width)))
204
+ if hovered:
205
+ imgui.set_mouse_cursor(imgui.MouseCursor.TEXT_INPUT)
206
+
207
+ focused = imgui.is_window_focused()
208
+ editor = _keyboard(editor, events, max(1, int(size[1] / height) - 1), focused)
209
+ if editor.text != "\n".join(lines):
210
+ lines = editor.text.split("\n")
211
+ spans = highlight(editor.text)
212
+
213
+ row, col = textedit.line_col(editor.text, editor.cursor)
214
+ cursor_x = gutter + len(lines[row][:col].expandtabs(4)) * char_width
215
+ cursor_y = row * height
216
+ if editor.reveal: # scroll the cursor into view once
217
+ scroll_x, scroll_y = imgui.get_scroll_x(), imgui.get_scroll_y()
218
+ window_w, window_h = imgui.get_window_size()
219
+ if cursor_y < scroll_y or cursor_y + 2 * height > scroll_y + window_h:
220
+ imgui.set_scroll_y(max(0.0, cursor_y - window_h / 2))
221
+ if cursor_x < scroll_x or cursor_x + 3 * char_width > scroll_x + window_w:
222
+ imgui.set_scroll_x(max(0.0, cursor_x - window_w / 2))
223
+ editor = replace(editor, reveal=False)
224
+
225
+ draw = imgui.get_window_draw_list()
226
+ color = imgui.get_color_u32
227
+ lo, hi = textedit.selection(editor)
228
+ first = max(0, int(imgui.get_scroll_y() / height) - 1)
229
+ position = sum(len(line) + 1 for line in lines[:first])
230
+ for index in range(first, min(len(lines), first + int(size[1] / height) + 3)):
231
+ line, y = lines[index], origin[1] + index * height
232
+ right = origin[0] + content_width
233
+ if index == row:
234
+ draw.add_rect_filled((origin[0], y), (right, y + height), color(CURRENT_LINE))
235
+ if index + 1 == error_line:
236
+ draw.add_rect_filled((origin[0], y), (right, y + height), color(ERROR_LINE))
237
+ if lo < hi and hi > position and lo <= position + len(line):
238
+ a = len(line[:max(0, lo - position)].expandtabs(4))
239
+ b = len(line[:max(0, hi - position)].expandtabs(4)) + (hi > position + len(line))
240
+ draw.add_rect_filled((origin[0] + gutter + a * char_width, y),
241
+ (origin[0] + gutter + b * char_width, y + height), color(SELECTION))
242
+ number = str(index + 1)
243
+ draw.add_text((origin[0] + gutter - (len(number) + 1) * char_width, y), color(COLORS["comment"]), number)
244
+ prefix = ""
245
+ for text, kind in spans[index]:
246
+ before = len(prefix.expandtabs(4))
247
+ prefix += text
248
+ draw.add_text((origin[0] + gutter + before * char_width, y), color(COLORS[kind]),
249
+ prefix.expandtabs(4)[before:])
250
+ position += len(line) + 1
251
+ if focused and imgui.get_time() % 1.1 < 0.7: # blinking cursor
252
+ x, y = origin[0] + cursor_x, origin[1] + cursor_y
253
+ draw.add_line((x, y), (x, y + height - 2.0), color(COLORS["text"]), 1.0)
254
+ return editor
255
+ finally:
256
+ imgui.end_child()