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/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()
|