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/__init__.py +18 -0
- pycodecad/__main__.py +3 -0
- pycodecad/api.py +105 -0
- pycodecad/app.py +34 -0
- pycodecad/cad.py +261 -0
- pycodecad/camera.py +136 -0
- pycodecad/cli.py +241 -0
- pycodecad/context.py +178 -0
- pycodecad/editor.py +256 -0
- pycodecad/embed.py +16 -0
- pycodecad/examples/assembly.py +7 -0
- pycodecad/examples/assets/logo.svg +1 -0
- pycodecad/examples/assets/pyramid.stl +44 -0
- pycodecad/examples/embedded_app.py +77 -0
- pycodecad/examples/gear.py +6 -0
- pycodecad/examples/gearbox.py +30 -0
- pycodecad/examples/gears_turning.py +11 -0
- pycodecad/examples/import_files.py +13 -0
- pycodecad/examples/parts.py +46 -0
- pycodecad/examples/tray.py +28 -0
- pycodecad/files.py +262 -0
- pycodecad/icons/LICENSE +43 -0
- pycodecad/icons/__init__.py +31 -0
- pycodecad/icons/lucide.ttf +0 -0
- pycodecad/imgui_backend.py +269 -0
- pycodecad/params.py +177 -0
- pycodecad/renderer.py +327 -0
- pycodecad/runner.py +404 -0
- pycodecad/sidecar.py +86 -0
- pycodecad/textedit.py +290 -0
- pycodecad/ui.py +677 -0
- pycodecad/viewcube.py +120 -0
- pycodecad/viewer.py +70 -0
- pycodecad/window.py +129 -0
- pycodecad/workspace.py +545 -0
- pycodecad-1.0.0.dist-info/METADATA +117 -0
- pycodecad-1.0.0.dist-info/RECORD +40 -0
- pycodecad-1.0.0.dist-info/WHEEL +4 -0
- pycodecad-1.0.0.dist-info/entry_points.txt +2 -0
- pycodecad-1.0.0.dist-info/licenses/LICENSE +21 -0
pycodecad/textedit.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Pure text editing: every function takes an Editor and returns a new one.
|
|
2
|
+
|
|
3
|
+
Positions are character offsets into the text; lines and columns are zero based.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
from pytypehint import Min, immutable
|
|
11
|
+
|
|
12
|
+
HISTORY = 200 # undo steps kept
|
|
13
|
+
|
|
14
|
+
Position = Annotated[int, Min(0)]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@immutable
|
|
18
|
+
class Snapshot:
|
|
19
|
+
"""A point in the undo history: a text and a cursor within it."""
|
|
20
|
+
|
|
21
|
+
text: str
|
|
22
|
+
cursor: Position
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
if self.cursor > len(self.text):
|
|
26
|
+
raise ValueError(f"snapshot cursor {self.cursor} past the end of its text ({len(self.text)})")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@immutable
|
|
30
|
+
class Editor:
|
|
31
|
+
"""Text, selection (from `anchor` to `cursor`) and undo/redo history of snapshots.
|
|
32
|
+
`reveal` asks the view to scroll the cursor into sight once.
|
|
33
|
+
Every position lies within its text, so an editor can never point past the end."""
|
|
34
|
+
|
|
35
|
+
text: str = ""
|
|
36
|
+
cursor: Position = 0
|
|
37
|
+
anchor: Position = 0
|
|
38
|
+
undo: tuple[Snapshot, ...] = ()
|
|
39
|
+
redo: tuple[Snapshot, ...] = ()
|
|
40
|
+
reveal: bool = False
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
if self.cursor > len(self.text) or self.anchor > len(self.text):
|
|
44
|
+
raise ValueError(f"cursor {self.cursor} / anchor {self.anchor} past the end of the text ({len(self.text)})")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def line_col(text: str, offset: int) -> tuple[int, int]:
|
|
48
|
+
offset = max(0, min(offset, len(text)))
|
|
49
|
+
return text.count("\n", 0, offset), offset - text.rfind("\n", 0, offset) - 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def offset(text: str, line: int, col: int) -> int:
|
|
53
|
+
lines = text.split("\n")
|
|
54
|
+
row = max(0, min(line, len(lines) - 1))
|
|
55
|
+
return sum(len(value) + 1 for value in lines[:row]) + max(0, min(col, len(lines[row])))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load(text: str) -> Editor:
|
|
59
|
+
"""A new document: cursor at the start, no history."""
|
|
60
|
+
return Editor(text=text, reveal=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def selection(editor: Editor) -> tuple[int, int]:
|
|
64
|
+
return min(editor.cursor, editor.anchor), max(editor.cursor, editor.anchor)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- changing the text ---------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _snapshot(editor: Editor) -> Snapshot:
|
|
70
|
+
return Snapshot(text=editor.text, cursor=editor.cursor)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _checkpoint(editor: Editor) -> Editor:
|
|
74
|
+
"""End a typing group: the next change gets its own undo step."""
|
|
75
|
+
if editor.undo and editor.undo[-1].text != editor.text:
|
|
76
|
+
return replace(editor, undo=(editor.undo + (_snapshot(editor),))[-HISTORY:])
|
|
77
|
+
return editor
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def change(editor: Editor, text: str, cursor: int, anchor: int | None = None, typing: bool = False) -> Editor:
|
|
81
|
+
"""Replace the text, recording undo. Consecutive typed characters share one undo step."""
|
|
82
|
+
anchor = cursor if anchor is None else anchor
|
|
83
|
+
if text == editor.text:
|
|
84
|
+
return replace(editor, cursor=cursor, anchor=anchor, reveal=True)
|
|
85
|
+
history = editor.undo
|
|
86
|
+
grouped = False
|
|
87
|
+
if typing and history and not editor.redo and editor.cursor == editor.anchor:
|
|
88
|
+
before, before_cursor = history[-1].text, history[-1].cursor
|
|
89
|
+
grouped = (editor.cursor > before_cursor and editor.text[:before_cursor] == before[:before_cursor]
|
|
90
|
+
and editor.text[editor.cursor:] == before[before_cursor:]
|
|
91
|
+
and "\n" not in editor.text[before_cursor:editor.cursor])
|
|
92
|
+
if not grouped and (not history or history[-1] != _snapshot(editor)):
|
|
93
|
+
history += (_snapshot(editor),)
|
|
94
|
+
result = replace(editor, text=text, cursor=cursor, anchor=anchor, undo=history[-HISTORY:], redo=(), reveal=True)
|
|
95
|
+
return result if typing else _checkpoint(result)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def replace_all(editor: Editor, text: str) -> Editor:
|
|
99
|
+
"""Swap the whole text (reload from disk, paste code) as one undoable step, keeping the cursor."""
|
|
100
|
+
return change(editor, text, min(editor.cursor, len(text)), min(editor.anchor, len(text)))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def insert(editor: Editor, text: str, typing: bool = False) -> Editor:
|
|
104
|
+
"""Insert over the selection. typing=True groups characters into one undo step."""
|
|
105
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
106
|
+
if not text:
|
|
107
|
+
return editor
|
|
108
|
+
lo, hi = selection(editor)
|
|
109
|
+
return change(editor, editor.text[:lo] + text + editor.text[hi:], lo + len(text),
|
|
110
|
+
typing=typing and "\n" not in text)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cut(editor: Editor) -> tuple[Editor, str]:
|
|
114
|
+
"""Remove the selection (or the current line) and return it."""
|
|
115
|
+
lo, hi = copy_range(editor)
|
|
116
|
+
return change(_checkpoint(editor), editor.text[:lo] + editor.text[hi:], lo), editor.text[lo:hi]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def copy_range(editor: Editor) -> tuple[int, int]:
|
|
120
|
+
"""The selection, or the whole current line when nothing is selected."""
|
|
121
|
+
lo, hi = selection(editor)
|
|
122
|
+
if lo == hi:
|
|
123
|
+
lo = editor.text.rfind("\n", 0, lo) + 1
|
|
124
|
+
end = editor.text.find("\n", hi)
|
|
125
|
+
hi = len(editor.text) if end < 0 else end + 1
|
|
126
|
+
return lo, hi
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def undo(editor: Editor) -> Editor:
|
|
130
|
+
return _history(editor, forward=False)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def redo(editor: Editor) -> Editor:
|
|
134
|
+
return _history(editor, forward=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _history(editor: Editor, forward: bool) -> Editor:
|
|
138
|
+
source = editor.redo if forward else editor.undo
|
|
139
|
+
while source and source[-1].text == editor.text:
|
|
140
|
+
source = source[:-1]
|
|
141
|
+
if not source:
|
|
142
|
+
return editor
|
|
143
|
+
text, cursor = source[-1].text, source[-1].cursor
|
|
144
|
+
other = ((editor.undo if forward else editor.redo) + (_snapshot(editor),))[-HISTORY:]
|
|
145
|
+
stacks = dict(redo=source[:-1], undo=other) if forward else dict(undo=source[:-1], redo=other)
|
|
146
|
+
restored = replace(editor, text=text, cursor=cursor, anchor=cursor, reveal=True, **stacks)
|
|
147
|
+
return _checkpoint(restored) if forward else restored
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _indent(editor: Editor, remove: bool) -> Editor:
|
|
151
|
+
"""Tab: indent the selected lines (or insert spaces). Shift+Tab: unindent them."""
|
|
152
|
+
lo, hi = selection(editor)
|
|
153
|
+
start = editor.text.rfind("\n", 0, lo) + 1
|
|
154
|
+
if lo == hi and not remove:
|
|
155
|
+
return insert(editor, " " * (4 - len(editor.text[start:lo].expandtabs(4)) % 4))
|
|
156
|
+
lines = editor.text[start:max(lo, hi)].split("\n")
|
|
157
|
+
count = len(lines) - (hi > lo and editor.text[hi - 1:hi] == "\n")
|
|
158
|
+
edits = [] # (position, deleted characters, added text)
|
|
159
|
+
position = start
|
|
160
|
+
for _ in range(count):
|
|
161
|
+
stop = editor.text.find("\n", position)
|
|
162
|
+
line = editor.text[position:stop if stop >= 0 else len(editor.text)]
|
|
163
|
+
if remove:
|
|
164
|
+
deleted = 1 if line.startswith("\t") else min(4, len(line) - len(line.lstrip(" ")))
|
|
165
|
+
edits.append((position, deleted, ""))
|
|
166
|
+
else:
|
|
167
|
+
edits.append((position, 0, " "))
|
|
168
|
+
position += len(line) + 1
|
|
169
|
+
text = editor.text
|
|
170
|
+
for position, deleted, added in reversed(edits):
|
|
171
|
+
text = text[:position] + added + text[position + deleted:]
|
|
172
|
+
|
|
173
|
+
def moved(point: int) -> int:
|
|
174
|
+
return point + sum(len(added) - min(deleted, max(0, point - position))
|
|
175
|
+
for position, deleted, added in edits if position <= point)
|
|
176
|
+
|
|
177
|
+
return change(editor, text, moved(editor.cursor), moved(editor.anchor))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _char_class(char: str) -> int:
|
|
181
|
+
"""0 space, 1 word character, 2 anything else: words and punctuation runs are units."""
|
|
182
|
+
return 0 if char.isspace() else 1 if char.isalnum() or char == "_" else 2
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _word(text: str, position: int, direction: int) -> int:
|
|
186
|
+
"""Next word boundary to the left (-1) or right (+1)."""
|
|
187
|
+
if direction < 0:
|
|
188
|
+
while position > 0 and text[position - 1].isspace():
|
|
189
|
+
position -= 1
|
|
190
|
+
if position:
|
|
191
|
+
kind = _char_class(text[position - 1])
|
|
192
|
+
while position > 0 and _char_class(text[position - 1]) == kind:
|
|
193
|
+
position -= 1
|
|
194
|
+
return position
|
|
195
|
+
if position < len(text) and not text[position].isspace():
|
|
196
|
+
kind = _char_class(text[position])
|
|
197
|
+
while position < len(text) and _char_class(text[position]) == kind:
|
|
198
|
+
position += 1
|
|
199
|
+
while position < len(text) and text[position].isspace():
|
|
200
|
+
position += 1
|
|
201
|
+
return position
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- keys and mouse ------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def key(editor: Editor, name: str, shift: bool = False, page_lines: int = 20) -> Editor:
|
|
207
|
+
"""An editing or movement key. `shift` extends the selection when moving."""
|
|
208
|
+
text = editor.text
|
|
209
|
+
lo, hi = selection(editor)
|
|
210
|
+
row, col = line_col(text, editor.cursor)
|
|
211
|
+
start = editor.cursor - col
|
|
212
|
+
end = text.find("\n", editor.cursor)
|
|
213
|
+
end = len(text) if end < 0 else end
|
|
214
|
+
editor = _checkpoint(editor)
|
|
215
|
+
if name == "select_all":
|
|
216
|
+
return replace(editor, anchor=0, cursor=len(text), reveal=True)
|
|
217
|
+
if name in ("tab", "untab"):
|
|
218
|
+
return _indent(editor, remove=name == "untab")
|
|
219
|
+
if name == "enter":
|
|
220
|
+
before = text[text.rfind("\n", 0, lo) + 1:lo]
|
|
221
|
+
indentation = before[:len(before) - len(before.lstrip(" \t"))]
|
|
222
|
+
return insert(editor, "\n" + indentation + (" " if before.rstrip().endswith(":") else ""))
|
|
223
|
+
if name in ("backspace", "delete", "word_backspace", "word_delete"):
|
|
224
|
+
if lo == hi:
|
|
225
|
+
if name == "word_backspace":
|
|
226
|
+
lo = _word(text, lo, -1)
|
|
227
|
+
elif name == "word_delete":
|
|
228
|
+
hi = _word(text, hi, 1)
|
|
229
|
+
elif name == "delete":
|
|
230
|
+
hi = min(len(text), hi + 1)
|
|
231
|
+
elif text[start:lo] and not text[start:lo].strip(" \t"):
|
|
232
|
+
lo = _unindent_point(text, start, lo) # backspace in indentation: back to the tab stop
|
|
233
|
+
else:
|
|
234
|
+
lo = max(0, lo - 1)
|
|
235
|
+
return change(editor, text[:lo] + text[hi:], lo)
|
|
236
|
+
point = editor.cursor
|
|
237
|
+
if name in ("left", "right"):
|
|
238
|
+
if lo != hi and not shift:
|
|
239
|
+
point = lo if name == "left" else hi
|
|
240
|
+
else:
|
|
241
|
+
point += -1 if name == "left" else 1
|
|
242
|
+
elif name in ("word_left", "word_right"):
|
|
243
|
+
point = _word(text, point, -1 if name == "word_left" else 1)
|
|
244
|
+
elif name in ("up", "down", "page_up", "page_down"):
|
|
245
|
+
lines = page_lines if name.startswith("page") else 1
|
|
246
|
+
point = offset(text, row + (-lines if name in ("up", "page_up") else lines), col)
|
|
247
|
+
elif name == "home":
|
|
248
|
+
first = start + len(text[start:end]) - len(text[start:end].lstrip(" \t"))
|
|
249
|
+
point = start if point == first else first
|
|
250
|
+
elif name == "end":
|
|
251
|
+
point = end
|
|
252
|
+
elif name == "doc_start":
|
|
253
|
+
point = 0
|
|
254
|
+
elif name == "doc_end":
|
|
255
|
+
point = len(text)
|
|
256
|
+
else:
|
|
257
|
+
raise ValueError(f"Unknown editor key: {name}")
|
|
258
|
+
point = max(0, min(point, len(text)))
|
|
259
|
+
return replace(editor, cursor=point, anchor=editor.anchor if shift else point, reveal=True)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _unindent_point(text: str, start: int, point: int) -> int:
|
|
263
|
+
target = (len(text[start:point].expandtabs(4)) - 1) // 4 * 4
|
|
264
|
+
column = 0
|
|
265
|
+
for index, char in enumerate(text[start:point]):
|
|
266
|
+
column += 4 - column % 4 if char == "\t" else 1
|
|
267
|
+
if column > target:
|
|
268
|
+
return start + index
|
|
269
|
+
return start
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def click(editor: Editor, line: int, column: int, extend: bool = False, clicks: int = 1) -> Editor:
|
|
273
|
+
"""Mouse press or drag. extend keeps the anchor; 2 clicks select a word, 3 a line."""
|
|
274
|
+
editor = _checkpoint(editor)
|
|
275
|
+
text = editor.text
|
|
276
|
+
point = offset(text, line, column)
|
|
277
|
+
anchor = editor.anchor if extend else point
|
|
278
|
+
if clicks >= 3:
|
|
279
|
+
anchor = text.rfind("\n", 0, point) + 1
|
|
280
|
+
end = text.find("\n", point)
|
|
281
|
+
point = len(text) if end < 0 else end + 1
|
|
282
|
+
elif clicks == 2 and text:
|
|
283
|
+
probe = min(point, len(text) - 1)
|
|
284
|
+
kind = _char_class(text[probe])
|
|
285
|
+
anchor, point = probe, probe + 1
|
|
286
|
+
while anchor > 0 and text[anchor - 1] != "\n" and _char_class(text[anchor - 1]) == kind:
|
|
287
|
+
anchor -= 1
|
|
288
|
+
while point < len(text) and text[point] != "\n" and _char_class(text[point]) == kind:
|
|
289
|
+
point += 1
|
|
290
|
+
return replace(editor, cursor=point, anchor=anchor, reveal=False)
|