Source code for simvx.ide.panels.debug_panel

"""Debug panel -- toolbar, variables, call stack, and breakpoints tabs."""

from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import Signal
from simvx.core.descriptors import Property
from simvx.core.input import MouseButton
from simvx.core.math.types import Vec2
from simvx.core.ui.containers import HBoxContainer
from simvx.core.ui.core import Control
from simvx.core.ui.tabs import TabContainer
from simvx.core.ui.theme import get_theme
from simvx.core.ui.tree import TreeItem, TreeView
from simvx.core.ui.widgets import Button, Panel

if TYPE_CHECKING:
    from ..debug_session import DebugSession
    from ..state import State


[docs] class DebugToolbar(HBoxContainer): """Horizontal bar of debug action buttons.""" separation = Property(4.0, range=(0, 100), hint="Space between children", on_change="mark_layout_dirty") def __init__(self, **kwargs): super().__init__(**kwargs) self.size = Vec2(600, 32) theme = get_theme() self.btn_continue = self._btn("Continue (F5)", theme.success) self.btn_pause = self._btn("Pause", theme.warning) self.btn_step_over = self._btn("Step Over (F10)", theme.text) self.btn_step_into = self._btn("Step Into (F11)", theme.text) self.btn_step_out = self._btn("Step Out", theme.text) self.btn_restart = self._btn("Restart", theme.accent) self.btn_stop = self._btn("Stop (Shift+F5)", theme.error) def _btn(self, text: str, colour: tuple) -> Button: theme = get_theme() b = Button(text) b.size = Vec2(max(80, len(text) * 8 + 16), 26) b.font_size = 12.0 b.text_colour = colour b._original_colour = colour b.bg_colour = theme.btn_bg b.hover_colour = theme.btn_hover b.border_colour = theme.border_light self.add_child(b) return b
[docs] def set_state(self, state: str): """Enable/disable buttons based on debug state (idle|running|stopped).""" debugging = state != "idle" stopped = state == "stopped" running = state == "running" self.btn_continue.disabled = not stopped self.btn_pause.disabled = not running self.btn_step_over.disabled = not stopped self.btn_step_into.disabled = not stopped self.btn_step_out.disabled = not stopped self.btn_restart.disabled = not debugging self.btn_stop.disabled = not debugging dim = get_theme().text_dim for btn in ( self.btn_continue, self.btn_pause, self.btn_step_over, self.btn_step_into, self.btn_step_out, self.btn_restart, self.btn_stop, ): btn.text_colour = dim if btn.disabled else btn._original_colour
[docs] def refresh_theme(self): """Re-apply theme colours to all toolbar buttons.""" theme = get_theme() semantic = { "btn_continue": theme.success, "btn_pause": theme.warning, "btn_step_over": theme.text, "btn_step_into": theme.text, "btn_step_out": theme.text, "btn_restart": theme.accent, "btn_stop": theme.error, } for attr, colour in semantic.items(): btn = getattr(self, attr, None) if btn is None: continue btn._original_colour = colour btn.bg_colour = theme.btn_bg btn.hover_colour = theme.btn_hover btn.border_colour = theme.border_light if not btn.disabled: btn.text_colour = colour
[docs] def on_draw(self, renderer): x, y, w, h = self.get_global_rect() renderer.draw_rect((x, y), (w, h), colour=get_theme().toolbar_bg, filled=True)
[docs] class VariablesTab(Control): """TreeView showing variable scopes with expandable nested objects.""" def __init__(self, **kwargs): super().__init__(name="Variables", **kwargs) self._var_tree = TreeView() self._var_tree.row_height = 20.0 self._var_tree.font_size = 12.0 self._var_tree.bg_colour = get_theme().panel_bg self._var_tree.text_colour = get_theme().text self.add_child(self._var_tree) self._var_tree.item_expanded.connect(self._on_expand) self._session: DebugSession | None = None self._placeholder = "No variables"
[docs] def refresh_theme(self): """Re-apply theme colours to the variables tree.""" theme = get_theme() self._var_tree.bg_colour = theme.panel_bg self._var_tree.text_colour = theme.text
[docs] def set_session(self, session: DebugSession): self._session = session
[docs] def refresh(self): """Rebuild the tree from cached scopes and variables.""" if not self._session: return root = TreeItem("Variables") scopes = self._session.scopes cached_vars = self._session.variables if not scopes: root.add_child(TreeItem(self._placeholder)) self._var_tree.root = root return for scope in scopes: scope_name = scope.get("name", "Scope") ref = scope.get("variablesReference", 0) scope_item = TreeItem(scope_name, expanded=True) scope_item.data = {"variablesReference": ref, "loaded": ref in cached_vars} var_list = cached_vars.get(ref, []) for var in var_list: self._add_variable_item(scope_item, var, cached_vars) root.add_child(scope_item) self._var_tree.root = root
def _add_variable_item(self, parent: TreeItem, var: dict, cached: dict): name = var.get("name", "?") value = var.get("value", "") var_type = var.get("type", "") ref = var.get("variablesReference", 0) display = f"{name}: {var_type} = {value}" if var_type else f"{name} = {value}" if len(display) > 80: display = display[:77] + "..." item = TreeItem(display, expanded=False) item.data = {"variablesReference": ref, "loaded": ref in cached} if ref: child_vars = cached.get(ref, []) if child_vars: for cv in child_vars: self._add_variable_item(item, cv, cached) else: item.add_child(TreeItem("...")) parent.add_child(item) def _on_expand(self, item: TreeItem): """Lazy-load children when expanding an unloaded node.""" if not self._session or not item.data: return ref = item.data.get("variablesReference", 0) if ref and not item.data.get("loaded"): item.data["loaded"] = True self._session.fetch_variables(ref, lambda vars: self._on_vars_loaded(item, vars)) def _on_vars_loaded(self, item: TreeItem, var_list: list[dict]): item.children.clear() cached = self._session.variables if self._session else {} for var in var_list: self._add_variable_item(item, var, cached) def _update_layout(self): _, _, w, h = self.get_rect() self._var_tree.position = Vec2(0, 0) self._var_tree.size = Vec2(w, h)
[docs] def on_update(self, dt: float): self._update_layout()
[docs] def on_draw(self, renderer): pass # TreeView draws itself
[docs] class CallStackTab(Control): """List of stack frames with click-to-navigate.""" def __init__(self, **kwargs): super().__init__(name="Call Stack", **kwargs) self._frames: list[dict] = [] self._selected_index: int = 0 self._row_height = 22.0 self._font_size = 12.0 self._scroll_y = 0.0 self._state: State | None = None self._session: DebugSession | None = None self.frame_selected = Signal()
[docs] def set_refs(self, state: State, session: DebugSession): self._state = state self._session = session
[docs] def refresh(self): self._frames = self._session.stack_frames if self._session else [] self._selected_index = 0
def _on_gui_input(self, event): # Handle scroll events first. Claimed, so the wheel moves this list # rather than the container the panel is docked in. if event.key == "scroll_up": self._scroll_y = max(0, self._scroll_y - 20) event.handled = True return if event.key == "scroll_down": self._scroll_y += 20 event.handled = True return # Handle click if event.button != MouseButton.LEFT or not event.pressed: return _, y, _, _ = self.get_global_rect() py = event.position.y row = int((py - y + self._scroll_y) / self._row_height) if 0 <= row < len(self._frames): self._selected_index = row self.frame_selected.emit(row) event.handled = True if self._session: self._session.select_frame(row)
[docs] def on_draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() renderer.draw_rect((x, y), (w, h), colour=theme.panel_bg, filled=True) renderer.push_clip(x, y, w, h) scale = self._font_size / 16.0 if not self._frames: renderer.draw_text("No call stack", (x + 8, y + 8), colour=theme.text_dim, scale=scale) renderer.pop_clip() return for i, frame in enumerate(self._frames): ry = y + i * self._row_height - self._scroll_y if ry + self._row_height < y or ry > y + h: continue if i == self._selected_index: renderer.draw_rect((x, ry), (w, self._row_height), colour=theme.selection, filled=True) func_name = frame.get("name", "?") source = frame.get("source", {}) file_path = source.get("path", "") line = frame.get("line", 0) short_file = Path(file_path).name if file_path else "?" label = f"{func_name} {short_file}:{line}" renderer.draw_text(label, (x + 8, ry + 3), colour=theme.text, scale=scale) if i == 0: renderer.draw_text(">", (x + 1, ry + 3), colour=theme.warning, scale=scale) renderer.pop_clip()
[docs] class WatchTab(Control): """Watch expressions tab -- evaluate expressions during debug pauses.""" def __init__(self, state: State, session: DebugSession | None, **kwargs): super().__init__(name="Watch", **kwargs) self._state = state self._session = session self._watches: list[dict] = [] # Each: {"expr": str, "value": str, "error": bool} self._input_text: str = "" self._scroll_y: float = 0.0 self._selected_index: int = -1
[docs] def add_watch(self, expression: str): """Add a watch expression.""" if not expression.strip(): return for w in self._watches: if w["expr"] == expression: return self._watches.append({"expr": expression, "value": "<not evaluated>", "error": False}) self._evaluate_all()
[docs] def remove_watch(self, index: int): """Remove a watch expression by index.""" if 0 <= index < len(self._watches): self._watches.pop(index)
def _evaluate_all(self): """Evaluate all watch expressions using the debug session.""" if not self._session or self._session._debug_state != "stopped": return for watch in self._watches: self._session.evaluate(watch["expr"], callback=lambda val, w=watch: self._on_eval_result(w, val)) def _on_eval_result(self, watch: dict, result): """Handle evaluation result from debug session.""" if isinstance(result, str): watch["value"] = result watch["error"] = False elif isinstance(result, dict): watch["value"] = result.get("result", str(result)) watch["error"] = "error" in result else: watch["value"] = str(result) if result is not None else "<error>" watch["error"] = result is None def _on_gui_input(self, event): # Claimed, so the wheel moves this list rather than the container the # panel is docked in. if event.key == "scroll_up": self._scroll_y = max(0, self._scroll_y - 20) event.handled = True return if event.key == "scroll_down": max_scroll = max(0, (len(self._watches) + 1) * 24 - self.size.y + 28) self._scroll_y = min(max_scroll, self._scroll_y + 20) event.handled = True return # Input for new expression if event.char and len(event.char) == 1: self._input_text += event.char return if event.key == "backspace" and event.pressed: self._input_text = self._input_text[:-1] return if event.key == "enter" and event.pressed and self._input_text: self.add_watch(self._input_text) self._input_text = "" return if event.key == "delete" and event.pressed and self._selected_index >= 0: self.remove_watch(self._selected_index) self._selected_index = -1 return # Click to select if event.button == MouseButton.LEFT and event.pressed: _, y, _, _ = self.get_global_rect() py = event.position.y row = int((py - y - 28 - 28 + self._scroll_y) / 24) if 0 <= row < len(self._watches): self._selected_index = row event.handled = True
[docs] def on_draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() scale = 12.0 / 16.0 small = scale * 0.85 # Input area at top renderer.draw_rect((x, y), (w, 28), colour=theme.bg_dark, filled=True) input_text = self._input_text or "Add expression..." colour = theme.text if self._input_text else theme.text_dim renderer.draw_text(input_text, (x + 8, y + 6), colour=colour, scale=scale) renderer.draw_line((x, y + 28), (x + w, y + 28), colour=theme.border) # Watch list renderer.push_clip(x, y + 28, w, h - 28) if not self._watches: renderer.draw_text("No watch expressions", (x + 8, y + 36), colour=theme.text_dim, scale=small) else: for i, watch in enumerate(self._watches): ry = y + 28 + i * 24 - self._scroll_y if ry + 24 < y + 28 or ry > y + h: continue if i == self._selected_index: renderer.draw_rect((x, ry), (w, 24), colour=theme.selection, filled=True) expr_colour = theme.text val_colour = theme.error if watch["error"] else theme.success renderer.draw_text(watch["expr"], (x + 8, ry + 4), colour=expr_colour, scale=small) val_text = watch["value"] if len(val_text) > 40: val_text = val_text[:37] + "..." vx = x + w * 0.5 renderer.draw_text(val_text, (vx, ry + 4), colour=val_colour, scale=small) renderer.pop_clip()
[docs] class BreakpointsTab(Control): """List of all breakpoints across files with click-to-navigate.""" def __init__(self, **kwargs): super().__init__(name="Breakpoints", **kwargs) self._entries: list[tuple[str, int]] = [] self._selected_index: int = -1 self._row_height = 22.0 self._font_size = 12.0 self._scroll_y = 0.0 self._state: State | None = None self._session: DebugSession | None = None self._editing_index: int = -1 self._edit_text: str = ""
[docs] def set_state(self, state: State): self._state = state
[docs] def set_session(self, session: DebugSession): self._session = session
[docs] def set_condition(self, path: str, line: int, condition: str): """Set or clear a condition on a breakpoint (delegates to DebugSession).""" if self._session: self._session.set_breakpoint_condition(path, line, condition)
[docs] def get_condition(self, path: str, line: int) -> str: """Get the condition for a breakpoint, or empty string if none.""" if self._session: return self._session.get_breakpoint_condition(path, line) return ""
[docs] def get_conditions_for_file(self, path: str) -> dict[int, str]: """Get all conditions for a file as {line: condition}.""" if self._session: return self._session.get_conditions_for_file(path) return {}
[docs] def refresh(self): self._entries.clear() if not self._state: return all_bp = self._state.all_breakpoints for path, lines in sorted(all_bp.items()): for ln in sorted(lines): self._entries.append((path, ln))
def _on_gui_input(self, event): # Handle condition editing input if self._editing_index >= 0: if event.key == "enter" and event.pressed: path, line = self._entries[self._editing_index] self.set_condition(path, line, self._edit_text) self._editing_index = -1 self._edit_text = "" return if event.key == "escape" and event.pressed: self._editing_index = -1 self._edit_text = "" return if event.char and len(event.char) == 1: self._edit_text += event.char return if event.key == "backspace" and event.pressed: self._edit_text = self._edit_text[:-1] return return # Handle scroll. Claimed, so the wheel moves this list rather than the # container the panel is docked in. if event.key == "scroll_up": self._scroll_y = max(0, self._scroll_y - 20) event.handled = True return if event.key == "scroll_down": self._scroll_y += 20 event.handled = True return # Right-click to edit condition if event.button == MouseButton.MIDDLE and event.pressed: _, y, _, _ = self.get_global_rect() py = event.position.y row = int((py - y + self._scroll_y) / self._row_height) if 0 <= row < len(self._entries): self._editing_index = row path, line = self._entries[row] self._edit_text = self.get_condition(path, line) event.handled = True return # Left-click to select and navigate if event.button != MouseButton.LEFT or not event.pressed: return _, y, _, _ = self.get_global_rect() py = event.position.y row = int((py - y + self._scroll_y) / self._row_height) if 0 <= row < len(self._entries): self._selected_index = row path, line = self._entries[row] event.handled = True if self._state: self._state.goto_requested.emit(path, line, 0)
[docs] def on_draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() renderer.draw_rect((x, y), (w, h), colour=theme.panel_bg, filled=True) renderer.push_clip(x, y, w, h) scale = self._font_size / 16.0 small = scale * 0.85 if not self._entries: renderer.draw_text("No breakpoints", (x + 8, y + 8), colour=theme.text_dim, scale=scale) renderer.pop_clip() return for i, (path, line) in enumerate(self._entries): ry = y + i * self._row_height - self._scroll_y if ry + self._row_height < y or ry > y + h: continue if i == self._selected_index: renderer.draw_rect((x, ry), (w, self._row_height), colour=theme.selection, filled=True) # Red dot dot_y = ry + self._row_height / 2 renderer.draw_rect((x + 4, dot_y - 3), (6, 6), colour=theme.error, filled=True) short = Path(path).name if path else "?" label = f"{short}:{line}" renderer.draw_text(label, (x + 16, ry + 3), colour=theme.text, scale=scale) # Show condition text if set cond = self.get_condition(path, line) if cond: cond_display = f"if {cond}" if len(cond_display) > 30: cond_display = cond_display[:27] + "..." renderer.draw_text(cond_display, (x + w * 0.55, ry + 3), colour=theme.warning, scale=small) # Show inline condition editor if i == self._editing_index: ey = ry + self._row_height renderer.draw_rect((x + 16, ey), (w - 24, self._row_height), colour=theme.bg_light, filled=True) edit_label = self._edit_text or "Enter condition..." edit_colour = theme.text if self._edit_text else theme.text_dim renderer.draw_text(edit_label, (x + 20, ey + 3), colour=edit_colour, scale=small) renderer.pop_clip()
[docs] class DebugPanel(Panel): """Complete debug panel with toolbar and tabbed sub-panels. Layout: [Toolbar: Continue | Pause | Step Over | Step Into | Step Out | Restart | Stop] [TabContainer: Variables | Call Stack | Breakpoints] """ def __init__(self, state: State, debug_session: DebugSession, **kwargs): super().__init__(**kwargs) self.name = "Debug" self._state = state self._session = debug_session self.bg_colour = get_theme().panel_bg self.border_width = 0 # Toolbar self._toolbar = DebugToolbar() self._toolbar.set_state("idle") self.add_child(self._toolbar) # Tab container self._tabs = TabContainer() self._tabs.tab_height = 26.0 self._tabs.font_size = 12.0 self.add_child(self._tabs) # Sub-panels self._variables_tab = VariablesTab() self._variables_tab.set_session(debug_session) self._tabs.add_child(self._variables_tab) self._callstack_tab = CallStackTab() self._callstack_tab.set_refs(state, debug_session) self._tabs.add_child(self._callstack_tab) self._breakpoints_tab = BreakpointsTab() self._breakpoints_tab.set_state(state) self._breakpoints_tab.set_session(debug_session) self._tabs.add_child(self._breakpoints_tab) self._watch_tab = WatchTab(state=state, session=debug_session) self._tabs.add_child(self._watch_tab) # Wire toolbar buttons self._toolbar.btn_continue.pressed.connect(debug_session.continue_execution) self._toolbar.btn_pause.pressed.connect(debug_session.pause) self._toolbar.btn_step_over.pressed.connect(debug_session.step_over) self._toolbar.btn_step_into.pressed.connect(debug_session.step_into) self._toolbar.btn_step_out.pressed.connect(debug_session.step_out) self._toolbar.btn_stop.pressed.connect(debug_session.stop_debug) self._toolbar.btn_restart.pressed.connect(self._restart) # Wire state signals state.debug_state_changed.connect(self._on_debug_state_changed) state.debug_started.connect(self._on_debug_started) state.debug_stopped.connect(self._on_debug_stopped) state.breakpoint_toggled.connect(self._on_breakpoint_toggled) # Initial refresh self._breakpoints_tab.refresh()
[docs] def refresh_theme(self): """Re-apply theme colours after a theme change.""" self.bg_colour = get_theme().panel_bg self._toolbar.refresh_theme() self._variables_tab.refresh_theme()
def _restart(self): """Stop and re-launch the current file.""" path = self._session.current_file or self._state.active_file self._session.stop_debug() if path: self._session.start_debug(path) # -- Signal handlers ------------------------------------------------------- def _on_debug_state_changed(self, state_name: str, data: dict): self._toolbar.set_state(state_name) if state_name == "stopped": self._refresh_all() self._watch_tab._evaluate_all() def _on_debug_started(self): self._toolbar.set_state("running") self._breakpoints_tab.refresh() def _on_debug_stopped(self): self._toolbar.set_state("idle") self._variables_tab._placeholder = "Not debugging" self._variables_tab.refresh() self._callstack_tab.refresh() def _on_breakpoint_toggled(self, path: str, line: int): self._breakpoints_tab.refresh() def _refresh_all(self): self._variables_tab.refresh() self._callstack_tab.refresh() self._breakpoints_tab.refresh() # -- Layout ----------------------------------------------------------------
[docs] def on_update(self, dt: float): _, _, w, h = self.get_rect() toolbar_h = 32 self._toolbar.position = Vec2(0, 0) self._toolbar.size = Vec2(w, toolbar_h) self._tabs.position = Vec2(0, toolbar_h) self._tabs.size = Vec2(w, max(0, h - toolbar_h))
[docs] def on_draw(self, renderer): x, y, w, h = self.get_global_rect() renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) if not self._session.is_debugging: scale = 12.0 / 16.0 msg = "Not debugging -- press F5 to start" tw = renderer.text_width(msg, scale) mx = x + (w - tw) / 2 my = y + h / 2 - 6 renderer.draw_text(msg, (mx, my), colour=get_theme().text_dim, scale=scale)