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