Source code for simvx.ide.panels.debug_panel

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


from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import Signal
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 ..dap.manager import DebugManager
    from ..state import IDEState

log = logging.getLogger(__name__)


[docs] class DebugToolbar(HBoxContainer): """Horizontal bar of debug action buttons.""" def __init__(self, **kwargs): super().__init__(**kwargs) self.separation = 4 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 draw(self, renderer): x, y, w, h = self.get_global_rect() renderer.draw_filled_rect(x, y, w, h, get_theme().toolbar_bg)
[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._manager: DebugManager | 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_manager(self, manager: DebugManager): self._manager = manager
[docs] def refresh(self): """Rebuild the tree from cached scopes and variables.""" if not self._manager: return root = TreeItem("Variables") scopes = self._manager.scopes cached_vars = self._manager.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._manager or not item.data: return ref = item.data.get("variablesReference", 0) if ref and not item.data.get("loaded"): item.data["loaded"] = True self._manager.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._manager.variables if self._manager 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 process(self, dt: float): self._update_layout()
[docs] def 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: IDEState | None = None self._manager: DebugManager | None = None self.frame_selected = Signal()
[docs] def set_refs(self, state: IDEState, manager: DebugManager): self._state = state self._manager = manager
[docs] def refresh(self): self._frames = self._manager.stack_frames if self._manager else [] self._selected_index = 0
def _on_gui_input(self, event): # Handle scroll events first if event.key == "scroll_up": self._scroll_y = max(0, self._scroll_y - 20) return if event.key == "scroll_down": self._scroll_y += 20 return # Handle click if event.button != 1 or not event.pressed: return _, y, _, _ = self.get_global_rect() py = event.position.y if hasattr(event.position, "y") else event.position[1] 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) if self._manager: self._manager.select_frame(row)
[docs] def draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() renderer.draw_filled_rect(x, y, w, h, theme.panel_bg) renderer.push_clip(x, y, w, h) scale = self._font_size / 14.0 if not self._frames: renderer.draw_text_coloured("No call stack", x + 8, y + 8, scale, theme.text_dim) 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_filled_rect(x, ry, w, self._row_height, theme.selection) 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_coloured(label, x + 8, ry + 3, scale, theme.text) if i == 0: renderer.draw_text_coloured(">", x + 1, ry + 3, scale, theme.warning) renderer.pop_clip()
[docs] class WatchTab(Control): """Watch expressions tab -- evaluate expressions during debug pauses.""" def __init__(self, state: IDEState, manager: DebugManager | None, **kwargs): super().__init__(name="Watch", **kwargs) self._state = state self._manager = manager 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 manager.""" if not self._manager or self._manager._debug_state != "stopped": return for watch in self._watches: self._manager.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 manager.""" 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): if event.key == "scroll_up": self._scroll_y = max(0, self._scroll_y - 20) 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) 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 == 1 and event.pressed: _, y, _, _ = self.get_global_rect() py = event.position.y if hasattr(event.position, "y") else event.position[1] row = int((py - y - 28 - 28 + self._scroll_y) / 24) if 0 <= row < len(self._watches): self._selected_index = row
[docs] def draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() scale = 12.0 / 14.0 small = scale * 0.85 # Input area at top renderer.draw_filled_rect(x, y, w, 28, theme.bg_dark) input_text = self._input_text or "Add expression..." colour = theme.text if self._input_text else theme.text_dim renderer.draw_text_coloured(input_text, x + 8, y + 6, scale, colour) renderer.draw_line_coloured(x, y + 28, x + w, y + 28, theme.border) # Watch list renderer.push_clip(x, y + 28, w, h - 28) if not self._watches: renderer.draw_text_coloured("No watch expressions", x + 8, y + 36, small, theme.text_dim) 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_filled_rect(x, ry, w, 24, theme.selection) expr_colour = theme.text val_colour = theme.error if watch["error"] else theme.success renderer.draw_text_coloured(watch["expr"], x + 8, ry + 4, small, expr_colour) val_text = watch["value"] if len(val_text) > 40: val_text = val_text[:37] + "..." vx = x + w * 0.5 renderer.draw_text_coloured(val_text, vx, ry + 4, small, val_colour) 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: IDEState | None = None self._manager: DebugManager | None = None self._editing_index: int = -1 self._edit_text: str = ""
[docs] def set_state(self, state: IDEState): self._state = state
[docs] def set_manager(self, manager: DebugManager): self._manager = manager
[docs] def set_condition(self, path: str, line: int, condition: str): """Set or clear a condition on a breakpoint (delegates to DebugManager).""" if self._manager: self._manager.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._manager: return self._manager.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._manager: return self._manager.get_conditions_for_file(path) return {}
[docs] def refresh(self): self._entries.clear() if not self._state: return all_bp = self._state.get_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 if event.key == "scroll_up": self._scroll_y = max(0, self._scroll_y - 20) return if event.key == "scroll_down": self._scroll_y += 20 return # Right-click to edit condition if event.button == 2 and event.pressed: _, y, _, _ = self.get_global_rect() py = event.position.y if hasattr(event.position, "y") else event.position[1] 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) return # Left-click to select and navigate if event.button != 1 or not event.pressed: return _, y, _, _ = self.get_global_rect() py = event.position.y if hasattr(event.position, "y") else event.position[1] 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] if self._state: self._state.goto_requested.emit(path, line, 0)
[docs] def draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() renderer.draw_filled_rect(x, y, w, h, theme.panel_bg) renderer.push_clip(x, y, w, h) scale = self._font_size / 14.0 small = scale * 0.85 if not self._entries: renderer.draw_text_coloured("No breakpoints", x + 8, y + 8, scale, theme.text_dim) 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_filled_rect(x, ry, w, self._row_height, theme.selection) # Red dot dot_y = ry + self._row_height / 2 renderer.draw_filled_rect(x + 4, dot_y - 3, 6, 6, theme.error) short = Path(path).name if path else "?" label = f"{short}:{line}" renderer.draw_text_coloured(label, x + 16, ry + 3, scale, theme.text) # 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_coloured(cond_display, x + w * 0.55, ry + 3, small, theme.warning) # Show inline condition editor if i == self._editing_index: ey = ry + self._row_height renderer.draw_filled_rect(x + 16, ey, w - 24, self._row_height, theme.bg_light) edit_label = self._edit_text or "Enter condition..." edit_colour = theme.text if self._edit_text else theme.text_dim renderer.draw_text_coloured(edit_label, x + 20, ey + 3, small, edit_colour) 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: IDEState, debug_manager: DebugManager, **kwargs): super().__init__(**kwargs) self.name = "Debug" self._state = state self._manager = debug_manager self.bg_colour = get_theme().panel_bg self.border_width = 0 # Toolbar self._toolbar = DebugToolbar() # Store original colours before set_state overwrites them for btn in (self._toolbar.btn_continue, self._toolbar.btn_pause, self._toolbar.btn_step_over, self._toolbar.btn_step_into, self._toolbar.btn_step_out, self._toolbar.btn_restart, self._toolbar.btn_stop): btn._original_colour = btn.text_colour 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_manager(debug_manager) self._tabs.add_child(self._variables_tab) self._callstack_tab = CallStackTab() self._callstack_tab.set_refs(state, debug_manager) self._tabs.add_child(self._callstack_tab) self._breakpoints_tab = BreakpointsTab() self._breakpoints_tab.set_state(state) self._breakpoints_tab.set_manager(debug_manager) self._tabs.add_child(self._breakpoints_tab) self._watch_tab = WatchTab(state=state, manager=debug_manager) self._tabs.add_child(self._watch_tab) # Wire toolbar buttons self._toolbar.btn_continue.pressed.connect(debug_manager.continue_execution) self._toolbar.btn_pause.pressed.connect(debug_manager.pause) self._toolbar.btn_step_over.pressed.connect(debug_manager.step_over) self._toolbar.btn_step_into.pressed.connect(debug_manager.step_into) self._toolbar.btn_step_out.pressed.connect(debug_manager.step_out) self._toolbar.btn_stop.pressed.connect(debug_manager.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._manager.current_file or self._state.active_file self._manager.stop_debug() if path: self._manager.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 process(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 draw(self, renderer): x, y, w, h = self.get_global_rect() renderer.draw_filled_rect(x, y, w, h, self.bg_colour) if not self._manager.is_debugging: scale = 12.0 / 14.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_coloured(msg, mx, my, scale, get_theme().text_dim)