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