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 Anchor, FocusMode, PointerFilter, Signal, on_gui_input
from simvx.core.descriptors import Property
from simvx.core.input import Key, Mouse, Pointer, PointerEvent, PointerPhase, ScrollEvent
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, TextEdit

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") size_x = Property(600.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(32.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") def __init__(self, **kwargs): super().__init__(**kwargs) 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._local_rect() renderer.rect((x, y), (w, h), fill=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._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.""" pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") 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
[docs] 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 isinstance(event, ScrollEvent): if event.scroll[1] > 0: self._scroll_y = max(0, self._scroll_y - 20) else: self._scroll_y += 20 return True # Handle click if not isinstance(event, PointerEvent): return None if event.button not in Pointer.PRIMARY or event.phase is not PointerPhase.DOWN: return py = self.to_local(event.position).y row = int((py + self._scroll_y) / self._row_height) if 0 <= row < len(self._frames): self._selected_index = row self.frame_selected.emit(row) if self._session: self._session.select_frame(row) return True return None
[docs] def on_draw(self, renderer): theme = get_theme() x, y, w, h = self._local_rect() renderer.rect((x, y), (w, h), fill=theme.panel_bg) renderer.push_clip(x, y, w, h) scale = self._font_size / 16.0 if not self._frames: renderer.text("No call stack", (x + 8, y + 8), fill=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.rect((x, ry), (w, self._row_height), fill=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.text(label, (x + 8, ry + 3), fill=theme.text, scale=scale) if i == 0: renderer.text(">", (x + 1, ry + 3), fill=theme.warning, scale=scale) renderer.pop_clip()
_WATCH_INPUT_H = 28.0 _WATCH_ROW_H = 24.0
[docs] class WatchTab(Control): """Watch expressions tab -- evaluate expressions during debug pauses.""" pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") # Clicking a watch focuses the list, so Delete removes the selected watch # while the expression field does not hold the keyboard. focus_mode = Property(FocusMode.CLICK, hint="Whether this control can take keyboard focus") 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._scroll_y: float = 0.0 self._selected_index: int = -1 self._input = self.add_child( TextEdit( placeholder="Add expression...", name="Expression", anchor=Anchor.TOP_WIDE, size=(0, _WATCH_INPUT_H) ) ) self._input.font_size = 12.0 self._input.text_submitted.connect(self._on_expression_submitted)
[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_expression_submitted(self, expression: str): self.add_watch(expression) self._input.text = "" self._input.cursor_pos = 0 @on_gui_input(Key.DELETE) def _delete_selected(self, event) -> bool: if self._selected_index < 0: return False self.remove_watch(self._selected_index) self._selected_index = -1 return True
[docs] def on_gui_input(self, event): # Claimed, so the wheel moves this list rather than the container the # panel is docked in. if isinstance(event, ScrollEvent): if event.scroll[1] > 0: self._scroll_y = max(0, self._scroll_y - 20) return True max_scroll = max(0, (len(self._watches) + 1) * _WATCH_ROW_H - self.size.y + _WATCH_INPUT_H) self._scroll_y = min(max_scroll, self._scroll_y + 20) return True # Click to select if isinstance(event, PointerEvent) and event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: py = self.to_local(event.position).y row = int((py - _WATCH_INPUT_H + self._scroll_y) // _WATCH_ROW_H) if 0 <= row < len(self._watches): self._selected_index = row self.grab_focus() return True return None
[docs] def on_draw(self, renderer): theme = get_theme() x, y, w, h = self._local_rect() scale = 12.0 / 16.0 small = scale * 0.85 # The expression field draws itself across the top. renderer.line((x, y + 28), (x + w, y + 28), stroke=theme.border) # Watch list renderer.push_clip(x, y + 28, w, h - 28) if not self._watches: renderer.text("No watch expressions", (x + 8, y + 36), fill=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.rect((x, ry), (w, 24), fill=theme.selection) expr_colour = theme.text val_colour = theme.error if watch["error"] else theme.success renderer.text(watch["expr"], (x + 8, ry + 4), fill=expr_colour, scale=small) val_text = watch["value"] if len(val_text) > 40: val_text = val_text[:37] + "..." vx = x + w * 0.5 renderer.text(val_text, (vx, ry + 4), fill=val_colour, scale=small) renderer.pop_clip()
class _ConditionField(TextEdit): """A breakpoint's condition line: Escape abandons the edit instead of committing it.""" cancelled = Signal() @on_gui_input(Key.ESCAPE) def _cancel(self, event) -> bool: self.cancelled.emit() return True
[docs] class BreakpointsTab(Control): """List of all breakpoints across files with click-to-navigate. A middle click on a breakpoint opens its condition under the row. Enter or moving focus away commits the condition; Escape abandons it. """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") 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 # The breakpoint whose condition is open for editing, as (path, line). self._editing: tuple[str, int] | None = None self._condition = self.add_child( _ConditionField(placeholder="Enter condition...", name="Condition", visible=False) ) self._condition.font_size = self._font_size * 0.85 self._condition.text_submitted.connect(self._commit_condition) self._condition.focus_exited.connect(self._commit_condition) self._condition.cancelled.connect(self._close_condition)
[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 _edit_condition(self, row: int): """Open the condition of the breakpoint on *row* for editing, committing one already open. Moving to another breakpoint does not take focus from the field, so the open condition is committed here rather than by ``focus_exited``. """ if self._editing is not None: self._commit_condition() self._editing = self._entries[row] self._condition.text = self.get_condition(*self._editing) self._condition.cursor_pos = len(self._condition.text) self._condition.visible = True self._place_condition() self._condition.grab_focus() def _commit_condition(self, *_): if self._editing is not None: self.set_condition(*self._editing, self._condition.text) self._close_condition() def _close_condition(self): """Close the condition editor without committing it.""" if self._editing is None: return self._editing = None self._condition.visible = False self._condition.release_focus() def _place_condition(self): """Put the condition editor in the row under the breakpoint it edits.""" if self._editing not in self._entries: self._close_condition() return _, _, w, _ = self.get_rect() row = self._entries.index(self._editing) self._condition.position = Vec2(16, (row + 1) * self._row_height - self._scroll_y) self._condition.size = Vec2(max(0.0, w - 24), self._row_height)
[docs] def on_update(self, dt: float): if self._editing is not None: self._place_condition()
[docs] def on_gui_input(self, event): # Handle scroll. Claimed, so the wheel moves this list rather than the # container the panel is docked in. if isinstance(event, ScrollEvent): if event.scroll[1] > 0: self._scroll_y = max(0, self._scroll_y - 20) else: self._scroll_y += 20 return True if not isinstance(event, PointerEvent): return None # Middle-click to edit condition if event.button == Mouse.MIDDLE and event.phase is PointerPhase.DOWN: py = self.to_local(event.position).y row = int((py + self._scroll_y) / self._row_height) if 0 <= row < len(self._entries): self._edit_condition(row) return True return None # Left-click to select and navigate if event.button not in Pointer.PRIMARY or event.phase is not PointerPhase.DOWN: return py = self.to_local(event.position).y row = int((py + 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) return True return None
[docs] def on_draw(self, renderer): theme = get_theme() x, y, w, h = self._local_rect() renderer.rect((x, y), (w, h), fill=theme.panel_bg) renderer.push_clip(x, y, w, h) scale = self._font_size / 16.0 small = scale * 0.85 if not self._entries: renderer.text("No breakpoints", (x + 8, y + 8), fill=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.rect((x, ry), (w, self._row_height), fill=theme.selection) # Red dot dot_y = ry + self._row_height / 2 renderer.rect((x + 4, dot_y - 3), (6, 6), fill=theme.error) short = Path(path).name if path else "?" label = f"{short}:{line}" renderer.text(label, (x + 16, ry + 3), fill=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.text(cond_display, (x + w * 0.55, ry + 3), fill=theme.warning, 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._local_rect() renderer.rect((x, y), (w, h), fill=self.bg_colour) 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.text(msg, (mx, my), fill=get_theme().text_dim, scale=scale)