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/ui.py ADDED
@@ -0,0 +1,677 @@
1
+ """What a Workspace looks like: a top bar, the folder's files on the left, then the code (with the
2
+ parameters of exposed functions above it), the 3D view (a Viewer) on the right and a status line. Drawn every frame with
3
+ Dear ImGui into the current region; buttons call Workspace and Viewer methods directly.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ import warnings
9
+ from dataclasses import replace
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, cast
12
+
13
+ import glfw
14
+ from slimgui import imgui
15
+
16
+ from .api import FPS
17
+ from . import camera as cam
18
+ from . import editor, textedit, viewcube
19
+ from .icons import icon
20
+ from .imgui_backend import FONT_SIZE, clipboard_text
21
+
22
+ if TYPE_CHECKING:
23
+ from .params import Param
24
+ from .viewer import Viewer
25
+ from .window import Window
26
+ from .workspace import Workspace
27
+
28
+ STATUS_HEIGHT = 22.0
29
+ TOOL_SIDE = 28.0 # the square top-bar buttons
30
+ VIEW_SIDE = 26.0 # the 3D view's buttons
31
+ SMALL_SIDE = 22.0 # Copy error, Reset, Play/Pause
32
+ ICON_SIZE = 16.0
33
+ GROUP_GAP = 7.0
34
+ CUBE_RADIUS = 32.0 # half the view cube's side, in pixels
35
+ CUBE_MARGIN = 68.0 # from the view's top right corner to the cube's centre
36
+ EXPORTS = (("STL (3D printing)", "stl", "generic"), ("3MF", "3mf", "generic"),
37
+ ("3MF for Bambu Studio", "3mf", "bambu"), ("STEP (CAD)", "step", "generic"),
38
+ ("GLB (web)", "glb", "generic"), ("BREP (OpenCascade)", "brep", "generic"))
39
+ ERROR = (1.0, 0.42, 0.38, 1.0)
40
+ WARNING = (0.96, 0.69, 0.16, 1.0)
41
+ MUTED = (0.62, 0.64, 0.70, 1.0)
42
+ OVERLAY = (0.10, 0.11, 0.14, 0.72)
43
+ BACKGROUND = (0.075, 0.08, 0.095, 1.0)
44
+ CUBE_FACE = (0.36, 0.40, 0.49, 0.94)
45
+ CUBE_EDGE = (0.60, 0.64, 0.73, 1.0)
46
+ CUBE_TEXT = (0.88, 0.90, 0.94, 1.0)
47
+ CUBE_HOVER = (0.96, 0.69, 0.16, 0.9) # brand gold
48
+ # Button colours (normal, hovered, active, icon): Run in the brand gold, Stop in red.
49
+ RUN_COLORS = ((0.96, 0.69, 0.16, 1.0), (1.0, 0.78, 0.32, 1.0), (0.85, 0.60, 0.12, 1.0), (0.10, 0.07, 0.0, 1.0))
50
+ STOP_COLORS = ((0.78, 0.30, 0.27, 1.0), (0.88, 0.38, 0.34, 1.0), (0.68, 0.25, 0.22, 1.0), (1.0, 1.0, 1.0, 1.0))
51
+ STYLE_COLORS = {
52
+ imgui.Col.WINDOW_BG: BACKGROUND,
53
+ imgui.Col.CHILD_BG: (0.095, 0.10, 0.12, 1.0),
54
+ imgui.Col.POPUP_BG: (0.11, 0.12, 0.15, 0.98),
55
+ imgui.Col.BORDER: (0.16, 0.17, 0.21, 1.0),
56
+ imgui.Col.BUTTON: (0.16, 0.17, 0.21, 1.0),
57
+ imgui.Col.BUTTON_HOVERED: (0.24, 0.26, 0.32, 1.0),
58
+ imgui.Col.BUTTON_ACTIVE: (0.30, 0.33, 0.42, 1.0),
59
+ imgui.Col.HEADER_HOVERED: (0.24, 0.26, 0.32, 1.0),
60
+ imgui.Col.FRAME_BG: (0.14, 0.15, 0.18, 1.0),
61
+ imgui.Col.CHECK_MARK: WARNING, # brand gold
62
+ }
63
+
64
+
65
+ def apply_style() -> None:
66
+ imgui.style_colors_dark()
67
+ style = imgui.get_style()
68
+ style.window_rounding = 0.0
69
+ style.frame_rounding = style.child_rounding = style.popup_rounding = 4.0
70
+ style.frame_padding = (8.0, 4.0)
71
+ style.item_spacing = (6.0, 4.0)
72
+ for col, value in STYLE_COLORS.items():
73
+ style.colors[col] = value
74
+
75
+
76
+ def region() -> tuple[float, float]:
77
+ """The available region of the current ImGui window (at least a pixel)."""
78
+ width, height = imgui.get_content_region_avail()
79
+ return max(1.0, width), max(1.0, height)
80
+
81
+
82
+ def workspace(ws: Workspace, events: list) -> None:
83
+ """A Workspace in the current region. events: this frame's keyboard input (see ImguiBackend.events)."""
84
+ imgui.push_style_color(imgui.Col.CHILD_BG, (0.0, 0.0, 0.0, 0.0)) # the host window's background
85
+ imgui.begin_child(f"workspace##{id(ws)}", region())
86
+ imgui.pop_style_color()
87
+ if ws.read_only:
88
+ read_only_band(ws)
89
+ if imgui.is_window_focused(imgui.FocusedFlags.ROOT_AND_CHILD_WINDOWS): # not while another ImGui window is
90
+ events = shortcuts(ws, events)
91
+ if ws.read_only: # no editor calls the shortcuts' actions
92
+ for event in events:
93
+ if callable(event):
94
+ event(ws.editor)
95
+ topbar(ws)
96
+ panels(ws, events)
97
+ statusbar(ws)
98
+ if ws.save_as_open:
99
+ save_as_dialog(ws)
100
+ if ws.opening:
101
+ open_prompt(ws)
102
+ imgui.end_child()
103
+
104
+
105
+ def shortcuts(ws: Workspace, events: list) -> list:
106
+ """Ctrl+R, F5 or Ctrl+Enter: Run; Ctrl+S: Save; Ctrl+Shift+S: Save as; Ctrl +/-/0: code zoom. They
107
+ work wherever the focus is, except in a dialog, a menu or a text field. Returns the events with
108
+ these keys replaced by actions, which the editor calls in order: text typed before Ctrl+S is saved."""
109
+ if imgui.is_popup_open("", imgui.PopupFlags.ANY_POPUP) or imgui.get_io().want_text_input:
110
+ return events
111
+
112
+ def action(do):
113
+ def act(editor) -> None:
114
+ ws.editor = editor # the edits that came before the shortcut
115
+ do()
116
+ return act
117
+
118
+ result = []
119
+ for event in events:
120
+ key, mods, repeat = event if isinstance(event, tuple) else (None, 0, False)
121
+ ctrl = mods & glfw.MOD_CONTROL
122
+ if key == glfw.KEY_F5 or ctrl and key in (glfw.KEY_R, glfw.KEY_ENTER, glfw.KEY_KP_ENTER):
123
+ if not repeat:
124
+ result.append(action(ws.run))
125
+ elif ctrl and (step := zoom_step(key)) is not None:
126
+ result.append(action(lambda step=step: ws.zoom_code(step)))
127
+ elif ctrl and key == glfw.KEY_S and not ws.read_only:
128
+ if not repeat and mods & glfw.MOD_SHIFT:
129
+ result.append(action(lambda: setattr(ws, "save_as_open", True)))
130
+ elif not repeat:
131
+ result.append(action(ws.save))
132
+ else:
133
+ result.append(event)
134
+ return result
135
+
136
+
137
+ def zoom_step(key: int | None) -> int | None:
138
+ """Ctrl with +, - or 0 zooms the code: by the character the key types, so it works on any
139
+ keyboard layout (on a Spanish one + and - are not where the US names put them), or the keypad."""
140
+ if key is None:
141
+ return None
142
+ keypad = {glfw.KEY_KP_ADD: 1, glfw.KEY_KP_SUBTRACT: -1, glfw.KEY_KP_0: 0}
143
+ if key in keypad:
144
+ return keypad[key]
145
+ with warnings.catch_warnings(): # without a window (tests) GLFW is not initialized: US names
146
+ warnings.simplefilter("ignore", glfw.GLFWError)
147
+ name = glfw.get_key_name(key, 0)
148
+ name = name or {glfw.KEY_EQUAL: "=", glfw.KEY_MINUS: "-", glfw.KEY_0: "0"}.get(key)
149
+ return {"+": 1, "=": 1, "-": -1, "0": 0}.get(name) if name else None
150
+
151
+
152
+ def read_only_band(ws: Workspace) -> None:
153
+ imgui.push_style_color(imgui.Col.CHILD_BG, WARNING)
154
+ imgui.begin_child("read-only", (0.0, imgui.get_frame_height()))
155
+ imgui.set_cursor_pos((8.0, 3.0))
156
+ imgui.text_colored((0.1, 0.07, 0.0, 1.0), "READ ONLY: set the parameters, Run, Export. The scripts are never "
157
+ "changed.")
158
+ imgui.end_child()
159
+ imgui.pop_style_color()
160
+
161
+
162
+ def unsaved_prompt(ws: Workspace, question: str) -> str | None:
163
+ """A modal asking to Save / Discard / Cancel the editor's unsaved changes: "go" once they are
164
+ saved or discarded, "cancel", or None while there is no answer (a failed save keeps asking)."""
165
+ answer = None
166
+ imgui.push_id(str(id(ws)))
167
+ imgui.open_popup("Unsaved changes")
168
+ if imgui.begin_popup_modal("Unsaved changes", flags=imgui.WindowFlags.ALWAYS_AUTO_RESIZE)[0]:
169
+ imgui.text(question)
170
+ if imgui.button("Save") and ws.save():
171
+ answer = "go"
172
+ imgui.same_line()
173
+ if imgui.button("Discard"):
174
+ ws.discard()
175
+ answer = "go"
176
+ imgui.same_line()
177
+ if imgui.button("Cancel"):
178
+ answer = "cancel"
179
+ if ws.message_is_error:
180
+ imgui.text_colored(ERROR, ws.message)
181
+ if answer:
182
+ imgui.close_current_popup()
183
+ imgui.end_popup()
184
+ imgui.pop_id()
185
+ return answer
186
+
187
+
188
+ def close_prompt(ws: Workspace, window: Window) -> None:
189
+ if not window.close_requested:
190
+ return
191
+ answer = unsaved_prompt(ws, f"Save the changes to {ws.path.name} before closing?")
192
+ if answer:
193
+ window.close_requested = False
194
+ if answer == "go":
195
+ window.request_close()
196
+
197
+
198
+ def open_file(ws: Workspace, path: Path, line: int | None = None) -> None:
199
+ """Edit another file: at once, or after the Save / Discard / Cancel prompt (see open_prompt)."""
200
+ if ws.dirty() and path != ws.path:
201
+ ws.opening = (path, line)
202
+ else:
203
+ ws.open(path, line)
204
+
205
+
206
+ def open_prompt(ws: Workspace) -> None:
207
+ """Another file was asked for while the editor had unsaved changes: Save / Discard / Cancel."""
208
+ assert ws.opening is not None
209
+ path, line = ws.opening
210
+ answer = unsaved_prompt(ws, f"Save the changes to {ws.path.name} before opening {path.name}?")
211
+ if answer:
212
+ ws.opening = None
213
+ if answer == "go":
214
+ ws.open(path, line)
215
+
216
+
217
+ def tooltip(name: str, shortcut: str = "") -> None:
218
+ """The hovered item's name, and its shortcut in grey."""
219
+ if imgui.begin_item_tooltip():
220
+ imgui.text(name)
221
+ if shortcut:
222
+ imgui.same_line(spacing=12.0)
223
+ imgui.text_colored(MUTED, shortcut)
224
+ imgui.end_tooltip()
225
+
226
+
227
+ def button(label: str, name: str, shortcut: str = "") -> bool:
228
+ pressed = imgui.button(label)
229
+ tooltip(name, shortcut)
230
+ return pressed
231
+
232
+
233
+ def icon_button(name: str, tip: str, shortcut: str = "", side: float = TOOL_SIDE, colors: tuple = ()) -> bool:
234
+ """A square button that shows one icon; tip (and shortcut) on hover. colors: (normal, hovered,
235
+ active, icon) to paint it, e.g. the Run button."""
236
+ for col, value in zip((imgui.Col.BUTTON, imgui.Col.BUTTON_HOVERED, imgui.Col.BUTTON_ACTIVE, imgui.Col.TEXT),
237
+ colors):
238
+ imgui.push_style_color(col, value)
239
+ imgui.push_font(None, ICON_SIZE if side >= VIEW_SIDE else ICON_SIZE - 2.0)
240
+ pressed = imgui.button(f"{icon(name)}##{name}", (side, side))
241
+ imgui.pop_font()
242
+ imgui.pop_style_color(len(colors))
243
+ tooltip(tip, shortcut)
244
+ return pressed
245
+
246
+
247
+ def flat_icon_button(name: str, tip: str) -> bool:
248
+ """A small icon button without a background until hovered (Copy error, Reset)."""
249
+ imgui.push_style_color(imgui.Col.BUTTON, (0.0, 0.0, 0.0, 0.0))
250
+ pressed = icon_button(name, tip, side=SMALL_SIDE)
251
+ imgui.pop_style_color()
252
+ return pressed
253
+
254
+
255
+ def group_gap() -> None:
256
+ """A thin vertical line between groups of top-bar buttons."""
257
+ imgui.same_line(spacing=GROUP_GAP)
258
+ x, y = imgui.get_cursor_screen_pos()
259
+ imgui.get_window_draw_list().add_line((x, y + 6.0), (x, y + TOOL_SIDE - 6.0),
260
+ imgui.get_color_u32(STYLE_COLORS[imgui.Col.BORDER]), 1.0)
261
+ imgui.same_line(spacing=GROUP_GAP + 1.0)
262
+
263
+
264
+ def topbar(ws: Workspace) -> None:
265
+ top = imgui.get_cursor_pos_y()
266
+ imgui.push_style_color(imgui.Col.BUTTON, (0.0, 0.0, 0.0, 0.0)) # flat until hovered
267
+ if ws.child is not None and time.monotonic() - ws.run_started > 0.5:
268
+ if icon_button("square", "Stop", colors=STOP_COLORS):
269
+ ws.stop()
270
+ elif icon_button("play", "Run", "Ctrl+R / F5", colors=RUN_COLORS):
271
+ ws.run()
272
+ group_gap()
273
+ if not ws.read_only: # read only: no code to save, copy or hand to an assistant
274
+ code_buttons(ws)
275
+ if icon_button("download", "Export"):
276
+ imgui.open_popup("export")
277
+ imgui.pop_style_color()
278
+ if imgui.begin_popup("export"):
279
+ for label, extension, profile in EXPORTS:
280
+ if imgui.menu_item(label)[0]:
281
+ ws.export(extension, profile)
282
+ imgui.separator()
283
+ if imgui.menu_item("PNG picture of the view")[0]:
284
+ ws.save_picture()
285
+ imgui.end_popup()
286
+ text_y = top + round((TOOL_SIDE - imgui.get_text_line_height()) / 2) - 4.0 # the font sits low in its line
287
+ if ws.disk_changed and not ws.read_only:
288
+ imgui.same_line(spacing=16.0)
289
+ lost = " (your unsaved edits here would be lost)" if ws.dirty() else ""
290
+ imgui.set_cursor_pos_y(text_y)
291
+ imgui.text_colored(WARNING, f"Changed on disk{lost}:")
292
+ imgui.same_line()
293
+ imgui.set_cursor_pos_y(top + (TOOL_SIDE - imgui.get_frame_height()) / 2)
294
+ if button(f"{icon('refresh-cw')} Reload", "Load the file from disk; then Run to see it"):
295
+ ws.reload()
296
+ name = ws.path.name
297
+ runs = f" · Run: {ws.main.name}" if ws.path != ws.main else "" # the main, when another file is edited
298
+ width = imgui.calc_text_size(name + runs)[0]
299
+ imgui.same_line(max(imgui.get_cursor_pos()[0], imgui.get_window_width() - width - 6.0))
300
+ imgui.set_cursor_pos_y(text_y)
301
+ if ws.dirty(): # a red dot: the editor differs from the file
302
+ x, y = imgui.get_cursor_screen_pos()
303
+ imgui.get_window_draw_list().add_circle_filled((x - 10.0, y + imgui.get_text_line_height() / 2 + 4.0), 4.0,
304
+ imgui.get_color_u32(ERROR))
305
+ imgui.text_colored(MUTED, name + runs)
306
+ imgui.set_cursor_pos_y(top + TOOL_SIDE + imgui.get_style().item_spacing[1])
307
+
308
+
309
+ def code_buttons(ws: Workspace) -> None:
310
+ if icon_button("save", "Save", "Ctrl+S"):
311
+ ws.save()
312
+ imgui.same_line()
313
+ if icon_button("file-plus-2", "Save as (a new file, and continue there)", "Ctrl+Shift+S"):
314
+ ws.save_as_open = True
315
+ group_gap()
316
+ if icon_button("sparkles", "Copy AI context (lets an AI assistant work on the main file)"):
317
+ from .context import ai_context
318
+
319
+ editor.copy_text(ai_context(ws.main, ws))
320
+ ws.say("AI context copied: paste it into your assistant")
321
+ imgui.same_line()
322
+ if icon_button("copy", "Copy code"):
323
+ editor.copy_text(ws.editor.text)
324
+ ws.say("Code copied")
325
+ imgui.same_line()
326
+ if icon_button("clipboard-paste", "Paste code (replaces all the code)"):
327
+ ws.editor = textedit.replace_all(ws.editor, clipboard_text())
328
+ group_gap()
329
+
330
+
331
+ def file_list(ws: Workspace, height: float) -> float:
332
+ """The folder's .py files, a narrow column: the edited one selected, the main one marked with
333
+ a play icon (a right click makes another the main; read only: a click runs a file). Returns its
334
+ width (0 when not shown)."""
335
+ if not ws.show_files:
336
+ return 0.0
337
+ files = ws.files()
338
+ names = [path.name for path in files]
339
+ marker = f"{icon('play')} "
340
+ padding = imgui.get_style().window_padding[0] * 2 + imgui.get_style().item_spacing[0]
341
+ width = min(200.0, max(110.0, imgui.calc_text_size(marker)[0] + padding
342
+ + max(imgui.calc_text_size(name)[0] for name in names)))
343
+ imgui.begin_child("files", (width, height), imgui.ChildFlags.BORDERS)
344
+ indent = imgui.calc_text_size(marker)[0]
345
+ for path, name in zip(files, names):
346
+ main = path == ws.main
347
+ imgui.push_id(str(path))
348
+ if imgui.selectable(f"##{name}", path == ws.path)[0]:
349
+ ws.choose(path) if ws.read_only else open_file(ws, path)
350
+ if ws.read_only:
351
+ tooltip(f"{name}: Run runs it" if main else f"Run {name}")
352
+ else:
353
+ tooltip(f"{name}: the main file, Run runs it" if main else f"{name}: right click to make it the main file")
354
+ if not ws.read_only and imgui.begin_popup_context_item("main"):
355
+ if imgui.menu_item("Main file (Run runs it)", selected=main)[0]:
356
+ ws.set_main(path)
357
+ imgui.end_popup()
358
+ imgui.same_line(imgui.get_style().window_padding[0])
359
+ if main:
360
+ imgui.text_colored(WARNING, icon("play")) # brand gold, as the Run button
361
+ imgui.same_line(imgui.get_style().window_padding[0] + indent)
362
+ else:
363
+ imgui.set_cursor_pos_x(imgui.get_style().window_padding[0] + indent)
364
+ imgui.text(name)
365
+ imgui.pop_id()
366
+ imgui.end_child()
367
+ imgui.same_line(spacing=6.0)
368
+ return width + 6.0
369
+
370
+
371
+ def panels(ws: Workspace, events: list) -> None:
372
+ width, height = imgui.get_content_region_avail()
373
+ height -= STATUS_HEIGHT
374
+ width -= file_list(ws, height)
375
+ left = max(260.0, min(width - 300.0, width * ws.split))
376
+
377
+ imgui.begin_child("left", (left, height))
378
+ error = ws.error
379
+ output = None if error else ws.stdout.strip()
380
+ bottom = min(height * 0.35, 190.0) if error or output else 0.0
381
+ if ws.read_only: # no code: the parameters fill the column
382
+ controls = height - bottom - (4.0 if bottom else 0.0)
383
+ else:
384
+ controls = parameters_height(ws, height * 0.45)
385
+ code = height - bottom - controls - (4.0 if bottom else 0.0) - (4.0 if controls else 0.0)
386
+ if controls:
387
+ imgui.begin_child("parameters", (0.0, controls), imgui.ChildFlags.BORDERS)
388
+ parameters(ws)
389
+ imgui.end_child()
390
+ if not ws.read_only:
391
+ code_view(ws, code, events)
392
+ in_this_file = ws.error_file == str(ws.path)
393
+ if error: # the error message first, then where, then its traceback
394
+ imgui.begin_child("error", (0.0, 0.0), imgui.ChildFlags.BORDERS)
395
+ start_x, start_y = imgui.get_cursor_pos()
396
+ copy_x = start_x + imgui.get_content_region_avail()[0] - SMALL_SIDE
397
+ lines = error.strip().splitlines()
398
+ imgui.push_text_wrap_pos(copy_x - 6.0) # the copy button sits top right
399
+ imgui.text_colored(ERROR, lines[-1])
400
+ if ws.error_file and not in_this_file and not ws.read_only: # a click opens that file at that line
401
+ where = Path(ws.error_file)
402
+ shown = where.relative_to(ws.folder) if where.is_relative_to(ws.folder) else where
403
+ imgui.text_colored(WARNING, f"in {shown}, line {ws.error_line}")
404
+ if imgui.is_item_hovered():
405
+ imgui.set_mouse_cursor(imgui.MouseCursor.HAND)
406
+ tooltip(f"Open {where.name} at line {ws.error_line}")
407
+ if imgui.is_item_clicked():
408
+ open_file(ws, where, ws.error_line)
409
+ for line in lines[:-1]:
410
+ imgui.text_colored(MUTED, line)
411
+ imgui.pop_text_wrap_pos()
412
+ imgui.set_cursor_pos((copy_x, start_y))
413
+ if flat_icon_button("copy", "Copy error"):
414
+ editor.copy_text(error)
415
+ ws.say("Error copied")
416
+ imgui.end_child()
417
+ elif output: # what the script printed
418
+ imgui.begin_child("output", (0.0, 0.0), imgui.ChildFlags.BORDERS)
419
+ imgui.text_colored(MUTED, output[-5000:])
420
+ imgui.end_child()
421
+ imgui.end_child()
422
+
423
+ imgui.same_line(spacing=0.0)
424
+ imgui.invisible_button("split", (6.0, height))
425
+ if imgui.is_item_hovered() or imgui.is_item_active():
426
+ imgui.set_mouse_cursor(imgui.MouseCursor.RESIZE_EW)
427
+ if imgui.is_item_active():
428
+ ws.split = min(0.8, max(0.15, ws.split + imgui.get_io().mouse_delta[0] / width))
429
+ imgui.same_line(spacing=0.0)
430
+ top_left = imgui.get_cursor_screen_pos()
431
+ bar = imgui.get_frame_height_with_spacing() + 6.0 if ws.frames else 0.0
432
+ view_width = imgui.get_content_region_avail()[0]
433
+ ws.viewer.draw(ws.on_screen(), (view_width, height - bar))
434
+ if ws.frames:
435
+ imgui.set_cursor_screen_pos((top_left[0], top_left[1] + height - bar + 4.0))
436
+ timeline(ws, view_width)
437
+ if not ws.shown and not ws.frames:
438
+ after = imgui.get_cursor_screen_pos()
439
+ if ws.running():
440
+ hint = "Running..."
441
+ elif ws.error:
442
+ hint = "Nothing shown: see the error"
443
+ elif ws.ran():
444
+ hint = "Nothing shown: call show() in the script"
445
+ else:
446
+ hint = "Press Run (Ctrl+R) to run the script"
447
+ imgui.set_cursor_screen_pos((top_left[0] + 14.0, top_left[1] + 16.0 + imgui.get_frame_height()))
448
+ imgui.text_colored(MUTED, hint)
449
+ imgui.set_cursor_screen_pos(after)
450
+
451
+
452
+ def code_view(ws: Workspace, height: float, events: list) -> None:
453
+ """The editor, with the line of the error marked."""
454
+ imgui.begin_child("code", (0.0, height), imgui.ChildFlags.BORDERS)
455
+ highlight = ws.error_line if ws.error_file == str(ws.path) else None
456
+ io = imgui.get_io()
457
+ if io.key_ctrl and io.mouse_wheel and imgui.is_window_hovered(imgui.HoveredFlags.CHILD_WINDOWS):
458
+ ws.zoom_code(1 if io.mouse_wheel > 0 else -1)
459
+ imgui.push_font(None, FONT_SIZE * ws.code_zoom)
460
+ ws.editor = editor.view(ws.editor, imgui.get_content_region_avail(), events, highlight,
461
+ wheel_scroll=not io.key_ctrl)
462
+ imgui.pop_font()
463
+ imgui.end_child()
464
+
465
+
466
+ def parameters_height(ws: Workspace, limit: float) -> float:
467
+ """Room for the parameters panel (0 when the last run exposed nothing); it scrolls beyond limit."""
468
+ if not ws.parameters:
469
+ return 0.0
470
+ rows = 1 + sum(2 + len(exposed.params) for exposed in ws.parameters) # title; header, controls, Reset
471
+ padding = imgui.get_style().window_padding[1] * 2
472
+ return min(limit, rows * imgui.get_frame_height_with_spacing() + padding)
473
+
474
+
475
+ def parameters(ws: Workspace) -> None:
476
+ """One group of controls per exposed function. A change stays in memory until Run."""
477
+ imgui.align_text_to_frame_padding()
478
+ imgui.text("Parameters")
479
+ if ws.values_changed():
480
+ imgui.same_line()
481
+ imgui.text_colored(WARNING, "Values changed: Run (Ctrl+R) to apply")
482
+ if not ws.parameters: # read only shows the panel anyway
483
+ imgui.text_colored(MUTED, "This script has no parameters (see expose())")
484
+ for exposed in ws.parameters:
485
+ imgui.push_id(exposed.function)
486
+ imgui.push_style_color(imgui.Col.HEADER, STYLE_COLORS[imgui.Col.BUTTON])
487
+ is_open = imgui.collapsing_header(exposed.function, flags=imgui.TreeNodeFlags.DEFAULT_OPEN)[0]
488
+ imgui.pop_style_color()
489
+ if is_open:
490
+ labels = [param.label or param.name for param in exposed.params]
491
+ label_width = max((imgui.calc_text_size(label)[0] for label in labels), default=0.0)
492
+ for param, label in zip(exposed.params, labels):
493
+ imgui.set_next_item_width(-label_width - imgui.get_style().item_inner_spacing[0] - 2.0)
494
+ changed, value = control(f"{label}##{param.name}", param, ws.value(exposed.function, param))
495
+ if param.description:
496
+ imgui.set_item_tooltip(param.description)
497
+ if changed:
498
+ ws.set_value(exposed.function, param, value)
499
+ prefix = exposed.function + "."
500
+ imgui.begin_disabled(not any(key.startswith(prefix) for key in ws.values))
501
+ if flat_icon_button("rotate-ccw", f"Reset: back to the defaults of {exposed.function} (on the next Run)"):
502
+ ws.reset(exposed.function)
503
+ imgui.end_disabled()
504
+ imgui.pop_id()
505
+
506
+
507
+ def control(label: str, param: Param, value: object) -> tuple[bool, int | float | bool | str]:
508
+ """The ImGui control for one parameter: slider, number input, checkbox or text. value is of
509
+ the parameter's kind (Workspace keeps only values of that kind)."""
510
+ if param.kind == "bool":
511
+ return imgui.checkbox(label, cast(bool, value))
512
+ if param.kind == "str":
513
+ return imgui.input_text(label, cast(str, value))
514
+ if param.slider:
515
+ low, high = param.min, param.max
516
+ assert low is not None and high is not None # a slider always has Min and Max (see Param)
517
+ if param.kind == "int":
518
+ return imgui.slider_int(label, cast(int, value), int(low), int(high),
519
+ flags=imgui.SliderFlags.ALWAYS_CLAMP)
520
+ changed, number = imgui.slider_float(label, cast(float, value), float(low), float(high), "%g",
521
+ flags=imgui.SliderFlags.ALWAYS_CLAMP)
522
+ if changed and param.step: # snap to the step, counted from Min
523
+ number = low + round((number - low) / param.step) * param.step
524
+ return changed, number
525
+ if param.kind == "int":
526
+ step = int(param.step or 1)
527
+ return imgui.input_int(label, cast(int, value), step, step * 10)
528
+ step = float(param.step or 0.0)
529
+ return imgui.input_float(label, cast(float, value), step, step * 10, "%g")
530
+
531
+
532
+ def timeline(ws: Workspace, width: float) -> None:
533
+ """Play/pause and the frame slider of an animation (frame() in the script), under the view."""
534
+ left = imgui.get_cursor_pos_x()
535
+ if icon_button("pause" if ws.playing else "play", "Pause" if ws.playing else "Play", side=SMALL_SIDE):
536
+ ws.playing = not ws.playing
537
+ imgui.same_line()
538
+ total = len(ws.frames)
539
+ label = f"{ws.frame_index() + 1} / {total} ({ws.clock % (total / FPS):.1f} s)"
540
+ imgui.set_next_item_width(left + width - imgui.get_cursor_pos_x() - imgui.calc_text_size(label)[0] - 16.0)
541
+ changed, index = imgui.slider_int("##frame", ws.frame_index(), 0, total - 1, "")
542
+ if changed:
543
+ ws.seek(index)
544
+ imgui.same_line()
545
+ imgui.text_colored(MUTED, label)
546
+
547
+
548
+ def view(viewer: Viewer, size: tuple[float, float]) -> None:
549
+ """The 3D view: left drag orbits, right/middle (or shift+left) drag pans, wheel zooms,
550
+ double-click fits."""
551
+ io = imgui.get_io()
552
+ width, height = size
553
+ scale_x, scale_y = io.display_framebuffer_scale
554
+ imgui.push_id(str(id(viewer)))
555
+ texture = viewer.texture(max(1, int(width * scale_x)), max(1, int(height * scale_y)))
556
+ top_left = imgui.get_cursor_screen_pos()
557
+ imgui.image(texture, (width, height), uv0=(0.0, 1.0), uv1=(1.0, 0.0)) # GL images are bottom-up
558
+ imgui.set_cursor_screen_pos(top_left)
559
+ imgui.set_next_item_allow_overlap() # the view buttons drawn on top still get their clicks
560
+ imgui.invisible_button("view3d", (width, height), imgui.ButtonFlags.MOUSE_BUTTON_LEFT
561
+ | imgui.ButtonFlags.MOUSE_BUTTON_RIGHT | imgui.ButtonFlags.MOUSE_BUTTON_MIDDLE)
562
+ dx, dy = io.mouse_delta
563
+ if imgui.is_item_active() and (dx or dy):
564
+ if io.mouse_down[0] and not io.key_shift:
565
+ viewer.camera = cam.orbit(viewer.camera, dx, dy)
566
+ else:
567
+ viewer.camera = cam.pan(viewer.camera, dx, dy, min(width, height))
568
+ if imgui.is_item_hovered():
569
+ if io.mouse_wheel:
570
+ viewer.camera = cam.zoom(viewer.camera, io.mouse_wheel)
571
+ if imgui.is_mouse_double_clicked(imgui.MouseButton.LEFT):
572
+ viewer.fit()
573
+
574
+ # The view cube top right, Fit and the display toggles under it.
575
+ center = (top_left[0] + width - CUBE_MARGIN, top_left[1] + CUBE_MARGIN)
576
+ view_cube(viewer, center)
577
+ spacing = imgui.get_style().item_spacing[0]
578
+ imgui.set_cursor_screen_pos((center[0] - VIEW_SIDE - spacing / 2, center[1] + CUBE_MARGIN - 8.0))
579
+ imgui.push_style_color(imgui.Col.BUTTON, OVERLAY)
580
+ if icon_button("scan", "Fit", "double-click the view", side=VIEW_SIDE):
581
+ viewer.fit()
582
+ imgui.same_line()
583
+ if icon_button("settings-2", "Edges, grid and axes", side=VIEW_SIDE):
584
+ imgui.open_popup("display")
585
+ imgui.pop_style_color()
586
+ if imgui.begin_popup("display"):
587
+ for label in ("Edges", "Grid", "Axes"):
588
+ changed, value = imgui.checkbox(label, getattr(viewer.display, label.lower()))
589
+ if changed:
590
+ viewer.display = replace(viewer.display, **{label.lower(): value})
591
+ imgui.end_popup()
592
+
593
+ imgui.set_cursor_screen_pos(top_left)
594
+ imgui.dummy((width, height)) # the cursor goes on after the view, as after an image
595
+ imgui.pop_id()
596
+
597
+
598
+ def view_cube(viewer: Viewer, center: tuple[float, float]) -> None:
599
+ """The navigation cube: it turns with the camera; a click on a face, edge or corner looks from there."""
600
+ visible = viewcube.faces(viewer.camera, center, CUBE_RADIUS)
601
+ reach = CUBE_RADIUS * 1.75 # the cube's largest extent on screen (half the diagonal is sqrt(3))
602
+ imgui.set_cursor_screen_pos((center[0] - reach, center[1] - reach))
603
+ clicked = imgui.invisible_button("view cube", (2 * reach, 2 * reach))
604
+ hovered = viewcube.hit(visible, imgui.get_mouse_pos()) if imgui.is_item_hovered() else None
605
+ if hovered:
606
+ imgui.set_mouse_cursor(imgui.MouseCursor.HAND)
607
+ tooltip(viewcube.name(hovered))
608
+ if clicked:
609
+ viewer.look_from(hovered)
610
+ draw = imgui.get_window_draw_list()
611
+ color = imgui.get_color_u32
612
+ red, green, blue, opacity = CUBE_FACE
613
+ for face in visible:
614
+ shade = 0.4 + 0.6 * face.light
615
+ draw.add_quad_filled(*face.quad, color((red * shade, green * shade, blue * shade, opacity)))
616
+ for direction, quad in face.cells:
617
+ if direction == hovered: # an edge or corner lights up on every face it touches
618
+ draw.add_quad_filled(*quad, color(CUBE_HOVER))
619
+ for face in visible:
620
+ draw.add_quad(*face.quad, color(CUBE_EDGE), 1.0)
621
+ size = imgui.calc_text_size(face.label)
622
+ xs = [x for x, _ in face.quad]
623
+ # A label shows only where it fits: it fades out as its face turns away.
624
+ room = max(xs) - min(xs) - size[0] - 2.0
625
+ alpha = min(1.0, max(0.0, room / 6.0)) if face.facing > 0.3 else 0.0
626
+ if alpha:
627
+ dark = hovered == face.normal
628
+ ink = (0.10, 0.07, 0.0, alpha) if dark else CUBE_TEXT[:3] + (alpha,)
629
+ draw.add_text((round(face.center[0] - size[0] / 2), round(face.center[1] - size[1] / 2)), color(ink),
630
+ face.label)
631
+
632
+
633
+ def statusbar(ws: Workspace) -> None:
634
+ running = ws.child is not None
635
+ if running:
636
+ status = "Running..."
637
+ elif ws.error:
638
+ status = "Error: " + ws.error.strip().splitlines()[-1]
639
+ else:
640
+ took = f" in {ws.duration:.2f} s" if ws.duration is not None else ""
641
+ count = len(ws.shown)
642
+ status = f"{count} object{'' if count == 1 else 's'}{took}"
643
+ imgui.text_colored(ERROR if ws.error and not running else MUTED, status)
644
+ if ws.message:
645
+ imgui.same_line()
646
+ imgui.text_colored(ERROR if ws.message_is_error else MUTED, f" · {ws.message}")
647
+ if ws.code_zoom != 1.0: # the code zoom, right-aligned; a click goes back to 100%
648
+ label = f"{round(ws.code_zoom * 100)}%"
649
+ imgui.same_line(imgui.get_window_width() - imgui.calc_text_size(label)[0] - 6.0)
650
+ imgui.text_colored(MUTED, label)
651
+ if imgui.is_item_clicked():
652
+ ws.zoom_code(0)
653
+ tooltip("Code zoom (click or Ctrl+0: 100%)", "Ctrl+ / Ctrl-")
654
+
655
+
656
+ def save_as_dialog(ws: Workspace) -> None:
657
+ """Ask for a new file name (relative to the current file's folder)."""
658
+ if not imgui.is_popup_open("Save as"):
659
+ imgui.open_popup("Save as")
660
+ ws.save_as_name = f"{ws.path.stem}_copy.py"
661
+ ws.say("")
662
+ if imgui.begin_popup_modal("Save as", flags=imgui.WindowFlags.ALWAYS_AUTO_RESIZE)[0]:
663
+ imgui.text("New file (next to the current one, or a full path):")
664
+ imgui.set_next_item_width(420.0)
665
+ if imgui.is_window_appearing():
666
+ imgui.set_keyboard_focus_here()
667
+ _, ws.save_as_name = imgui.input_text("##name", ws.save_as_name)
668
+ if imgui.button("Save") and ws.save_as(ws.save_as_name):
669
+ ws.save_as_open = False
670
+ imgui.close_current_popup()
671
+ imgui.same_line()
672
+ if imgui.button("Cancel"):
673
+ ws.save_as_open = False
674
+ imgui.close_current_popup()
675
+ if ws.message_is_error:
676
+ imgui.text_colored(ERROR, ws.message)
677
+ imgui.end_popup()