easycoder 251104.2__py2.py3-none-any.whl → 260108.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 (61) hide show
  1. easycoder/__init__.py +6 -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 +493 -11
  10. easycoder/ec_compiler.py +81 -44
  11. easycoder/ec_condition.py +1 -1
  12. easycoder/ec_core.py +1043 -1089
  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_mqtt.py +249 -0
  18. easycoder/ec_program.py +308 -156
  19. easycoder/ec_psutil.py +48 -0
  20. easycoder/ec_timestamp.py +2 -1
  21. easycoder/ec_value.py +65 -47
  22. easycoder/icons/exit.png +0 -0
  23. easycoder/icons/run.png +0 -0
  24. easycoder/icons/step.png +0 -0
  25. easycoder/icons/stop.png +0 -0
  26. easycoder/pre/README.md +3 -0
  27. easycoder/pre/__init__.py +17 -0
  28. easycoder/pre/debugger/__init__.py +5 -0
  29. easycoder/pre/debugger/ec_dbg_value_display copy.py +195 -0
  30. easycoder/pre/debugger/ec_dbg_value_display.py +24 -0
  31. easycoder/pre/debugger/ec_dbg_watch_list copy.py +219 -0
  32. easycoder/pre/debugger/ec_dbg_watchlist.py +293 -0
  33. easycoder/pre/debugger/ec_debug.py +1014 -0
  34. easycoder/pre/ec_border.py +67 -0
  35. easycoder/pre/ec_classes.py +470 -0
  36. easycoder/pre/ec_compiler.py +291 -0
  37. easycoder/pre/ec_condition.py +27 -0
  38. easycoder/pre/ec_core.py +2772 -0
  39. easycoder/pre/ec_gclasses.py +230 -0
  40. easycoder/{ec_pyside.py → pre/ec_graphics.py} +631 -496
  41. easycoder/pre/ec_handler.py +79 -0
  42. easycoder/pre/ec_keyboard.py +439 -0
  43. easycoder/pre/ec_program.py +557 -0
  44. easycoder/pre/ec_psutil.py +48 -0
  45. easycoder/pre/ec_timestamp.py +11 -0
  46. easycoder/pre/ec_value.py +124 -0
  47. easycoder/pre/icons/close.png +0 -0
  48. easycoder/pre/icons/exit.png +0 -0
  49. easycoder/pre/icons/run.png +0 -0
  50. easycoder/pre/icons/step.png +0 -0
  51. easycoder/pre/icons/stop.png +0 -0
  52. easycoder/pre/icons/tick.png +0 -0
  53. {easycoder-251104.2.dist-info → easycoder-260108.1.dist-info}/METADATA +11 -1
  54. easycoder-260108.1.dist-info/RECORD +59 -0
  55. easycoder/ec_debug.py +0 -464
  56. easycoder-251104.2.dist-info/RECORD +0 -20
  57. /easycoder/{close.png → icons/close.png} +0 -0
  58. /easycoder/{tick.png → icons/tick.png} +0 -0
  59. {easycoder-251104.2.dist-info → easycoder-260108.1.dist-info}/WHEEL +0 -0
  60. {easycoder-251104.2.dist-info → easycoder-260108.1.dist-info}/entry_points.txt +0 -0
  61. {easycoder-251104.2.dist-info → easycoder-260108.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1025 @@
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
+ self.step_from_line: int | None = None # Track source line when stepping
432
+
433
+ # try to load saved geometry from ~/.ecdebug.conf
434
+ cfg_path = os.path.join(os.path.expanduser("~"), ".ecdebug.conf")
435
+ initial_width = width
436
+ # default console height (pixels) if not stored in cfg
437
+ console_height = 150
438
+ try:
439
+ if os.path.exists(cfg_path):
440
+ with open(cfg_path, "r", encoding="utf-8") as f:
441
+ cfg = json.load(f)
442
+ x = int(cfg.get("x", 0))
443
+ y = int(cfg.get("y", 0))
444
+ w = int(cfg.get("width", width))
445
+ h = int(cfg.get("height", height))
446
+ ratio =float(cfg.get("ratio", ratio))
447
+ # load console height if present
448
+ console_height = int(cfg.get("console_height", console_height))
449
+ # Apply loaded geometry
450
+ self.setGeometry(x, y, w, h)
451
+ initial_width = w
452
+ except Exception:
453
+ # ignore errors and continue with defaults
454
+ initial_width = width
455
+
456
+ # process handle for running scripts
457
+ self._proc = None
458
+ # in-process Program instance and writer
459
+ self._program = None
460
+ self._writer = None
461
+ self._orig_stdout = None
462
+ self._orig_stderr = None
463
+ self._flush_timer = QTimer(self)
464
+ self._flush_timer.setInterval(50)
465
+ self._flush_timer.timeout.connect(self._flush_console_buffer)
466
+ self._flush_timer.stop()
467
+
468
+ # Keep a ratio so proportions are preserved when window is resized
469
+ self.ratio = ratio
470
+
471
+ # Central horizontal splitter (left/right)
472
+ self.hsplitter = QSplitter(Qt.Orientation.Horizontal, self)
473
+ self.hsplitter.setHandleWidth(8)
474
+ self.hsplitter.splitterMoved.connect(self.on_splitter_moved)
475
+
476
+ # Left pane
477
+ left = QFrame()
478
+ left.setFrameShape(QFrame.Shape.StyledPanel)
479
+ left_layout = QVBoxLayout(left)
480
+ left_layout.setContentsMargins(8, 8, 8, 8)
481
+ left_layout.setSpacing(0)
482
+ self.leftColumn = self.MainLeftColumn(self)
483
+ self.leftColumn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
484
+ left_layout.addWidget(self.leftColumn, 1)
485
+
486
+ # Right pane
487
+ right = QFrame()
488
+ right.setFrameShape(QFrame.Shape.StyledPanel)
489
+ right_layout = QVBoxLayout(right)
490
+ right_layout.setContentsMargins(8, 8, 8, 8)
491
+ self.rightColumn = self.MainRightColumn(self)
492
+ # Give the rightColumn a stretch factor so its scroll area fills the vertical space
493
+ right_layout.addWidget(self.rightColumn, 1)
494
+
495
+ # Add panes to horizontal splitter
496
+ self.hsplitter.addWidget(left)
497
+ self.hsplitter.addWidget(right)
498
+
499
+ # Initial sizes (proportional) for horizontal splitter
500
+ total = initial_width
501
+ self.hsplitter.setSizes([int(self.ratio * total), int((1 - self.ratio) * total)])
502
+
503
+ # Create a vertical splitter so we can add a resizable console panel at the bottom
504
+ self.vsplitter = QSplitter(Qt.Orientation.Vertical, self)
505
+ self.vsplitter.setHandleWidth(6)
506
+ # top: the existing horizontal splitter
507
+ self.vsplitter.addWidget(self.hsplitter)
508
+
509
+ # bottom: console panel
510
+ console_frame = QFrame()
511
+ console_frame.setFrameShape(QFrame.Shape.StyledPanel)
512
+ console_layout = QVBoxLayout(console_frame)
513
+ console_layout.setContentsMargins(4, 4, 4, 4)
514
+ # simple read-only text console for script output and messages
515
+ from PySide6.QtWidgets import QTextEdit
516
+ self.console = QTextEdit()
517
+ self.console.setReadOnly(True)
518
+ console_layout.addWidget(self.console)
519
+ self.vsplitter.addWidget(console_frame)
520
+
521
+ # Redirect stdout/stderr so all program output is captured in the console
522
+ try:
523
+ self._orig_stdout = sys.stdout
524
+ self._orig_stderr = sys.stderr
525
+ self._writer = self.ConsoleWriter(self)
526
+ sys.stdout = self._writer # type: ignore[assignment]
527
+ sys.stderr = self._writer # type: ignore[assignment]
528
+ except Exception:
529
+ # Best effort; if redirection fails, continue without it
530
+ self._writer = None
531
+
532
+ # Set initial vertical sizes: prefer saved console_height if available
533
+ try:
534
+ total_h = int(h) if 'h' in locals() else max(300, self.height())
535
+ ch = max(50, min(total_h - 50, console_height))
536
+ self.vsplitter.setSizes([int(total_h - ch), int(ch)])
537
+ except Exception:
538
+ pass
539
+
540
+ # Use the vertical splitter as the central widget
541
+ self.setCentralWidget(self.vsplitter)
542
+ self.parse(program.script.lines, program, program.scriptName)
543
+ self.show()
544
+
545
+ def _flush_console_buffer(self):
546
+ try:
547
+ writer = self._writer
548
+ if not writer:
549
+ return
550
+ if getattr(writer, '_buf', None):
551
+ text = ''.join(writer._buf)
552
+ writer._buf.clear()
553
+ # Append to the console and scroll to bottom
554
+ self.console.moveCursor(QTextCursor.MoveOperation.End)
555
+ self.console.insertPlainText(text)
556
+ self.console.moveCursor(QTextCursor.MoveOperation.End)
557
+ except Exception:
558
+ pass
559
+
560
+ def on_splitter_moved(self, pos, index):
561
+ # Update stored ratio when user drags the splitter
562
+ left_width = self.hsplitter.widget(0).width()
563
+ total = max(1, sum(w.width() for w in (self.hsplitter.widget(0), self.hsplitter.widget(1))))
564
+ self.ratio = left_width / total
565
+
566
+ def resizeEvent(self, event):
567
+ # Preserve the proportional widths when the window is resized
568
+ total_width = max(1, self.width())
569
+ left_w = max(0, int(self.ratio * total_width))
570
+ right_w = max(0, total_width - left_w)
571
+ self.hsplitter.setSizes([left_w, right_w])
572
+ super().resizeEvent(event)
573
+
574
+ ###########################################################################
575
+ # Parse a script into the right-hand column
576
+ def parse(self, script, program=None, filename=None):
577
+ """Parse a script and add it as a tab
578
+
579
+ Args:
580
+ script: List of script lines
581
+ program: The Program instance this script belongs to
582
+ filename: The filename to use as the tab label
583
+ """
584
+ self.scriptLines = []
585
+
586
+ # Get or create the panel for this program
587
+ panel = None
588
+ if program:
589
+ panel = self.rightColumn.getPanelForProgram(program)
590
+ if not panel:
591
+ # Create a new tab for this program
592
+ if not filename:
593
+ filename = getattr(program, 'path', 'Untitled')
594
+ panel = self.rightColumn.addScriptTab(program, filename)
595
+
596
+ # If no program specified or panel creation failed, use current panel
597
+ if not panel:
598
+ panel = self.rightColumn.getCurrentPanel()
599
+ if not panel:
600
+ # Create a default tab
601
+ panel = self.rightColumn.addScriptTab(None, filename or 'Untitled')
602
+
603
+ # Clear existing lines from the panel
604
+ if panel and isinstance(panel, Debugger.ScriptPanel):
605
+ layout = panel.inner_layout
606
+ while layout.count():
607
+ item = layout.takeAt(0)
608
+ widget = item.widget()
609
+ if widget:
610
+ widget.deleteLater()
611
+
612
+ # Parse and add new lines
613
+ lino = 0
614
+ for line in script:
615
+ orig_line = line
616
+ if len(line) > 0:
617
+ line = line.replace("\t", " ")
618
+ color_line = self.coloriseLine(line, lino)
619
+ else:
620
+ # still need to call coloriseLine to keep token list in sync
621
+ color_line = self.coloriseLine(line, lino)
622
+ lineSpec = Object()
623
+ lineSpec.lino = lino
624
+ lineSpec.line = color_line
625
+ lineSpec.orig_line = orig_line
626
+ lineSpec.bp = False
627
+ lineSpec.onClick = self.onClickLino
628
+ lino += 1
629
+ self.scriptLines.append(lineSpec)
630
+ lineSpec.panel = self.rightColumn.addLine(lineSpec)
631
+ self.rightColumn.addStretch()
632
+
633
+ ###########################################################################
634
+ # Colorise a line of script for HTML display
635
+ def coloriseLine(self, line, lino=None):
636
+ output = ''
637
+
638
+ # Preserve leading spaces (render as   except the first)
639
+ if len(line) > 0 and line[0] == ' ':
640
+ output += '<span>'
641
+ n = 0
642
+ while n < len(line) and line[n] == ' ': n += 1
643
+ output += '&nbsp;' * (n - 1)
644
+ output += '</span>'
645
+
646
+ # Find the first unquoted ! (not inside backticks)
647
+ comment_start = None
648
+ in_backtick = False
649
+ for idx, c in enumerate(line):
650
+ if c == '`':
651
+ in_backtick = not in_backtick
652
+ elif c == '!' and not in_backtick:
653
+ comment_start = idx
654
+ break
655
+
656
+ if comment_start is not None:
657
+ code_part = line[:comment_start]
658
+ comment_part = line[comment_start:]
659
+ else:
660
+ code_part = line
661
+ comment_part = None
662
+
663
+ # Tokenize code_part as before (respecting backticks)
664
+ tokens = []
665
+ i = 0
666
+ L = len(code_part)
667
+ while i < L:
668
+ if code_part[i].isspace():
669
+ i += 1
670
+ continue
671
+ if code_part[i] == '`':
672
+ j = code_part.find('`', i + 1)
673
+ if j == -1:
674
+ tokens.append(code_part[i:])
675
+ break
676
+ else:
677
+ tokens.append(code_part[i:j+1])
678
+ i = j + 1
679
+ else:
680
+ j = i
681
+ while j < L and not code_part[j].isspace():
682
+ j += 1
683
+ tokens.append(code_part[i:j])
684
+ i = j
685
+
686
+ # Colour code tokens and generate a list of elements
687
+ for token in tokens:
688
+ if token == '':
689
+ continue
690
+ elif token[0].isupper():
691
+ esc = html.escape(token)
692
+ element = f'&nbsp;<span style="color: purple; font-weight: bold;">{esc}</span>'
693
+ elif token[0].isdigit():
694
+ esc = html.escape(token)
695
+ element = f'&nbsp;<span style="color: green;">{esc}</span>'
696
+ elif token[0] == '`':
697
+ esc = html.escape(token)
698
+ element = f'&nbsp;<span style="color: peru;">{esc}</span>'
699
+ else:
700
+ esc = html.escape(token)
701
+ element = f'&nbsp;<span>{esc}</span>'
702
+ output += element
703
+ # Colour comment if present
704
+ if comment_part is not None:
705
+ esc = html.escape(comment_part)
706
+ output += f'<span style="color: green;">&nbsp;{esc}</span>'
707
+
708
+ return output
709
+
710
+ ###########################################################################
711
+ # Enable breakpoints to be set on any line that is classed as a command
712
+ def enableBreakpoints(self):
713
+ """
714
+ Examine each line and set is_command flag based on compiled code.
715
+ A line is a command if:
716
+ - It's not empty or a comment
717
+ - It's not a label (single word ending with colon)
718
+ - It corresponds to a command in program.code with type != 'symbol'
719
+ """
720
+ # First, mark all lines as non-commands by default
721
+ for lineSpec in self.scriptLines:
722
+ lineSpec.is_command = False
723
+
724
+ # Now iterate through compiled commands and mark those that are executable
725
+ for command in self.program.code:
726
+ if 'lino' not in command:
727
+ continue
728
+
729
+ lino = command['lino']
730
+ if lino < 0 or lino >= len(self.scriptLines):
731
+ continue
732
+
733
+ # Check if this is a symbol declaration (variable/constant definition)
734
+ if command.get('type') == 'symbol':
735
+ continue
736
+
737
+ # Check if this is a structural keyword that shouldn't have breakpoints
738
+ if command.get('keyword') in ['begin', 'end']:
739
+ continue
740
+
741
+ # This is an executable command
742
+ self.scriptLines[lino].is_command = True
743
+
744
+ ###########################################################################
745
+ # Here when the user clicks a line number
746
+ def onClickLino(self, lino):
747
+ # Check if this line is a command - if not, take no action
748
+ lineSpec = self.scriptLines[lino]
749
+ if not getattr(lineSpec, 'is_command', True):
750
+ return
751
+
752
+ # Show or hide the red blob next to this line
753
+ lineSpec.bp = not lineSpec.bp
754
+ if lineSpec.bp: lineSpec.label.showBlob()
755
+ else: lineSpec.label.hideBlob()
756
+ # Set or clear a breakpoint on this command
757
+ for command in self.program.code:
758
+ if 'lino' in command and command['lino'] == lino:
759
+ command['bp'] = lineSpec.bp
760
+ break
761
+
762
+ ###########################################################################
763
+ # Scroll to a given line number
764
+ def scrollTo(self, lino):
765
+ # Ensure the line number is valid
766
+ if lino < 0 or lino >= len(self.scriptLines):
767
+ return
768
+
769
+ # Get the panel widget for this line
770
+ lineSpec = self.scriptLines[lino]
771
+ panel = lineSpec.panel
772
+
773
+ if not panel:
774
+ return
775
+
776
+ # Get the current script panel
777
+ script_panel = self.rightColumn.getCurrentPanel()
778
+ if not script_panel or not isinstance(script_panel, Debugger.ScriptPanel):
779
+ return
780
+
781
+ # Get the scroll area from the script panel
782
+ scroll_area = script_panel.scroll
783
+
784
+ # Get the vertical position of the panel relative to the content widget
785
+ panel_y = panel.y()
786
+ panel_height = panel.height()
787
+
788
+ # Get the viewport height (visible area)
789
+ viewport_height = scroll_area.viewport().height()
790
+
791
+ # Calculate the target scroll position to center the panel
792
+ # We want the panel's center to align with the viewport's center
793
+ target_scroll = panel_y + (panel_height // 2) - (viewport_height // 2)
794
+
795
+ # Clamp to valid scroll range
796
+ scrollbar = scroll_area.verticalScrollBar()
797
+ target_scroll = max(scrollbar.minimum(), min(target_scroll, scrollbar.maximum()))
798
+
799
+ # Smoothly scroll to the target position
800
+ scrollbar.setValue(target_scroll)
801
+
802
+ # Bring the window to the front
803
+ self.raise_()
804
+ self.activateWindow()
805
+
806
+ ###########################################################################
807
+ # Set the background color of one line of the script
808
+ def setBackground(self, lino, color):
809
+ # Set the background color of the given line and track highlighted lines
810
+ if lino < 0 or lino >= len(self.scriptLines):
811
+ return
812
+ lineSpec = self.scriptLines[lino]
813
+ panel = lineSpec.panel
814
+ if not panel:
815
+ return
816
+ if color == 'none':
817
+ panel.setStyleSheet("")
818
+ self._highlighted.discard(lino)
819
+ else:
820
+ panel.setStyleSheet(f"background-color: {color};")
821
+ self._highlighted.add(lino)
822
+
823
+ def _clearHighlights(self):
824
+ # Remove highlighting from all previously highlighted lines
825
+ for lino in list(self._highlighted):
826
+ self.setBackground(lino, 'none')
827
+ self._highlighted.clear()
828
+
829
+ ###########################################################################
830
+ # Here before each instruction is run
831
+ # Returns True if the program should halt and wait for user interaction
832
+ def checkIfHalt(self, is_first_command=False):
833
+ self.pc = self.program.pc
834
+ command = self.program.code[self.pc]
835
+ lino = command['lino'] if 'lino' in command else 0
836
+ bp = command.get('bp', False)
837
+
838
+ # Check if we should skip this breakpoint check (resuming from same location)
839
+ if self.skip_next_breakpoint:
840
+ self.skip_next_breakpoint = False
841
+ return False
842
+
843
+ # Labels should never halt execution - they're just markers
844
+ # A label is a symbol whose name ends with ':'
845
+ if command.get('type') == 'symbol' and command.get('name', '').endswith(':'):
846
+ return False
847
+
848
+ # Determine if we should halt
849
+ should_halt = False
850
+
851
+ # If this is the first real command (pc==1), always halt to initialize display
852
+ if is_first_command:
853
+ should_halt = True
854
+ self.stopped = True
855
+ self.step_from_line = None
856
+ print(f"Program ready at line {lino + 1}")
857
+ # If we're in stopped (step) mode, halt after each command
858
+ elif self.stopped:
859
+ # If stepping, only halt when we reach a different source line
860
+ if self.step_from_line is not None:
861
+ if lino != self.step_from_line:
862
+ should_halt = True
863
+ self.step_from_line = None
864
+ else:
865
+ should_halt = True
866
+ # If there's a breakpoint on this line, halt
867
+ elif bp:
868
+ print(f"Hit breakpoint at line {lino + 1}")
869
+ self.stopped = True
870
+ should_halt = True
871
+
872
+ # If halting, update the UI and save queue state
873
+ if should_halt:
874
+ self.scrollTo(lino)
875
+ self._clearHighlights()
876
+ self.setBackground(lino, 'Yellow')
877
+ # Refresh variable values when halted
878
+ self.refreshVariables()
879
+ # Save the current queue state to preserve forked threads
880
+ self._saveQueueState()
881
+
882
+ return should_halt
883
+
884
+ def refreshVariables(self):
885
+ """Update all watched variable values"""
886
+ try:
887
+ if hasattr(self, 'leftColumn') and hasattr(self.leftColumn, 'watch_list'):
888
+ self.leftColumn.watch_list.refreshVariables(self.program)
889
+ except Exception as ex:
890
+ print(f"Error refreshing variables: {ex}")
891
+
892
+ def _saveQueueState(self):
893
+ """Save the current global queue state (preserves forked threads)"""
894
+ try:
895
+ # Import the module to access the global queue
896
+ from easycoder import ec_program
897
+ # Save a copy of the queue
898
+ self.saved_queue = list(ec_program.queue)
899
+ except Exception as ex:
900
+ print(f"Error saving queue state: {ex}")
901
+
902
+ def _restoreQueueState(self):
903
+ """Restore the saved queue state (resume all forked threads)"""
904
+ try:
905
+ # Import here to avoid circular dependency
906
+ from easycoder import ec_program
907
+ # Restore the queue from saved state
908
+ if self.saved_queue:
909
+ ec_program.queue.clear()
910
+ ec_program.queue.extend(self.saved_queue)
911
+ except Exception as ex:
912
+ print(f"Error restoring queue state: {ex}")
913
+
914
+ def doRun(self):
915
+ """Resume free-running execution from current PC"""
916
+ command = self.program.code[self.pc]
917
+ lino = command.get('lino', 0)
918
+ print(f"Continuing execution at line {lino + 1}")
919
+
920
+ # Clear the highlight on the current line
921
+ self.setBackground(lino, 'none')
922
+
923
+ # Switch to free-running mode
924
+ self.stopped = False
925
+ self.step_from_line = None
926
+
927
+ # Skip the breakpoint check for the current instruction (the one we're resuming from)
928
+ self.skip_next_breakpoint = True
929
+
930
+ # Restore the saved queue state to resume all forked threads
931
+ self._restoreQueueState()
932
+
933
+ # Enqueue the current thread, then flush immediately
934
+ self.program.run(self.pc)
935
+ from easycoder.ec_program import flush
936
+ flush()
937
+
938
+ def doStep(self):
939
+ """Execute one instruction and halt again"""
940
+ command = self.program.code[self.pc]
941
+ lino = command.get('lino', 0)
942
+
943
+ # Clear the highlight on the current line
944
+ self.setBackground(lino, 'none')
945
+
946
+ # Stay in stopped mode (will halt after next instruction)
947
+ self.stopped = True
948
+ # Remember the line we're stepping from - don't halt until we reach a different line
949
+ self.step_from_line = lino
950
+
951
+ # Skip the breakpoint check for the current instruction (the one we're stepping from)
952
+ self.skip_next_breakpoint = True
953
+
954
+ # Restore the saved queue state to resume all forked threads
955
+ self._restoreQueueState()
956
+
957
+ # Enqueue the current thread, then flush a single cycle
958
+ self.program.run(self.pc)
959
+ from easycoder.ec_program import flush
960
+ flush()
961
+
962
+ def doStop(self):
963
+ try:
964
+ lino = self.program.code[self.pc].get('lino', 0) + 1
965
+ print(f"Stopped by user at line {lino}")
966
+ except Exception:
967
+ print("Stopped by user")
968
+ # Clear all previous highlights and mark the current line
969
+ try:
970
+ self._clearHighlights()
971
+ current_lino = self.program.code[self.pc].get('lino', 0)
972
+ self.setBackground(current_lino, 'LightYellow')
973
+ except Exception:
974
+ pass
975
+ self.stopped = True
976
+
977
+ def doClose(self):
978
+ self.closeEvent(None)
979
+
980
+ ###########################################################################
981
+ # Override closeEvent to save window geometry
982
+ def closeEvent(self, event):
983
+ """Save window position and size to ~/.ecdebug.conf as JSON on exit."""
984
+ cfg = {
985
+ "x": self.x(),
986
+ "y": self.y(),
987
+ "width": self.width(),
988
+ "height": self.height(),
989
+ "ratio": self.ratio
990
+ }
991
+ # try to persist console height (bottom pane) if present
992
+ try:
993
+ ch = None
994
+ if hasattr(self, 'vsplitter'):
995
+ sizes = self.vsplitter.sizes()
996
+ if len(sizes) >= 2:
997
+ ch = int(sizes[1])
998
+ if ch is not None:
999
+ cfg['console_height'] = ch
1000
+ except Exception:
1001
+ pass
1002
+ try:
1003
+ cfg_path = os.path.join(os.path.expanduser("~"), ".ecdebug.conf")
1004
+ with open(cfg_path, "w", encoding="utf-8") as f:
1005
+ json.dump(cfg, f, indent=2)
1006
+ except Exception as exc:
1007
+ # best-effort only; avoid blocking shutdown
1008
+ try:
1009
+ self.statusBar().showMessage(f"Could not save config: {exc}", 3000)
1010
+ except Exception:
1011
+ pass
1012
+ # Restore stdout/stderr and stop timers
1013
+ try:
1014
+ if self._orig_stdout is not None:
1015
+ sys.stdout = self._orig_stdout
1016
+ if self._orig_stderr is not None:
1017
+ sys.stderr = self._orig_stderr
1018
+ if self._flush_timer is not None:
1019
+ try:
1020
+ self._flush_timer.stop()
1021
+ except Exception:
1022
+ pass
1023
+ except Exception:
1024
+ pass
1025
+ super().close()