easycoder 251103.4__py2.py3-none-any.whl → 251105.1__py2.py3-none-any.whl

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