easycoder 251104.2__py2.py3-none-any.whl → 260110.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.

Potentially problematic release.


This version of easycoder might be problematic. Click here for more details.

Files changed (60) hide show
  1. easycoder/__init__.py +5 -3
  2. easycoder/debugger/__init__.py +5 -0
  3. easycoder/debugger/ec_dbg_value_display copy.py +195 -0
  4. easycoder/debugger/ec_dbg_value_display.py +24 -0
  5. easycoder/debugger/ec_dbg_watch_list copy.py +219 -0
  6. easycoder/debugger/ec_dbg_watchlist.py +293 -0
  7. easycoder/debugger/ec_debug.py +1025 -0
  8. easycoder/ec_border.py +15 -11
  9. easycoder/ec_classes.py +487 -11
  10. easycoder/ec_compiler.py +81 -44
  11. easycoder/ec_condition.py +1 -1
  12. easycoder/ec_core.py +1044 -1090
  13. easycoder/ec_gclasses.py +236 -0
  14. easycoder/ec_graphics.py +1683 -0
  15. easycoder/ec_handler.py +18 -13
  16. easycoder/ec_keyboard.py +50 -50
  17. easycoder/ec_program.py +299 -156
  18. easycoder/ec_psutil.py +48 -0
  19. easycoder/ec_timestamp.py +2 -1
  20. easycoder/ec_value.py +65 -47
  21. easycoder/icons/exit.png +0 -0
  22. easycoder/icons/run.png +0 -0
  23. easycoder/icons/step.png +0 -0
  24. easycoder/icons/stop.png +0 -0
  25. easycoder/pre/README.md +3 -0
  26. easycoder/pre/__init__.py +17 -0
  27. easycoder/pre/debugger/__init__.py +5 -0
  28. easycoder/pre/debugger/ec_dbg_value_display copy.py +195 -0
  29. easycoder/pre/debugger/ec_dbg_value_display.py +24 -0
  30. easycoder/pre/debugger/ec_dbg_watch_list copy.py +219 -0
  31. easycoder/pre/debugger/ec_dbg_watchlist.py +293 -0
  32. easycoder/pre/debugger/ec_debug.py +1014 -0
  33. easycoder/pre/ec_border.py +67 -0
  34. easycoder/pre/ec_classes.py +470 -0
  35. easycoder/pre/ec_compiler.py +291 -0
  36. easycoder/pre/ec_condition.py +27 -0
  37. easycoder/pre/ec_core.py +2772 -0
  38. easycoder/pre/ec_gclasses.py +230 -0
  39. easycoder/{ec_pyside.py → pre/ec_graphics.py} +631 -496
  40. easycoder/pre/ec_handler.py +79 -0
  41. easycoder/pre/ec_keyboard.py +439 -0
  42. easycoder/pre/ec_program.py +557 -0
  43. easycoder/pre/ec_psutil.py +48 -0
  44. easycoder/pre/ec_timestamp.py +11 -0
  45. easycoder/pre/ec_value.py +124 -0
  46. easycoder/pre/icons/close.png +0 -0
  47. easycoder/pre/icons/exit.png +0 -0
  48. easycoder/pre/icons/run.png +0 -0
  49. easycoder/pre/icons/step.png +0 -0
  50. easycoder/pre/icons/stop.png +0 -0
  51. easycoder/pre/icons/tick.png +0 -0
  52. {easycoder-251104.2.dist-info → easycoder-260110.1.dist-info}/METADATA +11 -1
  53. easycoder-260110.1.dist-info/RECORD +58 -0
  54. easycoder/ec_debug.py +0 -464
  55. easycoder-251104.2.dist-info/RECORD +0 -20
  56. /easycoder/{close.png → icons/close.png} +0 -0
  57. /easycoder/{tick.png → icons/tick.png} +0 -0
  58. {easycoder-251104.2.dist-info → easycoder-260110.1.dist-info}/WHEEL +0 -0
  59. {easycoder-251104.2.dist-info → easycoder-260110.1.dist-info}/entry_points.txt +0 -0
  60. {easycoder-251104.2.dist-info → easycoder-260110.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1014 @@
1
+ import sys, os, json, html
2
+ from PySide6.QtWidgets import (
3
+ QMainWindow,
4
+ QWidget,
5
+ QFrame,
6
+ QHBoxLayout,
7
+ QGridLayout,
8
+ QVBoxLayout,
9
+ QLabel,
10
+ QSplitter,
11
+ QMessageBox,
12
+ QScrollArea,
13
+ QScrollBar,
14
+ QSizePolicy,
15
+ QToolBar,
16
+ QPushButton,
17
+ QInputDialog,
18
+ QTabWidget
19
+ )
20
+ from PySide6.QtGui import QTextCursor, QIcon
21
+ from PySide6.QtCore import Qt, QTimer
22
+ from typing import Any, Optional
23
+ from .ec_dbg_value_display import ValueDisplay
24
+ from .ec_dbg_watchlist import WatchListWidget
25
+
26
+ class Object():
27
+ def __setattr__(self, name: str, value: Any) -> None:
28
+ self.__dict__[name] = value
29
+
30
+ def __getattr__(self, name: str) -> Any:
31
+ return self.__dict__.get(name)
32
+
33
+ class Debugger(QMainWindow):
34
+ # Help type-checkers know these attributes exist
35
+ _flush_timer: Optional[QTimer]
36
+
37
+ class ConsoleWriter:
38
+ def __init__(self, debugger: 'Debugger'):
39
+ self.debugger = debugger
40
+ self._buf: list[str] = []
41
+
42
+ def write(self, text: str):
43
+ if not text:
44
+ return
45
+
46
+ # Echo all output to original stdout with proper line breaks
47
+ try:
48
+ if self.debugger._orig_stdout:
49
+ self.debugger._orig_stdout.write(text)
50
+ self.debugger._orig_stdout.flush()
51
+ except Exception:
52
+ pass
53
+
54
+ # Check if this looks like an error message - if so, also write to original stderr
55
+ if any(err_marker in text for err_marker in ['Error', 'Traceback', 'Exception']):
56
+ try:
57
+ if self.debugger._orig_stderr:
58
+ self.debugger._orig_stderr.write(text)
59
+ self.debugger._orig_stderr.flush()
60
+ except Exception:
61
+ pass
62
+
63
+ # Buffer text and request a flush on the GUI timer
64
+ self._buf.append(text)
65
+ if self.debugger._flush_timer and not self.debugger._flush_timer.isActive():
66
+ self.debugger._flush_timer.start()
67
+
68
+ def flush(self):
69
+ # Explicit flush request
70
+ self.debugger._flush_console_buffer()
71
+
72
+ ###########################################################################
73
+ # The left-hand column of the main window
74
+ class MainLeftColumn(QWidget):
75
+ def __init__(self, parent=None):
76
+ super().__init__(parent)
77
+ self.debugger = parent
78
+ layout = QVBoxLayout(self)
79
+
80
+ # Header panel
81
+ variable_panel = QFrame()
82
+ variable_panel.setFrameShape(QFrame.Shape.StyledPanel)
83
+ variable_panel.setStyleSheet("background-color: white;")
84
+ variable_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
85
+ watch_layout = QHBoxLayout(variable_panel)
86
+ watch_layout.setContentsMargins(4, 4, 4, 4)
87
+ watch_layout.setSpacing(4)
88
+
89
+ title_label = QLabel("VARIABLES")
90
+ title_label.setStyleSheet("font-weight: bold; letter-spacing: 1px;")
91
+ watch_layout.addWidget(title_label)
92
+ watch_layout.addStretch()
93
+
94
+ add_btn = QPushButton("+")
95
+ add_btn.setToolTip("Add variable to watch")
96
+ add_btn.setFixedSize(24, 24)
97
+ add_btn.clicked.connect(self.on_add_clicked)
98
+ watch_layout.addWidget(add_btn)
99
+
100
+ layout.addWidget(variable_panel)
101
+
102
+ # Watch list widget
103
+ self.watch_list = WatchListWidget(self.debugger)
104
+ layout.addWidget(self.watch_list, 1)
105
+
106
+ def on_add_clicked(self):
107
+ try:
108
+ program = self.debugger.program # type: ignore[attr-defined]
109
+ items = []
110
+ if hasattr(program, 'symbols') and isinstance(program.symbols, dict) and program.symbols:
111
+ items = sorted([name for name in program.symbols.keys() if name and not name.endswith(':')])
112
+ else:
113
+ for cmd in getattr(program, 'code', []):
114
+ try:
115
+ if cmd.get('type') == 'symbol' and 'name' in cmd:
116
+ items.append(cmd['name'])
117
+ except Exception:
118
+ pass
119
+ items = sorted(set(items))
120
+ if not items:
121
+ QMessageBox.information(self, "Add Watch", "No variables found in this program.")
122
+ return
123
+
124
+ # Use a custom chooser dialog with a visible list (already open)
125
+ from PySide6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QDialogButtonBox
126
+ dlg = QDialog(self)
127
+ dlg.setWindowTitle("Add Watch")
128
+ v = QVBoxLayout(dlg)
129
+ lst = QListWidget(dlg)
130
+ lst.addItems(items)
131
+ lst.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
132
+ if items:
133
+ lst.setCurrentRow(0)
134
+ # Allow double-click to accept immediately
135
+ def accept_double(item):
136
+ if item:
137
+ lst.setCurrentItem(item)
138
+ dlg.accept()
139
+ lst.itemDoubleClicked.connect(accept_double)
140
+ v.addWidget(lst)
141
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=dlg)
142
+ v.addWidget(buttons)
143
+ buttons.accepted.connect(dlg.accept)
144
+ buttons.rejected.connect(dlg.reject)
145
+ if dlg.exec() == QDialog.DialogCode.Accepted:
146
+ cur = lst.currentItem()
147
+ choice = cur.text() if cur else None
148
+ if choice:
149
+ self.watch_list.addVariable(choice)
150
+ try:
151
+ self.debugger.console.append(f"Watching: {choice}") # type: ignore[attr-defined]
152
+ except Exception:
153
+ pass
154
+ except Exception as exc:
155
+ QMessageBox.warning(self, "Add Watch", f"Could not list variables: {exc}")
156
+
157
+ ###########################################################################
158
+ # A single script panel that displays one script's lines
159
+ class ScriptPanel(QWidget):
160
+ scroll: QScrollArea
161
+ layout: QHBoxLayout # type: ignore[assignment]
162
+
163
+ def __init__(self, parent=None):
164
+ super().__init__(parent)
165
+
166
+ # Set white background
167
+ self.setStyleSheet("background-color: white;")
168
+
169
+ # Main layout
170
+ panel_layout = QVBoxLayout(self)
171
+ panel_layout.setContentsMargins(0, 0, 0, 0)
172
+ panel_layout.setSpacing(0)
173
+
174
+ # Create a scroll area - its content widget holds the lines
175
+ self.scroll = QScrollArea()
176
+ self.scroll.setStyleSheet("background-color: white;")
177
+ self.scroll.setWidgetResizable(True)
178
+
179
+ # Ensure this widget and the scroll area expand to fill available space
180
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
181
+ self.scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
182
+
183
+ self.content = QWidget()
184
+ # let the content expand horizontally but have flexible height
185
+ self.content.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
186
+
187
+ self.inner_layout = QVBoxLayout(self.content)
188
+ # spacing and small top/bottom margins to separate lines
189
+ self.inner_layout.setSpacing(0)
190
+ self.inner_layout.setContentsMargins(0, 0, 0, 0)
191
+
192
+ self.scroll.setWidget(self.content)
193
+
194
+ # Add scroll area to the panel layout
195
+ panel_layout.addWidget(self.scroll)
196
+
197
+ # Store script lines for this panel
198
+ self.scriptLines = []
199
+
200
+ #######################################################################
201
+ # Add a line to this script panel
202
+ def addLine(self, spec):
203
+
204
+ # is_command will be set later by enableBreakpoints() after compilation
205
+ # Initialize to False for now
206
+ spec.is_command = False
207
+
208
+ class Label(QLabel):
209
+ def __init__(self, text, fixed_width=None, align=Qt.AlignmentFlag.AlignLeft, on_click=None):
210
+ super().__init__()
211
+ self.setText(text)
212
+ self.setMargin(0)
213
+ self.setContentsMargins(0, 0, 0, 0)
214
+ self.setStyleSheet("padding:0px; margin:0px; font-family: mono")
215
+ fm = self.fontMetrics()
216
+ self.setFixedHeight(fm.height())
217
+ if fixed_width is not None:
218
+ self.setFixedWidth(fixed_width)
219
+ self.setAlignment(align | Qt.AlignmentFlag.AlignVCenter)
220
+ self._on_click = on_click
221
+
222
+ def mousePressEvent(self, event):
223
+ if self._on_click:
224
+ try:
225
+ self._on_click()
226
+ except Exception:
227
+ pass
228
+ super().mousePressEvent(event)
229
+
230
+ spec.label = self
231
+ panel = QWidget()
232
+ # ensure the panel itself has no margins
233
+ try:
234
+ panel.setContentsMargins(0, 0, 0, 0)
235
+ except Exception:
236
+ pass
237
+ # tidy layout: remove spacing/margins so lines sit flush
238
+ layout = QHBoxLayout(panel)
239
+ layout.setSpacing(0)
240
+ layout.setContentsMargins(0, 0, 0, 0)
241
+ self.layout: QHBoxLayout = layout # type: ignore
242
+ # make panel take minimal vertical space
243
+ panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
244
+ # compute width to fit a 4-digit line number using this widget's font
245
+ fm_main = self.fontMetrics()
246
+ width_4 = fm_main.horizontalAdvance('0000') + 8
247
+
248
+
249
+ # create the red blob (always present). We'll toggle its opacity
250
+ # by changing the stylesheet (rgba alpha 255/0). Do NOT store it
251
+ # on the MainRightColumn instance — keep it per-line.
252
+
253
+ class ClickableBlob(QLabel):
254
+ def __init__(self, on_click=None):
255
+ super().__init__()
256
+ self._on_click = on_click
257
+ def mousePressEvent(self, event):
258
+ if self._on_click:
259
+ try:
260
+ self._on_click()
261
+ except Exception:
262
+ pass
263
+ super().mousePressEvent(event)
264
+
265
+ blob_size = 10
266
+ blob = ClickableBlob(on_click=lambda: spec.onClick(spec.lino))
267
+ blob.setFixedSize(blob_size, blob_size)
268
+
269
+ def set_blob_visible(widget, visible):
270
+ alpha = 255 if visible else 0
271
+ widget.setStyleSheet(f"background-color: rgba(255,0,0,{alpha}); border-radius: {blob_size//2}px; margin:0px; padding:0px;")
272
+ widget._blob_visible = visible
273
+ # force repaint
274
+ widget.update()
275
+
276
+ # attach methods to this blob so callers can toggle it via spec.label
277
+ blob.showBlob = lambda: set_blob_visible(blob, True) # type: ignore[attr-defined]
278
+ blob.hideBlob = lambda: set_blob_visible(blob, False) # type: ignore[attr-defined]
279
+
280
+ # initialize according to spec flag
281
+ if spec.bp:
282
+ blob.showBlob() # type: ignore[attr-defined]
283
+ else:
284
+ blob.hideBlob() # type: ignore[attr-defined]
285
+
286
+ # expose the blob to the outside via spec['label'] so onClick can call showBlob/hideBlob
287
+ spec.label = blob
288
+
289
+ # create the line-number label; clicking it reports back to the caller
290
+ lino_label = Label(str(spec.lino+1), fixed_width=width_4, align=Qt.AlignmentFlag.AlignRight,
291
+ on_click=lambda: spec.onClick(spec.lino))
292
+ lino_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
293
+ # create the text label for the line itself
294
+ text_label = Label(spec.line, fixed_width=None, align=Qt.AlignmentFlag.AlignLeft)
295
+ text_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
296
+ layout.addWidget(lino_label)
297
+ layout.addSpacing(10)
298
+ layout.addWidget(blob, 0, Qt.AlignmentFlag.AlignVCenter)
299
+ layout.addSpacing(3)
300
+ layout.addWidget(text_label)
301
+ self.inner_layout.addWidget(panel)
302
+ return panel
303
+
304
+ def addStretch(self):
305
+ self.inner_layout.addStretch()
306
+
307
+ ###########################################################################
308
+ # The right-hand column of the main window
309
+ class MainRightColumn(QWidget):
310
+
311
+ def __init__(self, parent=None):
312
+ super().__init__(parent)
313
+
314
+ # Set white background for the entire column
315
+ self.setStyleSheet("background-color: white;")
316
+
317
+ # Main layout for this column
318
+ column_layout = QVBoxLayout(self)
319
+ column_layout.setContentsMargins(0, 0, 0, 0)
320
+ column_layout.setSpacing(0)
321
+
322
+ # Create toolbar with icon buttons
323
+ toolbar = QToolBar()
324
+ toolbar.setMovable(False)
325
+
326
+ # Get the icons directory path
327
+ icons_dir = os.path.join(os.path.dirname(__file__), '../icons')
328
+
329
+ # Get the parent debugger for callbacks
330
+ debugger = parent
331
+
332
+ # Run button
333
+ run_btn = QPushButton()
334
+ run_icon_path = os.path.join(icons_dir, 'run.png')
335
+ run_btn.setIcon(QIcon(run_icon_path))
336
+ run_btn.setToolTip("Run")
337
+ run_btn.clicked.connect(lambda: debugger.doRun() if debugger else None) # type: ignore[attr-defined]
338
+ toolbar.addWidget(run_btn)
339
+
340
+ # Step button
341
+ step_btn = QPushButton()
342
+ step_icon_path = os.path.join(icons_dir, 'step.png')
343
+ step_btn.setIcon(QIcon(step_icon_path))
344
+ step_btn.setToolTip("Step")
345
+ step_btn.clicked.connect(lambda: debugger.doStep() if debugger else None) # type: ignore[attr-defined]
346
+ toolbar.addWidget(step_btn)
347
+
348
+ # Stop button
349
+ stop_btn = QPushButton()
350
+ stop_icon_path = os.path.join(icons_dir, 'stop.png')
351
+ stop_btn.setIcon(QIcon(stop_icon_path))
352
+ stop_btn.setToolTip("Stop")
353
+ stop_btn.clicked.connect(lambda: debugger.doStop() if debugger else None) # type: ignore[attr-defined]
354
+ toolbar.addWidget(stop_btn)
355
+
356
+ # Exit button
357
+ exit_btn = QPushButton()
358
+ exit_icon_path = os.path.join(icons_dir, 'exit.png')
359
+ exit_btn.setIcon(QIcon(exit_icon_path))
360
+ exit_btn.setToolTip("Exit")
361
+ exit_btn.clicked.connect(lambda: debugger.doClose() if debugger else None) # type: ignore[attr-defined]
362
+ toolbar.addWidget(exit_btn)
363
+
364
+ column_layout.addWidget(toolbar)
365
+
366
+ # Create a tab widget to hold multiple script panels
367
+ self.tabWidget = QTabWidget()
368
+ self.tabWidget.setTabsClosable(False) # Don't allow closing tabs for now
369
+
370
+ # Ensure tab widget expands
371
+ self.tabWidget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
372
+
373
+ column_layout.addWidget(self.tabWidget)
374
+
375
+ # Dictionary to map program -> script panel
376
+ self.programPanels = {}
377
+
378
+ #######################################################################
379
+ # Add a new script tab
380
+ def addScriptTab(self, program, filename):
381
+ """Add a new tab for a script"""
382
+ panel = Debugger.ScriptPanel(self)
383
+ # Extract just the filename from the full path
384
+ tab_label = os.path.basename(filename)
385
+ self.tabWidget.addTab(panel, tab_label)
386
+ self.programPanels[id(program)] = panel
387
+ return panel
388
+
389
+ #######################################################################
390
+ # Get the current active script panel
391
+ def getCurrentPanel(self):
392
+ """Get the currently active script panel"""
393
+ return self.tabWidget.currentWidget()
394
+
395
+ #######################################################################
396
+ # Get the panel for a specific program
397
+ def getPanelForProgram(self, program):
398
+ """Get the panel associated with a program"""
399
+ return self.programPanels.get(id(program))
400
+
401
+ #######################################################################
402
+ # Legacy method - add a line to the current panel
403
+ def addLine(self, spec):
404
+ """Delegate to the current panel's addLine method"""
405
+ panel = self.getCurrentPanel()
406
+ if panel and isinstance(panel, Debugger.ScriptPanel):
407
+ return panel.addLine(spec)
408
+ return None
409
+
410
+ #######################################################################
411
+ # Add stretch to current panel
412
+ def addStretch(self):
413
+ """Delegate to the current panel's addStretch method"""
414
+ panel = self.getCurrentPanel()
415
+ if panel and isinstance(panel, Debugger.ScriptPanel):
416
+ panel.addStretch()
417
+
418
+ ###########################################################################
419
+ # Main debugger class initializer
420
+ def __init__(self, program, width=800, height=600, ratio=0.2):
421
+ super().__init__()
422
+ self.program = program
423
+ self.setWindowTitle("EasyCoder Debugger")
424
+ self.setMinimumSize(width, height)
425
+ # Disable the window close button
426
+ self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
427
+ self.stopped = True
428
+ self.skip_next_breakpoint = False # Flag to skip breakpoint check on resume
429
+ self.saved_queue = [] # Save queue state when stopped to preserve forked threads
430
+ self._highlighted: set[int] = set()
431
+
432
+ # try to load saved geometry from ~/.ecdebug.conf
433
+ cfg_path = os.path.join(os.path.expanduser("~"), ".ecdebug.conf")
434
+ initial_width = width
435
+ # default console height (pixels) if not stored in cfg
436
+ console_height = 150
437
+ try:
438
+ if os.path.exists(cfg_path):
439
+ with open(cfg_path, "r", encoding="utf-8") as f:
440
+ cfg = json.load(f)
441
+ x = int(cfg.get("x", 0))
442
+ y = int(cfg.get("y", 0))
443
+ w = int(cfg.get("width", width))
444
+ h = int(cfg.get("height", height))
445
+ ratio =float(cfg.get("ratio", ratio))
446
+ # load console height if present
447
+ console_height = int(cfg.get("console_height", console_height))
448
+ # Apply loaded geometry
449
+ self.setGeometry(x, y, w, h)
450
+ initial_width = w
451
+ except Exception:
452
+ # ignore errors and continue with defaults
453
+ initial_width = width
454
+
455
+ # process handle for running scripts
456
+ self._proc = None
457
+ # in-process Program instance and writer
458
+ self._program = None
459
+ self._writer = None
460
+ self._orig_stdout = None
461
+ self._orig_stderr = None
462
+ self._flush_timer = QTimer(self)
463
+ self._flush_timer.setInterval(50)
464
+ self._flush_timer.timeout.connect(self._flush_console_buffer)
465
+ self._flush_timer.stop()
466
+
467
+ # Keep a ratio so proportions are preserved when window is resized
468
+ self.ratio = ratio
469
+
470
+ # Central horizontal splitter (left/right)
471
+ self.hsplitter = QSplitter(Qt.Orientation.Horizontal, self)
472
+ self.hsplitter.setHandleWidth(8)
473
+ self.hsplitter.splitterMoved.connect(self.on_splitter_moved)
474
+
475
+ # Left pane
476
+ left = QFrame()
477
+ left.setFrameShape(QFrame.Shape.StyledPanel)
478
+ left_layout = QVBoxLayout(left)
479
+ left_layout.setContentsMargins(8, 8, 8, 8)
480
+ left_layout.setSpacing(0)
481
+ self.leftColumn = self.MainLeftColumn(self)
482
+ self.leftColumn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
483
+ left_layout.addWidget(self.leftColumn, 1)
484
+
485
+ # Right pane
486
+ right = QFrame()
487
+ right.setFrameShape(QFrame.Shape.StyledPanel)
488
+ right_layout = QVBoxLayout(right)
489
+ right_layout.setContentsMargins(8, 8, 8, 8)
490
+ self.rightColumn = self.MainRightColumn(self)
491
+ # Give the rightColumn a stretch factor so its scroll area fills the vertical space
492
+ right_layout.addWidget(self.rightColumn, 1)
493
+
494
+ # Add panes to horizontal splitter
495
+ self.hsplitter.addWidget(left)
496
+ self.hsplitter.addWidget(right)
497
+
498
+ # Initial sizes (proportional) for horizontal splitter
499
+ total = initial_width
500
+ self.hsplitter.setSizes([int(self.ratio * total), int((1 - self.ratio) * total)])
501
+
502
+ # Create a vertical splitter so we can add a resizable console panel at the bottom
503
+ self.vsplitter = QSplitter(Qt.Orientation.Vertical, self)
504
+ self.vsplitter.setHandleWidth(6)
505
+ # top: the existing horizontal splitter
506
+ self.vsplitter.addWidget(self.hsplitter)
507
+
508
+ # bottom: console panel
509
+ console_frame = QFrame()
510
+ console_frame.setFrameShape(QFrame.Shape.StyledPanel)
511
+ console_layout = QVBoxLayout(console_frame)
512
+ console_layout.setContentsMargins(4, 4, 4, 4)
513
+ # simple read-only text console for script output and messages
514
+ from PySide6.QtWidgets import QTextEdit
515
+ self.console = QTextEdit()
516
+ self.console.setReadOnly(True)
517
+ console_layout.addWidget(self.console)
518
+ self.vsplitter.addWidget(console_frame)
519
+
520
+ # Redirect stdout/stderr so all program output is captured in the console
521
+ try:
522
+ self._orig_stdout = sys.stdout
523
+ self._orig_stderr = sys.stderr
524
+ self._writer = self.ConsoleWriter(self)
525
+ sys.stdout = self._writer # type: ignore[assignment]
526
+ sys.stderr = self._writer # type: ignore[assignment]
527
+ except Exception:
528
+ # Best effort; if redirection fails, continue without it
529
+ self._writer = None
530
+
531
+ # Set initial vertical sizes: prefer saved console_height if available
532
+ try:
533
+ total_h = int(h) if 'h' in locals() else max(300, self.height())
534
+ ch = max(50, min(total_h - 50, console_height))
535
+ self.vsplitter.setSizes([int(total_h - ch), int(ch)])
536
+ except Exception:
537
+ pass
538
+
539
+ # Use the vertical splitter as the central widget
540
+ self.setCentralWidget(self.vsplitter)
541
+ self.parse(program.script.lines, program, program.scriptName)
542
+ self.show()
543
+
544
+ def _flush_console_buffer(self):
545
+ try:
546
+ writer = self._writer
547
+ if not writer:
548
+ return
549
+ if getattr(writer, '_buf', None):
550
+ text = ''.join(writer._buf)
551
+ writer._buf.clear()
552
+ # Append to the console and scroll to bottom
553
+ self.console.moveCursor(QTextCursor.MoveOperation.End)
554
+ self.console.insertPlainText(text)
555
+ self.console.moveCursor(QTextCursor.MoveOperation.End)
556
+ except Exception:
557
+ pass
558
+
559
+ def on_splitter_moved(self, pos, index):
560
+ # Update stored ratio when user drags the splitter
561
+ left_width = self.hsplitter.widget(0).width()
562
+ total = max(1, sum(w.width() for w in (self.hsplitter.widget(0), self.hsplitter.widget(1))))
563
+ self.ratio = left_width / total
564
+
565
+ def resizeEvent(self, event):
566
+ # Preserve the proportional widths when the window is resized
567
+ total_width = max(1, self.width())
568
+ left_w = max(0, int(self.ratio * total_width))
569
+ right_w = max(0, total_width - left_w)
570
+ self.hsplitter.setSizes([left_w, right_w])
571
+ super().resizeEvent(event)
572
+
573
+ ###########################################################################
574
+ # Parse a script into the right-hand column
575
+ def parse(self, script, program=None, filename=None):
576
+ """Parse a script and add it as a tab
577
+
578
+ Args:
579
+ script: List of script lines
580
+ program: The Program instance this script belongs to
581
+ filename: The filename to use as the tab label
582
+ """
583
+ self.scriptLines = []
584
+
585
+ # Get or create the panel for this program
586
+ panel = None
587
+ if program:
588
+ panel = self.rightColumn.getPanelForProgram(program)
589
+ if not panel:
590
+ # Create a new tab for this program
591
+ if not filename:
592
+ filename = getattr(program, 'path', 'Untitled')
593
+ panel = self.rightColumn.addScriptTab(program, filename)
594
+
595
+ # If no program specified or panel creation failed, use current panel
596
+ if not panel:
597
+ panel = self.rightColumn.getCurrentPanel()
598
+ if not panel:
599
+ # Create a default tab
600
+ panel = self.rightColumn.addScriptTab(None, filename or 'Untitled')
601
+
602
+ # Clear existing lines from the panel
603
+ if panel and isinstance(panel, Debugger.ScriptPanel):
604
+ layout = panel.inner_layout
605
+ while layout.count():
606
+ item = layout.takeAt(0)
607
+ widget = item.widget()
608
+ if widget:
609
+ widget.deleteLater()
610
+
611
+ # Parse and add new lines
612
+ lino = 0
613
+ for line in script:
614
+ orig_line = line
615
+ if len(line) > 0:
616
+ line = line.replace("\t", " ")
617
+ color_line = self.coloriseLine(line, lino)
618
+ else:
619
+ # still need to call coloriseLine to keep token list in sync
620
+ color_line = self.coloriseLine(line, lino)
621
+ lineSpec = Object()
622
+ lineSpec.lino = lino
623
+ lineSpec.line = color_line
624
+ lineSpec.orig_line = orig_line
625
+ lineSpec.bp = False
626
+ lineSpec.onClick = self.onClickLino
627
+ lino += 1
628
+ self.scriptLines.append(lineSpec)
629
+ lineSpec.panel = self.rightColumn.addLine(lineSpec)
630
+ self.rightColumn.addStretch()
631
+
632
+ ###########################################################################
633
+ # Colorise a line of script for HTML display
634
+ def coloriseLine(self, line, lino=None):
635
+ output = ''
636
+
637
+ # Preserve leading spaces (render as   except the first)
638
+ if len(line) > 0 and line[0] == ' ':
639
+ output += '<span>'
640
+ n = 0
641
+ while n < len(line) and line[n] == ' ': n += 1
642
+ output += '&nbsp;' * (n - 1)
643
+ output += '</span>'
644
+
645
+ # Find the first unquoted ! (not inside backticks)
646
+ comment_start = None
647
+ in_backtick = False
648
+ for idx, c in enumerate(line):
649
+ if c == '`':
650
+ in_backtick = not in_backtick
651
+ elif c == '!' and not in_backtick:
652
+ comment_start = idx
653
+ break
654
+
655
+ if comment_start is not None:
656
+ code_part = line[:comment_start]
657
+ comment_part = line[comment_start:]
658
+ else:
659
+ code_part = line
660
+ comment_part = None
661
+
662
+ # Tokenize code_part as before (respecting backticks)
663
+ tokens = []
664
+ i = 0
665
+ L = len(code_part)
666
+ while i < L:
667
+ if code_part[i].isspace():
668
+ i += 1
669
+ continue
670
+ if code_part[i] == '`':
671
+ j = code_part.find('`', i + 1)
672
+ if j == -1:
673
+ tokens.append(code_part[i:])
674
+ break
675
+ else:
676
+ tokens.append(code_part[i:j+1])
677
+ i = j + 1
678
+ else:
679
+ j = i
680
+ while j < L and not code_part[j].isspace():
681
+ j += 1
682
+ tokens.append(code_part[i:j])
683
+ i = j
684
+
685
+ # Colour code tokens and generate a list of elements
686
+ for token in tokens:
687
+ if token == '':
688
+ continue
689
+ elif token[0].isupper():
690
+ esc = html.escape(token)
691
+ element = f'&nbsp;<span style="color: purple; font-weight: bold;">{esc}</span>'
692
+ elif token[0].isdigit():
693
+ esc = html.escape(token)
694
+ element = f'&nbsp;<span style="color: green;">{esc}</span>'
695
+ elif token[0] == '`':
696
+ esc = html.escape(token)
697
+ element = f'&nbsp;<span style="color: peru;">{esc}</span>'
698
+ else:
699
+ esc = html.escape(token)
700
+ element = f'&nbsp;<span>{esc}</span>'
701
+ output += element
702
+ # Colour comment if present
703
+ if comment_part is not None:
704
+ esc = html.escape(comment_part)
705
+ output += f'<span style="color: green;">&nbsp;{esc}</span>'
706
+
707
+ return output
708
+
709
+ ###########################################################################
710
+ # Enable breakpoints to be set on any line that is classed as a command
711
+ def enableBreakpoints(self):
712
+ """
713
+ Examine each line and set is_command flag based on compiled code.
714
+ A line is a command if:
715
+ - It's not empty or a comment
716
+ - It's not a label (single word ending with colon)
717
+ - It corresponds to a command in program.code with type != 'symbol'
718
+ """
719
+ # First, mark all lines as non-commands by default
720
+ for lineSpec in self.scriptLines:
721
+ lineSpec.is_command = False
722
+
723
+ # Now iterate through compiled commands and mark those that are executable
724
+ for command in self.program.code:
725
+ if 'lino' not in command:
726
+ continue
727
+
728
+ lino = command['lino']
729
+ if lino < 0 or lino >= len(self.scriptLines):
730
+ continue
731
+
732
+ # Check if this is a symbol declaration (variable/constant definition)
733
+ if command.get('type') == 'symbol':
734
+ continue
735
+
736
+ # Check if this is a structural keyword that shouldn't have breakpoints
737
+ if command.get('keyword') in ['begin', 'end']:
738
+ continue
739
+
740
+ # This is an executable command
741
+ self.scriptLines[lino].is_command = True
742
+
743
+ ###########################################################################
744
+ # Here when the user clicks a line number
745
+ def onClickLino(self, lino):
746
+ # Check if this line is a command - if not, take no action
747
+ lineSpec = self.scriptLines[lino]
748
+ if not getattr(lineSpec, 'is_command', True):
749
+ return
750
+
751
+ # Show or hide the red blob next to this line
752
+ lineSpec.bp = not lineSpec.bp
753
+ if lineSpec.bp: lineSpec.label.showBlob()
754
+ else: lineSpec.label.hideBlob()
755
+ # Set or clear a breakpoint on this command
756
+ for command in self.program.code:
757
+ if 'lino' in command and command['lino'] == lino:
758
+ command['bp'] = lineSpec.bp
759
+ break
760
+
761
+ ###########################################################################
762
+ # Scroll to a given line number
763
+ def scrollTo(self, lino):
764
+ # Ensure the line number is valid
765
+ if lino < 0 or lino >= len(self.scriptLines):
766
+ return
767
+
768
+ # Get the panel widget for this line
769
+ lineSpec = self.scriptLines[lino]
770
+ panel = lineSpec.panel
771
+
772
+ if not panel:
773
+ return
774
+
775
+ # Get the current script panel
776
+ script_panel = self.rightColumn.getCurrentPanel()
777
+ if not script_panel or not isinstance(script_panel, Debugger.ScriptPanel):
778
+ return
779
+
780
+ # Get the scroll area from the script panel
781
+ scroll_area = script_panel.scroll
782
+
783
+ # Get the vertical position of the panel relative to the content widget
784
+ panel_y = panel.y()
785
+ panel_height = panel.height()
786
+
787
+ # Get the viewport height (visible area)
788
+ viewport_height = scroll_area.viewport().height()
789
+
790
+ # Calculate the target scroll position to center the panel
791
+ # We want the panel's center to align with the viewport's center
792
+ target_scroll = panel_y + (panel_height // 2) - (viewport_height // 2)
793
+
794
+ # Clamp to valid scroll range
795
+ scrollbar = scroll_area.verticalScrollBar()
796
+ target_scroll = max(scrollbar.minimum(), min(target_scroll, scrollbar.maximum()))
797
+
798
+ # Smoothly scroll to the target position
799
+ scrollbar.setValue(target_scroll)
800
+
801
+ # Bring the window to the front
802
+ self.raise_()
803
+ self.activateWindow()
804
+
805
+ ###########################################################################
806
+ # Set the background color of one line of the script
807
+ def setBackground(self, lino, color):
808
+ # Set the background color of the given line and track highlighted lines
809
+ if lino < 0 or lino >= len(self.scriptLines):
810
+ return
811
+ lineSpec = self.scriptLines[lino]
812
+ panel = lineSpec.panel
813
+ if not panel:
814
+ return
815
+ if color == 'none':
816
+ panel.setStyleSheet("")
817
+ self._highlighted.discard(lino)
818
+ else:
819
+ panel.setStyleSheet(f"background-color: {color};")
820
+ self._highlighted.add(lino)
821
+
822
+ def _clearHighlights(self):
823
+ # Remove highlighting from all previously highlighted lines
824
+ for lino in list(self._highlighted):
825
+ self.setBackground(lino, 'none')
826
+ self._highlighted.clear()
827
+
828
+ ###########################################################################
829
+ # Here before each instruction is run
830
+ # Returns True if the program should halt and wait for user interaction
831
+ def checkIfHalt(self, is_first_command=False):
832
+ self.pc = self.program.pc
833
+ command = self.program.code[self.pc]
834
+ lino = command['lino'] if 'lino' in command else 0
835
+ bp = command.get('bp', False)
836
+
837
+ # Check if we should skip this breakpoint check (resuming from same location)
838
+ if self.skip_next_breakpoint:
839
+ self.skip_next_breakpoint = False
840
+ return False
841
+
842
+ # Labels should never halt execution - they're just markers
843
+ # A label is a symbol whose name ends with ':'
844
+ if command.get('type') == 'symbol' and command.get('name', '').endswith(':'):
845
+ return False
846
+
847
+ # Determine if we should halt
848
+ should_halt = False
849
+
850
+ # If this is the first real command (pc==1), always halt to initialize display
851
+ if is_first_command:
852
+ should_halt = True
853
+ self.stopped = True
854
+ print(f"Program ready at line {lino + 1}")
855
+ # If we're in stopped (step) mode, halt after each command
856
+ elif self.stopped:
857
+ should_halt = True
858
+ # If there's a breakpoint on this line, halt
859
+ elif bp:
860
+ print(f"Hit breakpoint at line {lino + 1}")
861
+ self.stopped = True
862
+ should_halt = True
863
+
864
+ # If halting, update the UI and save queue state
865
+ if should_halt:
866
+ self.scrollTo(lino)
867
+ self._clearHighlights()
868
+ self.setBackground(lino, 'Yellow')
869
+ # Refresh variable values when halted
870
+ self.refreshVariables()
871
+ # Save the current queue state to preserve forked threads
872
+ self._saveQueueState()
873
+
874
+ return should_halt
875
+
876
+ def refreshVariables(self):
877
+ """Update all watched variable values"""
878
+ try:
879
+ if hasattr(self, 'leftColumn') and hasattr(self.leftColumn, 'watch_list'):
880
+ self.leftColumn.watch_list.refreshVariables(self.program)
881
+ except Exception as ex:
882
+ print(f"Error refreshing variables: {ex}")
883
+
884
+ def _saveQueueState(self):
885
+ """Save the current global queue state (preserves forked threads)"""
886
+ try:
887
+ # Import the module to access the global queue
888
+ from easycoder import ec_program
889
+ # Save a copy of the queue
890
+ self.saved_queue = list(ec_program.queue)
891
+ except Exception as ex:
892
+ print(f"Error saving queue state: {ex}")
893
+
894
+ def _restoreQueueState(self):
895
+ """Restore the saved queue state (resume all forked threads)"""
896
+ try:
897
+ # Import here to avoid circular dependency
898
+ from easycoder import ec_program
899
+ # Restore the queue from saved state
900
+ if self.saved_queue:
901
+ ec_program.queue.clear()
902
+ ec_program.queue.extend(self.saved_queue)
903
+ except Exception as ex:
904
+ print(f"Error restoring queue state: {ex}")
905
+
906
+ def doRun(self):
907
+ """Resume free-running execution from current PC"""
908
+ command = self.program.code[self.pc]
909
+ lino = command.get('lino', 0)
910
+ print(f"Continuing execution at line {lino + 1}")
911
+
912
+ # Clear the highlight on the current line
913
+ self.setBackground(lino, 'none')
914
+
915
+ # Switch to free-running mode
916
+ self.stopped = False
917
+
918
+ # Skip the breakpoint check for the current instruction (the one we're resuming from)
919
+ self.skip_next_breakpoint = True
920
+
921
+ # Restore the saved queue state to resume all forked threads
922
+ self._restoreQueueState()
923
+
924
+ # Enqueue the current thread, then flush immediately
925
+ self.program.run(self.pc)
926
+ from easycoder.ec_program import flush
927
+ flush()
928
+
929
+ def doStep(self):
930
+ """Execute one instruction and halt again"""
931
+ command = self.program.code[self.pc]
932
+ lino = command.get('lino', 0)
933
+
934
+ # Clear the highlight on the current line
935
+ self.setBackground(lino, 'none')
936
+
937
+ # Stay in stopped mode (will halt after next instruction)
938
+ self.stopped = True
939
+
940
+ # Skip the breakpoint check for the current instruction (the one we're stepping from)
941
+ self.skip_next_breakpoint = True
942
+
943
+ # Restore the saved queue state to resume all forked threads
944
+ self._restoreQueueState()
945
+
946
+ # Enqueue the current thread, then flush a single cycle
947
+ self.program.run(self.pc)
948
+ from easycoder.ec_program import flush
949
+ flush()
950
+
951
+ def doStop(self):
952
+ try:
953
+ lino = self.program.code[self.pc].get('lino', 0) + 1
954
+ print(f"Stopped by user at line {lino}")
955
+ except Exception:
956
+ print("Stopped by user")
957
+ # Clear all previous highlights and mark the current line
958
+ try:
959
+ self._clearHighlights()
960
+ current_lino = self.program.code[self.pc].get('lino', 0)
961
+ self.setBackground(current_lino, 'LightYellow')
962
+ except Exception:
963
+ pass
964
+ self.stopped = True
965
+
966
+ def doClose(self):
967
+ self.closeEvent(None)
968
+
969
+ ###########################################################################
970
+ # Override closeEvent to save window geometry
971
+ def closeEvent(self, event):
972
+ """Save window position and size to ~/.ecdebug.conf as JSON on exit."""
973
+ cfg = {
974
+ "x": self.x(),
975
+ "y": self.y(),
976
+ "width": self.width(),
977
+ "height": self.height(),
978
+ "ratio": self.ratio
979
+ }
980
+ # try to persist console height (bottom pane) if present
981
+ try:
982
+ ch = None
983
+ if hasattr(self, 'vsplitter'):
984
+ sizes = self.vsplitter.sizes()
985
+ if len(sizes) >= 2:
986
+ ch = int(sizes[1])
987
+ if ch is not None:
988
+ cfg['console_height'] = ch
989
+ except Exception:
990
+ pass
991
+ try:
992
+ cfg_path = os.path.join(os.path.expanduser("~"), ".ecdebug.conf")
993
+ with open(cfg_path, "w", encoding="utf-8") as f:
994
+ json.dump(cfg, f, indent=2)
995
+ except Exception as exc:
996
+ # best-effort only; avoid blocking shutdown
997
+ try:
998
+ self.statusBar().showMessage(f"Could not save config: {exc}", 3000)
999
+ except Exception:
1000
+ pass
1001
+ # Restore stdout/stderr and stop timers
1002
+ try:
1003
+ if self._orig_stdout is not None:
1004
+ sys.stdout = self._orig_stdout
1005
+ if self._orig_stderr is not None:
1006
+ sys.stderr = self._orig_stderr
1007
+ if self._flush_timer is not None:
1008
+ try:
1009
+ self._flush_timer.stop()
1010
+ except Exception:
1011
+ pass
1012
+ except Exception:
1013
+ pass
1014
+ super().close()