easycoder 251103.4__py2.py3-none-any.whl → 251105.1__py2.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.
- easycoder/__init__.py +1 -1
- easycoder/ec_border.py +15 -11
- easycoder/ec_classes.py +7 -2
- easycoder/ec_compiler.py +26 -4
- easycoder/ec_core.py +20 -27
- easycoder/ec_debug.py +781 -0
- easycoder/ec_handler.py +2 -1
- easycoder/ec_keyboard.py +50 -50
- easycoder/ec_program.py +44 -10
- easycoder/ec_pyside.py +88 -101
- easycoder/ec_timestamp.py +2 -1
- easycoder/icons/exit.png +0 -0
- easycoder/icons/run.png +0 -0
- easycoder/icons/step.png +0 -0
- easycoder/icons/stop.png +0 -0
- {easycoder-251103.4.dist-info → easycoder-251105.1.dist-info}/METADATA +1 -1
- easycoder-251105.1.dist-info/RECORD +24 -0
- easycoder-251103.4.dist-info/RECORD +0 -19
- /easycoder/{close.png → icons/close.png} +0 -0
- /easycoder/{tick.png → icons/tick.png} +0 -0
- {easycoder-251103.4.dist-info → easycoder-251105.1.dist-info}/WHEEL +0 -0
- {easycoder-251103.4.dist-info → easycoder-251105.1.dist-info}/entry_points.txt +0 -0
- {easycoder-251103.4.dist-info → easycoder-251105.1.dist-info}/licenses/LICENSE +0 -0
easycoder/ec_debug.py
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
import sys, os, json, html
|
|
2
|
+
from PySide6.QtWidgets import (
|
|
3
|
+
QMainWindow,
|
|
4
|
+
QWidget,
|
|
5
|
+
QFrame,
|
|
6
|
+
QHBoxLayout,
|
|
7
|
+
QVBoxLayout,
|
|
8
|
+
QLabel,
|
|
9
|
+
QSplitter,
|
|
10
|
+
QMessageBox,
|
|
11
|
+
QScrollArea,
|
|
12
|
+
QSizePolicy,
|
|
13
|
+
QToolBar,
|
|
14
|
+
QPushButton,
|
|
15
|
+
QInputDialog
|
|
16
|
+
)
|
|
17
|
+
from PySide6.QtGui import QTextCursor, QIcon
|
|
18
|
+
from PySide6.QtCore import Qt, QTimer
|
|
19
|
+
from typing import Any
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
class Object():
|
|
23
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
24
|
+
self.__dict__[name] = value
|
|
25
|
+
|
|
26
|
+
def __getattr__(self, name: str) -> Any:
|
|
27
|
+
return self.__dict__.get(name)
|
|
28
|
+
|
|
29
|
+
class Debugger(QMainWindow):
|
|
30
|
+
# Help type-checkers know these attributes exist
|
|
31
|
+
_flush_timer: Optional[QTimer]
|
|
32
|
+
|
|
33
|
+
class ConsoleWriter:
|
|
34
|
+
def __init__(self, debugger: 'Debugger'):
|
|
35
|
+
self.debugger = debugger
|
|
36
|
+
self._buf: list[str] = []
|
|
37
|
+
|
|
38
|
+
def write(self, text: str):
|
|
39
|
+
if not text:
|
|
40
|
+
return
|
|
41
|
+
# Buffer text and request a flush on the GUI timer
|
|
42
|
+
self._buf.append(text)
|
|
43
|
+
if self.debugger._flush_timer and not self.debugger._flush_timer.isActive():
|
|
44
|
+
self.debugger._flush_timer.start()
|
|
45
|
+
|
|
46
|
+
def flush(self):
|
|
47
|
+
# Explicit flush request
|
|
48
|
+
self.debugger._flush_console_buffer()
|
|
49
|
+
|
|
50
|
+
###########################################################################
|
|
51
|
+
# The left-hand column of the main window
|
|
52
|
+
class MainLeftColumn(QWidget):
|
|
53
|
+
def __init__(self, parent=None):
|
|
54
|
+
super().__init__(parent)
|
|
55
|
+
self.debugger = parent
|
|
56
|
+
layout = QVBoxLayout(self)
|
|
57
|
+
|
|
58
|
+
# Create toolbar with icon buttons
|
|
59
|
+
toolbar = QToolBar()
|
|
60
|
+
toolbar.setMovable(False)
|
|
61
|
+
|
|
62
|
+
# Get the icons directory path
|
|
63
|
+
icons_dir = os.path.join(os.path.dirname(__file__), 'icons')
|
|
64
|
+
|
|
65
|
+
# Run button
|
|
66
|
+
run_btn = QPushButton()
|
|
67
|
+
run_icon_path = os.path.join(icons_dir, 'run.png')
|
|
68
|
+
run_btn.setIcon(QIcon(run_icon_path))
|
|
69
|
+
run_btn.setToolTip("Run")
|
|
70
|
+
run_btn.clicked.connect(self.on_run_clicked)
|
|
71
|
+
toolbar.addWidget(run_btn)
|
|
72
|
+
|
|
73
|
+
# Step button
|
|
74
|
+
step_btn = QPushButton()
|
|
75
|
+
step_icon_path = os.path.join(icons_dir, 'step.png')
|
|
76
|
+
step_btn.setIcon(QIcon(step_icon_path))
|
|
77
|
+
step_btn.setToolTip("Step")
|
|
78
|
+
step_btn.clicked.connect(self.on_step_clicked)
|
|
79
|
+
toolbar.addWidget(step_btn)
|
|
80
|
+
|
|
81
|
+
# Stop button
|
|
82
|
+
stop_btn = QPushButton()
|
|
83
|
+
stop_icon_path = os.path.join(icons_dir, 'stop.png')
|
|
84
|
+
stop_btn.setIcon(QIcon(stop_icon_path))
|
|
85
|
+
stop_btn.setToolTip("Stop")
|
|
86
|
+
stop_btn.clicked.connect(self.on_stop_clicked)
|
|
87
|
+
toolbar.addWidget(stop_btn)
|
|
88
|
+
|
|
89
|
+
# Exit button
|
|
90
|
+
exit_btn = QPushButton()
|
|
91
|
+
exit_icon_path = os.path.join(icons_dir, 'exit.png')
|
|
92
|
+
exit_btn.setIcon(QIcon(exit_icon_path))
|
|
93
|
+
exit_btn.setToolTip("Exit")
|
|
94
|
+
exit_btn.clicked.connect(self.on_exit_clicked)
|
|
95
|
+
toolbar.addWidget(exit_btn)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
layout.addWidget(toolbar)
|
|
99
|
+
|
|
100
|
+
# --- Watch panel (like VS Code) ---
|
|
101
|
+
watch_panel = QFrame()
|
|
102
|
+
watch_panel.setFrameShape(QFrame.Shape.StyledPanel)
|
|
103
|
+
# Ensure the VARIABLES bar stretches to full available width
|
|
104
|
+
watch_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
105
|
+
watch_layout = QHBoxLayout(watch_panel)
|
|
106
|
+
watch_layout.setContentsMargins(4, 4, 4, 4)
|
|
107
|
+
watch_layout.setSpacing(4)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Title label
|
|
111
|
+
title_label = QLabel("VARIABLES")
|
|
112
|
+
title_label.setStyleSheet("font-weight: bold; letter-spacing: 1px;")
|
|
113
|
+
watch_layout.addWidget(title_label)
|
|
114
|
+
|
|
115
|
+
# Stretch to push buttons right
|
|
116
|
+
watch_layout.addStretch()
|
|
117
|
+
|
|
118
|
+
# Placeholder add/remove icons (replace with real icons later)
|
|
119
|
+
add_btn = QPushButton()
|
|
120
|
+
add_btn.setToolTip("Add variable to watch")
|
|
121
|
+
# TODO: set add_btn.setIcon(QIcon(path)) when icon is available
|
|
122
|
+
add_btn.setText("+")
|
|
123
|
+
add_btn.setFixedSize(24, 24)
|
|
124
|
+
add_btn.clicked.connect(self.on_add_clicked)
|
|
125
|
+
watch_layout.addWidget(add_btn)
|
|
126
|
+
|
|
127
|
+
layout.addWidget(watch_panel)
|
|
128
|
+
|
|
129
|
+
# Watch list area (renders selected variables beneath the toolbar)
|
|
130
|
+
self.watch_list_widget = QWidget()
|
|
131
|
+
self.watch_list_layout = QVBoxLayout(self.watch_list_widget)
|
|
132
|
+
self.watch_list_layout.setContentsMargins(6, 2, 6, 2)
|
|
133
|
+
self.watch_list_layout.setSpacing(2)
|
|
134
|
+
layout.addWidget(self.watch_list_widget)
|
|
135
|
+
|
|
136
|
+
# Keep a simple set to prevent duplicate labels
|
|
137
|
+
self._watch_set = set()
|
|
138
|
+
|
|
139
|
+
layout.addStretch()
|
|
140
|
+
|
|
141
|
+
def on_add_clicked(self):
|
|
142
|
+
# Build the variable list from the program. Prefer Program.symbols mapping.
|
|
143
|
+
try:
|
|
144
|
+
program = self.debugger.program # type: ignore[attr-defined]
|
|
145
|
+
# Fallback to scanning code if symbols is empty
|
|
146
|
+
items = []
|
|
147
|
+
if hasattr(program, 'symbols') and isinstance(program.symbols, dict) and program.symbols:
|
|
148
|
+
items = sorted([name for name in program.symbols.keys() if name and not name.endswith(':')])
|
|
149
|
+
else:
|
|
150
|
+
# Fallback heuristic: look for commands whose 'type' == 'symbol' (as per requirement)
|
|
151
|
+
for cmd in getattr(program, 'code', []):
|
|
152
|
+
try:
|
|
153
|
+
if cmd.get('type') == 'symbol' and 'name' in cmd:
|
|
154
|
+
items.append(cmd['name'])
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
items = sorted(set(items))
|
|
158
|
+
if not items:
|
|
159
|
+
QMessageBox.information(self, "Add Watch", "No variables found in this program.")
|
|
160
|
+
return
|
|
161
|
+
choice, ok = QInputDialog.getItem(self, "Add Watch", "Select a variable:", items, 0, False)
|
|
162
|
+
if ok and choice:
|
|
163
|
+
# Record the choice for future use (UI for list will be added later)
|
|
164
|
+
if not hasattr(self.debugger, 'watched'):
|
|
165
|
+
self.debugger.watched = [] # type: ignore[attr-defined]
|
|
166
|
+
if choice not in self.debugger.watched: # type: ignore[attr-defined]
|
|
167
|
+
self.debugger.watched.append(choice) # type: ignore[attr-defined]
|
|
168
|
+
# Render as a plain label beneath the toolbar if not already present
|
|
169
|
+
if choice not in self._watch_set:
|
|
170
|
+
self._add_watch_row(choice)
|
|
171
|
+
self._watch_set.add(choice)
|
|
172
|
+
# Optionally echo to console for now
|
|
173
|
+
try:
|
|
174
|
+
self.debugger.console.append(f"Watching: {choice}") # type: ignore[attr-defined]
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
QMessageBox.warning(self, "Add Watch", f"Could not list variables: {exc}")
|
|
179
|
+
|
|
180
|
+
def _add_watch_row(self, name: str):
|
|
181
|
+
row = QWidget()
|
|
182
|
+
h = QHBoxLayout(row)
|
|
183
|
+
h.setContentsMargins(0, 0, 0, 0)
|
|
184
|
+
h.setSpacing(4)
|
|
185
|
+
lbl = QLabel(name)
|
|
186
|
+
lbl.setStyleSheet("font-family: mono; padding: 1px 2px;")
|
|
187
|
+
h.addWidget(lbl)
|
|
188
|
+
h.addStretch()
|
|
189
|
+
btn = QPushButton()
|
|
190
|
+
btn.setText("–") # placeholder until icon provided
|
|
191
|
+
btn.setToolTip(f"Remove '{name}' from watch")
|
|
192
|
+
btn.setFixedSize(20, 20)
|
|
193
|
+
|
|
194
|
+
def on_remove():
|
|
195
|
+
try:
|
|
196
|
+
# update internal structures
|
|
197
|
+
if hasattr(self.debugger, 'watched') and name in self.debugger.watched: # type: ignore[attr-defined]
|
|
198
|
+
self.debugger.watched.remove(name) # type: ignore[attr-defined]
|
|
199
|
+
if name in self._watch_set:
|
|
200
|
+
self._watch_set.remove(name)
|
|
201
|
+
# remove row from layout/UI
|
|
202
|
+
row.setParent(None)
|
|
203
|
+
row.deleteLater()
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
btn.clicked.connect(on_remove)
|
|
208
|
+
h.addWidget(btn)
|
|
209
|
+
self.watch_list_layout.addWidget(row)
|
|
210
|
+
|
|
211
|
+
def on_run_clicked(self):
|
|
212
|
+
self.debugger.doRun() # type: ignore[attr-defined]
|
|
213
|
+
|
|
214
|
+
def on_step_clicked(self):
|
|
215
|
+
self.debugger.doStep() # type: ignore[attr-defined]
|
|
216
|
+
|
|
217
|
+
def on_stop_clicked(self):
|
|
218
|
+
self.debugger.doStop() # type: ignore[attr-defined]
|
|
219
|
+
|
|
220
|
+
def on_exit_clicked(self):
|
|
221
|
+
self.debugger.doClose() # type: ignore[attr-defined]
|
|
222
|
+
|
|
223
|
+
###########################################################################
|
|
224
|
+
# The right-hand column of the main window
|
|
225
|
+
class MainRightColumn(QWidget):
|
|
226
|
+
scroll: QScrollArea
|
|
227
|
+
layout: QHBoxLayout # type: ignore[assignment]
|
|
228
|
+
blob: QLabel
|
|
229
|
+
|
|
230
|
+
def __init__(self, parent=None):
|
|
231
|
+
super().__init__(parent)
|
|
232
|
+
|
|
233
|
+
# Create a scroll area - its content widget holds the lines
|
|
234
|
+
self.scroll = QScrollArea(self)
|
|
235
|
+
self.scroll.setWidgetResizable(True)
|
|
236
|
+
|
|
237
|
+
# Ensure this widget and the scroll area expand to fill available space
|
|
238
|
+
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
239
|
+
self.scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
240
|
+
|
|
241
|
+
self.content = QWidget()
|
|
242
|
+
# let the content expand horizontally but have flexible height
|
|
243
|
+
self.content.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
244
|
+
|
|
245
|
+
self.inner_layout = QVBoxLayout(self.content)
|
|
246
|
+
# spacing and small top/bottom margins to separate lines
|
|
247
|
+
self.inner_layout.setSpacing(0)
|
|
248
|
+
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
|
249
|
+
|
|
250
|
+
self.scroll.setWidget(self.content)
|
|
251
|
+
|
|
252
|
+
# outer layout for this widget contains only the scroll area
|
|
253
|
+
main_layout = QVBoxLayout(self)
|
|
254
|
+
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
255
|
+
main_layout.addWidget(self.scroll)
|
|
256
|
+
# ensure the scroll area gets the stretch so it fills the parent
|
|
257
|
+
main_layout.setStretch(0, 1)
|
|
258
|
+
|
|
259
|
+
#######################################################################
|
|
260
|
+
# Add a line to the right-hand column
|
|
261
|
+
def addLine(self, spec):
|
|
262
|
+
|
|
263
|
+
# Determine if this line is a command (not empty, not a comment), using the original script line
|
|
264
|
+
orig_line = getattr(spec, 'orig_line', spec.line) if hasattr(spec, 'orig_line') or 'orig_line' in spec.__dict__ else spec.line
|
|
265
|
+
line_lstripped = orig_line.lstrip()
|
|
266
|
+
is_command = bool(line_lstripped and not line_lstripped.startswith('!'))
|
|
267
|
+
|
|
268
|
+
class Label(QLabel):
|
|
269
|
+
def __init__(self, text, fixed_width=None, align=Qt.AlignmentFlag.AlignLeft, on_click=None):
|
|
270
|
+
super().__init__()
|
|
271
|
+
self.setText(text)
|
|
272
|
+
self.setMargin(0)
|
|
273
|
+
self.setContentsMargins(0, 0, 0, 0)
|
|
274
|
+
self.setStyleSheet("padding:0px; margin:0px; font-family: mono")
|
|
275
|
+
fm = self.fontMetrics()
|
|
276
|
+
self.setFixedHeight(fm.height())
|
|
277
|
+
if fixed_width is not None:
|
|
278
|
+
self.setFixedWidth(fixed_width)
|
|
279
|
+
self.setAlignment(align | Qt.AlignmentFlag.AlignVCenter)
|
|
280
|
+
self._on_click = on_click if is_command else None
|
|
281
|
+
|
|
282
|
+
def mousePressEvent(self, event):
|
|
283
|
+
if self._on_click:
|
|
284
|
+
try:
|
|
285
|
+
self._on_click()
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
super().mousePressEvent(event)
|
|
289
|
+
|
|
290
|
+
spec.label = self
|
|
291
|
+
panel = QWidget()
|
|
292
|
+
# ensure the panel itself has no margins
|
|
293
|
+
try:
|
|
294
|
+
panel.setContentsMargins(0, 0, 0, 0)
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
# tidy layout: remove spacing/margins so lines sit flush
|
|
298
|
+
layout = QHBoxLayout(panel)
|
|
299
|
+
layout.setSpacing(0)
|
|
300
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
301
|
+
self.layout: QHBoxLayout = layout # type: ignore
|
|
302
|
+
# make panel take minimal vertical space
|
|
303
|
+
panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
304
|
+
# compute width to fit a 4-digit line number using this widget's font
|
|
305
|
+
fm_main = self.fontMetrics()
|
|
306
|
+
width_4 = fm_main.horizontalAdvance('0000') + 8
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# create the red blob (always present). We'll toggle its opacity
|
|
310
|
+
# by changing the stylesheet (rgba alpha 255/0). Do NOT store it
|
|
311
|
+
# on the MainRightColumn instance — keep it per-line.
|
|
312
|
+
|
|
313
|
+
class ClickableBlob(QLabel):
|
|
314
|
+
def __init__(self, on_click=None):
|
|
315
|
+
super().__init__()
|
|
316
|
+
self._on_click = on_click if is_command else None
|
|
317
|
+
def mousePressEvent(self, event):
|
|
318
|
+
if self._on_click:
|
|
319
|
+
try:
|
|
320
|
+
self._on_click()
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
super().mousePressEvent(event)
|
|
324
|
+
|
|
325
|
+
blob_size = 10
|
|
326
|
+
blob = ClickableBlob(on_click=(lambda: spec.onClick(spec.lino)) if is_command else None)
|
|
327
|
+
blob.setFixedSize(blob_size, blob_size)
|
|
328
|
+
|
|
329
|
+
def set_blob_visible(widget, visible):
|
|
330
|
+
alpha = 255 if visible else 0
|
|
331
|
+
widget.setStyleSheet(f"background-color: rgba(255,0,0,{alpha}); border-radius: {blob_size//2}px; margin:0px; padding:0px;")
|
|
332
|
+
widget._blob_visible = visible
|
|
333
|
+
# force repaint
|
|
334
|
+
widget.update()
|
|
335
|
+
|
|
336
|
+
# attach methods to this blob so callers can toggle it via spec.label
|
|
337
|
+
blob.showBlob = lambda: set_blob_visible(blob, True) # type: ignore[attr-defined]
|
|
338
|
+
blob.hideBlob = lambda: set_blob_visible(blob, False) # type: ignore[attr-defined]
|
|
339
|
+
|
|
340
|
+
# initialize according to spec flag
|
|
341
|
+
if spec.bp:
|
|
342
|
+
blob.showBlob() # type: ignore[attr-defined]
|
|
343
|
+
else:
|
|
344
|
+
blob.hideBlob() # type: ignore[attr-defined]
|
|
345
|
+
|
|
346
|
+
# expose the blob to the outside via spec['label'] so onClick can call showBlob/hideBlob
|
|
347
|
+
spec.label = blob
|
|
348
|
+
|
|
349
|
+
# create the line-number label; clicking it reports back to the caller
|
|
350
|
+
lino_label = Label(str(spec.lino+1), fixed_width=width_4, align=Qt.AlignmentFlag.AlignRight,
|
|
351
|
+
on_click=(lambda: spec.onClick(spec.lino)) if is_command else None)
|
|
352
|
+
lino_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|
353
|
+
# create the text label for the line itself
|
|
354
|
+
text_label = Label(spec.line, fixed_width=None, align=Qt.AlignmentFlag.AlignLeft)
|
|
355
|
+
text_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
356
|
+
layout.addWidget(lino_label)
|
|
357
|
+
layout.addSpacing(10)
|
|
358
|
+
layout.addWidget(blob, 0, Qt.AlignmentFlag.AlignVCenter)
|
|
359
|
+
layout.addSpacing(3)
|
|
360
|
+
layout.addWidget(text_label)
|
|
361
|
+
self.inner_layout.addWidget(panel)
|
|
362
|
+
return panel
|
|
363
|
+
|
|
364
|
+
def showBlob(self):
|
|
365
|
+
self.blob.setStyleSheet("background-color: red; border-radius: 5px; margin:0px; padding:0px;")
|
|
366
|
+
|
|
367
|
+
def hideBlob(self):
|
|
368
|
+
self.blob.setStyleSheet("background-color: none; border-radius: 5px; margin:0px; padding:0px;")
|
|
369
|
+
|
|
370
|
+
def addStretch(self):
|
|
371
|
+
self.layout.addStretch()
|
|
372
|
+
|
|
373
|
+
###########################################################################
|
|
374
|
+
# Main debugger class initializer
|
|
375
|
+
def __init__(self, program, width=800, height=600, ratio=0.2):
|
|
376
|
+
super().__init__()
|
|
377
|
+
self.program = program
|
|
378
|
+
self.setWindowTitle("EasyCoder Debugger")
|
|
379
|
+
self.setMinimumSize(width, height)
|
|
380
|
+
# Disable the window close button
|
|
381
|
+
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
|
382
|
+
self.stopped = True
|
|
383
|
+
|
|
384
|
+
# try to load saved geometry from ~/.ecdebug.conf
|
|
385
|
+
cfg_path = os.path.join(os.path.expanduser("~"), ".ecdebug.conf")
|
|
386
|
+
initial_width = width
|
|
387
|
+
# default console height (pixels) if not stored in cfg
|
|
388
|
+
console_height = 150
|
|
389
|
+
try:
|
|
390
|
+
if os.path.exists(cfg_path):
|
|
391
|
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
392
|
+
cfg = json.load(f)
|
|
393
|
+
x = int(cfg.get("x", 0))
|
|
394
|
+
y = int(cfg.get("y", 0))
|
|
395
|
+
w = int(cfg.get("width", width))
|
|
396
|
+
h = int(cfg.get("height", height))
|
|
397
|
+
ratio =float(cfg.get("ratio", ratio))
|
|
398
|
+
# load console height if present
|
|
399
|
+
console_height = int(cfg.get("console_height", console_height))
|
|
400
|
+
# Apply loaded geometry
|
|
401
|
+
self.setGeometry(x, y, w, h)
|
|
402
|
+
initial_width = w
|
|
403
|
+
except Exception:
|
|
404
|
+
# ignore errors and continue with defaults
|
|
405
|
+
initial_width = width
|
|
406
|
+
|
|
407
|
+
# process handle for running scripts
|
|
408
|
+
self._proc = None
|
|
409
|
+
# in-process Program instance and writer
|
|
410
|
+
self._program = None
|
|
411
|
+
self._writer = None
|
|
412
|
+
self._orig_stdout = None
|
|
413
|
+
self._orig_stderr = None
|
|
414
|
+
self._flush_timer = QTimer(self)
|
|
415
|
+
self._flush_timer.setInterval(50)
|
|
416
|
+
self._flush_timer.timeout.connect(self._flush_console_buffer)
|
|
417
|
+
self._flush_timer.stop()
|
|
418
|
+
|
|
419
|
+
# Keep a ratio so proportions are preserved when window is resized
|
|
420
|
+
self.ratio = ratio
|
|
421
|
+
|
|
422
|
+
# Central horizontal splitter (left/right)
|
|
423
|
+
self.hsplitter = QSplitter(Qt.Orientation.Horizontal, self)
|
|
424
|
+
self.hsplitter.setHandleWidth(8)
|
|
425
|
+
self.hsplitter.splitterMoved.connect(self.on_splitter_moved)
|
|
426
|
+
|
|
427
|
+
# Left pane
|
|
428
|
+
left = QFrame()
|
|
429
|
+
left.setFrameShape(QFrame.Shape.StyledPanel)
|
|
430
|
+
left_layout = QVBoxLayout(left)
|
|
431
|
+
left_layout.setContentsMargins(8, 8, 8, 8)
|
|
432
|
+
self.leftColumn = self.MainLeftColumn(self)
|
|
433
|
+
left_layout.addWidget(self.leftColumn)
|
|
434
|
+
left_layout.addStretch()
|
|
435
|
+
|
|
436
|
+
# Right pane
|
|
437
|
+
right = QFrame()
|
|
438
|
+
right.setFrameShape(QFrame.Shape.StyledPanel)
|
|
439
|
+
right_layout = QVBoxLayout(right)
|
|
440
|
+
right_layout.setContentsMargins(8, 8, 8, 8)
|
|
441
|
+
self.rightColumn = self.MainRightColumn(self)
|
|
442
|
+
# Give the rightColumn a stretch factor so its scroll area fills the vertical space
|
|
443
|
+
right_layout.addWidget(self.rightColumn, 1)
|
|
444
|
+
|
|
445
|
+
# Add panes to horizontal splitter
|
|
446
|
+
self.hsplitter.addWidget(left)
|
|
447
|
+
self.hsplitter.addWidget(right)
|
|
448
|
+
|
|
449
|
+
# Initial sizes (proportional) for horizontal splitter
|
|
450
|
+
total = initial_width
|
|
451
|
+
self.hsplitter.setSizes([int(self.ratio * total), int((1 - self.ratio) * total)])
|
|
452
|
+
|
|
453
|
+
# Create a vertical splitter so we can add a resizable console panel at the bottom
|
|
454
|
+
self.vsplitter = QSplitter(Qt.Orientation.Vertical, self)
|
|
455
|
+
self.vsplitter.setHandleWidth(6)
|
|
456
|
+
# top: the existing horizontal splitter
|
|
457
|
+
self.vsplitter.addWidget(self.hsplitter)
|
|
458
|
+
|
|
459
|
+
# bottom: console panel
|
|
460
|
+
console_frame = QFrame()
|
|
461
|
+
console_frame.setFrameShape(QFrame.Shape.StyledPanel)
|
|
462
|
+
console_layout = QVBoxLayout(console_frame)
|
|
463
|
+
console_layout.setContentsMargins(4, 4, 4, 4)
|
|
464
|
+
# simple read-only text console for script output and messages
|
|
465
|
+
from PySide6.QtWidgets import QTextEdit
|
|
466
|
+
self.console = QTextEdit()
|
|
467
|
+
self.console.setReadOnly(True)
|
|
468
|
+
console_layout.addWidget(self.console)
|
|
469
|
+
self.vsplitter.addWidget(console_frame)
|
|
470
|
+
|
|
471
|
+
# Redirect stdout/stderr so all program output is captured in the console
|
|
472
|
+
try:
|
|
473
|
+
self._orig_stdout = sys.stdout
|
|
474
|
+
self._orig_stderr = sys.stderr
|
|
475
|
+
self._writer = self.ConsoleWriter(self)
|
|
476
|
+
sys.stdout = self._writer # type: ignore[assignment]
|
|
477
|
+
sys.stderr = self._writer # type: ignore[assignment]
|
|
478
|
+
except Exception:
|
|
479
|
+
# Best effort; if redirection fails, continue without it
|
|
480
|
+
self._writer = None
|
|
481
|
+
|
|
482
|
+
# Set initial vertical sizes: prefer saved console_height if available
|
|
483
|
+
try:
|
|
484
|
+
total_h = int(h) if 'h' in locals() else max(300, self.height())
|
|
485
|
+
ch = max(50, min(total_h - 50, console_height))
|
|
486
|
+
self.vsplitter.setSizes([int(total_h - ch), int(ch)])
|
|
487
|
+
except Exception:
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
# Use the vertical splitter as the central widget
|
|
491
|
+
self.setCentralWidget(self.vsplitter)
|
|
492
|
+
self.parse(program.script.lines)
|
|
493
|
+
self.show()
|
|
494
|
+
|
|
495
|
+
def _flush_console_buffer(self):
|
|
496
|
+
try:
|
|
497
|
+
writer = self._writer
|
|
498
|
+
if not writer:
|
|
499
|
+
return
|
|
500
|
+
if getattr(writer, '_buf', None):
|
|
501
|
+
text = ''.join(writer._buf)
|
|
502
|
+
writer._buf.clear()
|
|
503
|
+
# Append to the console and scroll to bottom
|
|
504
|
+
self.console.moveCursor(QTextCursor.MoveOperation.End)
|
|
505
|
+
self.console.insertPlainText(text)
|
|
506
|
+
self.console.moveCursor(QTextCursor.MoveOperation.End)
|
|
507
|
+
except Exception:
|
|
508
|
+
pass
|
|
509
|
+
|
|
510
|
+
def on_splitter_moved(self, pos, index):
|
|
511
|
+
# Update stored ratio when user drags the splitter
|
|
512
|
+
left_width = self.hsplitter.widget(0).width()
|
|
513
|
+
total = max(1, sum(w.width() for w in (self.hsplitter.widget(0), self.hsplitter.widget(1))))
|
|
514
|
+
self.ratio = left_width / total
|
|
515
|
+
|
|
516
|
+
def resizeEvent(self, event):
|
|
517
|
+
# Preserve the proportional widths when the window is resized
|
|
518
|
+
total_width = max(1, self.width())
|
|
519
|
+
left_w = max(0, int(self.ratio * total_width))
|
|
520
|
+
right_w = max(0, total_width - left_w)
|
|
521
|
+
self.hsplitter.setSizes([left_w, right_w])
|
|
522
|
+
super().resizeEvent(event)
|
|
523
|
+
|
|
524
|
+
###########################################################################
|
|
525
|
+
# Parse a script into the right-hand column
|
|
526
|
+
def parse(self, script):
|
|
527
|
+
self.scriptLines = []
|
|
528
|
+
# Clear existing lines from the right column layout
|
|
529
|
+
layout = self.rightColumn.inner_layout
|
|
530
|
+
while layout.count():
|
|
531
|
+
item = layout.takeAt(0)
|
|
532
|
+
widget = item.widget()
|
|
533
|
+
if widget:
|
|
534
|
+
widget.deleteLater()
|
|
535
|
+
|
|
536
|
+
# Parse and add new lines
|
|
537
|
+
lino = 0
|
|
538
|
+
for line in script:
|
|
539
|
+
orig_line = line
|
|
540
|
+
if len(line) > 0:
|
|
541
|
+
line = line.replace("\t", " ")
|
|
542
|
+
color_line = self.coloriseLine(line, lino)
|
|
543
|
+
else:
|
|
544
|
+
# still need to call coloriseLine to keep token list in sync
|
|
545
|
+
color_line = self.coloriseLine(line, lino)
|
|
546
|
+
lineSpec = Object()
|
|
547
|
+
lineSpec.lino = lino
|
|
548
|
+
lineSpec.line = color_line
|
|
549
|
+
lineSpec.orig_line = orig_line
|
|
550
|
+
lineSpec.bp = False
|
|
551
|
+
lineSpec.onClick = self.onClickLino
|
|
552
|
+
lino += 1
|
|
553
|
+
self.scriptLines.append(lineSpec)
|
|
554
|
+
lineSpec.panel = self.rightColumn.addLine(lineSpec)
|
|
555
|
+
self.rightColumn.addStretch()
|
|
556
|
+
|
|
557
|
+
###########################################################################
|
|
558
|
+
# Colorise a line of script for HTML display
|
|
559
|
+
def coloriseLine(self, line, lino=None):
|
|
560
|
+
output = ''
|
|
561
|
+
|
|
562
|
+
# Preserve leading spaces (render as except the first)
|
|
563
|
+
if len(line) > 0 and line[0] == ' ':
|
|
564
|
+
output += '<span>'
|
|
565
|
+
n = 0
|
|
566
|
+
while n < len(line) and line[n] == ' ': n += 1
|
|
567
|
+
output += ' ' * (n - 1)
|
|
568
|
+
output += '</span>'
|
|
569
|
+
|
|
570
|
+
# Find the first unquoted ! (not inside backticks)
|
|
571
|
+
comment_start = None
|
|
572
|
+
in_backtick = False
|
|
573
|
+
for idx, c in enumerate(line):
|
|
574
|
+
if c == '`':
|
|
575
|
+
in_backtick = not in_backtick
|
|
576
|
+
elif c == '!' and not in_backtick:
|
|
577
|
+
comment_start = idx
|
|
578
|
+
break
|
|
579
|
+
|
|
580
|
+
if comment_start is not None:
|
|
581
|
+
code_part = line[:comment_start]
|
|
582
|
+
comment_part = line[comment_start:]
|
|
583
|
+
else:
|
|
584
|
+
code_part = line
|
|
585
|
+
comment_part = None
|
|
586
|
+
|
|
587
|
+
# Tokenize code_part as before (respecting backticks)
|
|
588
|
+
tokens = []
|
|
589
|
+
i = 0
|
|
590
|
+
L = len(code_part)
|
|
591
|
+
while i < L:
|
|
592
|
+
if code_part[i].isspace():
|
|
593
|
+
i += 1
|
|
594
|
+
continue
|
|
595
|
+
if code_part[i] == '`':
|
|
596
|
+
j = code_part.find('`', i + 1)
|
|
597
|
+
if j == -1:
|
|
598
|
+
tokens.append(code_part[i:])
|
|
599
|
+
break
|
|
600
|
+
else:
|
|
601
|
+
tokens.append(code_part[i:j+1])
|
|
602
|
+
i = j + 1
|
|
603
|
+
else:
|
|
604
|
+
j = i
|
|
605
|
+
while j < L and not code_part[j].isspace():
|
|
606
|
+
j += 1
|
|
607
|
+
tokens.append(code_part[i:j])
|
|
608
|
+
i = j
|
|
609
|
+
|
|
610
|
+
# Colour code tokens and generate a list of elements
|
|
611
|
+
for token in tokens:
|
|
612
|
+
if token == '':
|
|
613
|
+
continue
|
|
614
|
+
elif token[0].isupper():
|
|
615
|
+
esc = html.escape(token)
|
|
616
|
+
element = f' <span style="color: purple; font-weight: bold;">{esc}</span>'
|
|
617
|
+
elif token[0].isdigit():
|
|
618
|
+
esc = html.escape(token)
|
|
619
|
+
element = f' <span style="color: green;">{esc}</span>'
|
|
620
|
+
elif token[0] == '`':
|
|
621
|
+
esc = html.escape(token)
|
|
622
|
+
element = f' <span style="color: peru;">{esc}</span>'
|
|
623
|
+
else:
|
|
624
|
+
esc = html.escape(token)
|
|
625
|
+
element = f' <span>{esc}</span>'
|
|
626
|
+
output += element
|
|
627
|
+
# Colour comment if present
|
|
628
|
+
if comment_part is not None:
|
|
629
|
+
esc = html.escape(comment_part)
|
|
630
|
+
output += f'<span style="color: green;"> {esc}</span>'
|
|
631
|
+
|
|
632
|
+
return output
|
|
633
|
+
|
|
634
|
+
###########################################################################
|
|
635
|
+
# Here when the user clicks a line number
|
|
636
|
+
def onClickLino(self, lino):
|
|
637
|
+
# Show or hide the red blob next to this line
|
|
638
|
+
lineSpec = self.scriptLines[lino]
|
|
639
|
+
lineSpec.bp = not lineSpec.bp
|
|
640
|
+
if lineSpec.bp: lineSpec.label.showBlob()
|
|
641
|
+
else: lineSpec.label.hideBlob()
|
|
642
|
+
# Set or clear a breakpoint on this command
|
|
643
|
+
for command in self.program.code:
|
|
644
|
+
if 'lino' in command and command['lino'] == lino:
|
|
645
|
+
command['bp'] = lineSpec.bp
|
|
646
|
+
break
|
|
647
|
+
|
|
648
|
+
###########################################################################
|
|
649
|
+
# Scroll to a given line number
|
|
650
|
+
def scrollTo(self, lino):
|
|
651
|
+
# Ensure the line number is valid
|
|
652
|
+
if lino < 0 or lino >= len(self.scriptLines):
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
# Get the panel widget for this line
|
|
656
|
+
lineSpec = self.scriptLines[lino]
|
|
657
|
+
panel = lineSpec.panel
|
|
658
|
+
|
|
659
|
+
if not panel:
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
# Get the scroll area from the right column
|
|
663
|
+
scroll_area = self.rightColumn.scroll
|
|
664
|
+
|
|
665
|
+
# Get the vertical position of the panel relative to the content widget
|
|
666
|
+
panel_y = panel.y()
|
|
667
|
+
panel_height = panel.height()
|
|
668
|
+
|
|
669
|
+
# Get the viewport height (visible area)
|
|
670
|
+
viewport_height = scroll_area.viewport().height()
|
|
671
|
+
|
|
672
|
+
# Calculate the target scroll position to center the panel
|
|
673
|
+
# We want the panel's center to align with the viewport's center
|
|
674
|
+
target_scroll = panel_y + (panel_height // 2) - (viewport_height // 2)
|
|
675
|
+
|
|
676
|
+
# Clamp to valid scroll range
|
|
677
|
+
scrollbar = scroll_area.verticalScrollBar()
|
|
678
|
+
target_scroll = max(scrollbar.minimum(), min(target_scroll, scrollbar.maximum()))
|
|
679
|
+
|
|
680
|
+
# Smoothly scroll to the target position
|
|
681
|
+
scrollbar.setValue(target_scroll)
|
|
682
|
+
|
|
683
|
+
# Bring the window to the front
|
|
684
|
+
self.raise_()
|
|
685
|
+
self.activateWindow()
|
|
686
|
+
|
|
687
|
+
###########################################################################
|
|
688
|
+
# Set the background color of one line of the script
|
|
689
|
+
def setBackground(self, lino, color):
|
|
690
|
+
# Set the background color of the given line
|
|
691
|
+
if lino < 0 or lino >= len(self.scriptLines):
|
|
692
|
+
return
|
|
693
|
+
lineSpec = self.scriptLines[lino]
|
|
694
|
+
panel = lineSpec.panel
|
|
695
|
+
if not panel:
|
|
696
|
+
return
|
|
697
|
+
if color == 'none':
|
|
698
|
+
panel.setStyleSheet("")
|
|
699
|
+
else:
|
|
700
|
+
panel.setStyleSheet(f"background-color: {color};")
|
|
701
|
+
|
|
702
|
+
###########################################################################
|
|
703
|
+
# Here after each instruction has run
|
|
704
|
+
def continueExecution(self):
|
|
705
|
+
result = True
|
|
706
|
+
self.pc = self.program.pc
|
|
707
|
+
command = self.program.code[self.pc]
|
|
708
|
+
lino = command['lino'] + 1
|
|
709
|
+
if self.stopped: result = False
|
|
710
|
+
elif command['bp']:
|
|
711
|
+
print(f"Hit breakpoint at line {lino}")
|
|
712
|
+
self.stopped = True
|
|
713
|
+
result = False
|
|
714
|
+
if not result:
|
|
715
|
+
self.scrollTo(lino)
|
|
716
|
+
self.setBackground(command['lino'], 'LightYellow')
|
|
717
|
+
return result
|
|
718
|
+
|
|
719
|
+
def doRun(self):
|
|
720
|
+
self.stopped = False
|
|
721
|
+
print("Continuing execution at line", self.program.pc + 1)
|
|
722
|
+
self.program.run(self.pc)
|
|
723
|
+
|
|
724
|
+
def doStep(self):
|
|
725
|
+
command = self.program.code[self.pc]
|
|
726
|
+
# print("Stepping at line", command['lino'] + 1)
|
|
727
|
+
self.setBackground(command['lino'], 'none')
|
|
728
|
+
self.program.run(self.pc)
|
|
729
|
+
|
|
730
|
+
def doStop(self):
|
|
731
|
+
self.stopped = True
|
|
732
|
+
|
|
733
|
+
def doClose(self):
|
|
734
|
+
self.closeEvent(None)
|
|
735
|
+
|
|
736
|
+
###########################################################################
|
|
737
|
+
# Override closeEvent to save window geometry
|
|
738
|
+
def closeEvent(self, event):
|
|
739
|
+
"""Save window position and size to ~/.ecdebug.conf as JSON on exit."""
|
|
740
|
+
cfg = {
|
|
741
|
+
"x": self.x(),
|
|
742
|
+
"y": self.y(),
|
|
743
|
+
"width": self.width(),
|
|
744
|
+
"height": self.height(),
|
|
745
|
+
"ratio": self.ratio
|
|
746
|
+
}
|
|
747
|
+
# try to persist console height (bottom pane) if present
|
|
748
|
+
try:
|
|
749
|
+
ch = None
|
|
750
|
+
if hasattr(self, 'vsplitter'):
|
|
751
|
+
sizes = self.vsplitter.sizes()
|
|
752
|
+
if len(sizes) >= 2:
|
|
753
|
+
ch = int(sizes[1])
|
|
754
|
+
if ch is not None:
|
|
755
|
+
cfg['console_height'] = ch
|
|
756
|
+
except Exception:
|
|
757
|
+
pass
|
|
758
|
+
try:
|
|
759
|
+
cfg_path = os.path.join(os.path.expanduser("~"), ".ecdebug.conf")
|
|
760
|
+
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
761
|
+
json.dump(cfg, f, indent=2)
|
|
762
|
+
except Exception as exc:
|
|
763
|
+
# best-effort only; avoid blocking shutdown
|
|
764
|
+
try:
|
|
765
|
+
self.statusBar().showMessage(f"Could not save config: {exc}", 3000)
|
|
766
|
+
except Exception:
|
|
767
|
+
pass
|
|
768
|
+
# Restore stdout/stderr and stop timers
|
|
769
|
+
try:
|
|
770
|
+
if self._orig_stdout is not None:
|
|
771
|
+
sys.stdout = self._orig_stdout
|
|
772
|
+
if self._orig_stderr is not None:
|
|
773
|
+
sys.stderr = self._orig_stderr
|
|
774
|
+
if self._flush_timer is not None:
|
|
775
|
+
try:
|
|
776
|
+
self._flush_timer.stop()
|
|
777
|
+
except Exception:
|
|
778
|
+
pass
|
|
779
|
+
except Exception:
|
|
780
|
+
pass
|
|
781
|
+
super().close()
|