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