"""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 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)