"""Debug overlay renderer — draws via Draw2D interface."""
from __future__ import annotations
import logging
log = logging.getLogger(__name__)
__all__ = ["DebugOverlay"]
[docs]
class DebugOverlay:
"""Renders debug visuals using any Draw2D-compatible renderer."""
def __init__(self):
self.show_bounds: bool = False
self.show_labels: bool = False
self.show_hit_target: bool = False
self.show_profiler: bool = True
self.show_event_log: bool = False
[docs]
def draw_overlay(self, renderer, tree, profiler, inspector):
if tree is None:
return
ss = tree.screen_size
w = float(ss[0]) if isinstance(ss, tuple | list) else float(ss.x)
h = float(ss[1]) if isinstance(ss, tuple | list) else float(ss.y)
# Count nodes once per frame when profiler visible
if self.show_profiler:
profiler.count_nodes(tree)
inspector.snapshot_state(tree)
if self.show_bounds:
self._draw_control_bounds(renderer, tree)
if self.show_labels:
from ..input.state import Input
mouse = Input._mouse_pos
self._draw_control_labels(renderer, tree, mouse)
if self.show_profiler:
self._draw_profiler_hud(renderer, profiler, w, h)
self._draw_frame_graph(renderer, profiler, w - 230, 130, 200, 50)
if self.show_event_log:
self._draw_event_log(renderer, inspector, w, h)
def _draw_control_bounds(self, renderer, tree):
"""Draw coloured rects for all visible Controls."""
from ..ui import Control
hit_target = None
if self.show_hit_target:
from ..input.state import Input
hit_target = tree._find_control_at_point(Input._mouse_pos)
def _walk(node):
if not node.visible:
return
if isinstance(node, Control):
x, y, w, h = node.get_rect()
if node is hit_target:
colour = (1.0, 1.0, 0.0, 0.6) # Yellow = hit target
elif node.disabled:
colour = (1.0, 0.2, 0.2, 0.4) # Red = disabled
elif node.focused:
colour = (0.2, 0.4, 1.0, 0.4) # Blue = focused
elif node.mouse_over:
colour = (0.2, 0.8, 0.2, 0.4) # Green = hovered
else:
colour = (0.5, 0.5, 0.5, 0.3) # Gray = normal
renderer._colour = colour
renderer.draw_rect(x, y, w, h)
for child in node.children:
_walk(child)
if tree.root:
_walk(tree.root)
def _draw_control_labels(self, renderer, tree, mouse_pos):
"""Draw control name+type near cursor (within 200px only)."""
from ..ui import Control
mx = float(mouse_pos[0]) if not hasattr(mouse_pos, "x") else float(mouse_pos.x)
my = float(mouse_pos[1]) if not hasattr(mouse_pos, "y") else float(mouse_pos.y)
radius_sq = 200.0 * 200.0
def _walk(node):
if not node.visible:
return
if isinstance(node, Control):
x, y, w, h = node.get_rect()
cx, cy = x + w / 2, y + h / 2
dx, dy = cx - mx, cy - my
if dx * dx + dy * dy <= radius_sq:
label = f"{node.name} ({type(node).__name__})"
renderer._colour = (1.0, 1.0, 1.0, 0.9)
# Background for readability
tw = len(label) * 6 * 1 + 2
renderer._colour = (0.0, 0.0, 0.0, 0.7)
renderer.fill_rect(x, y - 10, tw, 10)
renderer._colour = (1.0, 1.0, 1.0, 0.9)
renderer.draw_text(label, (x + 1, y - 9), scale=1)
for child in node.children:
_walk(child)
if tree.root:
_walk(tree.root)
def _draw_profiler_hud(self, renderer, profiler, w, h):
"""Top-right corner: FPS, phase timings, node counts."""
x0 = w - 230
y0 = 10
line_h = 12
scale = 1
# Background
renderer._colour = (0.0, 0.0, 0.0, 0.75)
renderer.fill_rect(x0 - 5, y0 - 5, 225, 120)
# FPS / frame time
if profiler.fps >= 55:
renderer._colour = (0.0, 1.0, 0.0, 1.0)
elif profiler.fps >= 30:
renderer._colour = (1.0, 1.0, 0.0, 1.0)
else:
renderer._colour = (1.0, 0.0, 0.0, 1.0)
renderer.draw_text(f"FPS: {profiler.fps:.0f} Frame: {profiler.frame_time_ms:.1f}ms", (x0, y0), scale=scale)
y0 += line_h + 4
# Phase timings with mini bars
phases = ["physics", "process", "draw", "submit", "total"]
max_ms = max(profiler.phase_avg_ms(p) for p in phases) if any(profiler.phase_avg_ms(p) for p in phases) else 1.0
max_ms = max(max_ms, 1.0)
for phase in phases:
ms = profiler.phase_avg_ms(phase)
renderer._colour = (0.8, 0.8, 0.8, 1.0)
label = f"{phase:>8s}: {ms:5.1f}ms"
renderer.draw_text(label, (x0, y0), scale=scale)
# Mini bar
bar_x = x0 + 110
bar_w = min(100, (ms / max_ms) * 100)
if phase == "total":
renderer._colour = (0.3, 0.6, 1.0, 0.8)
else:
renderer._colour = (0.2, 0.8, 0.3, 0.8)
renderer.fill_rect(bar_x, y0 + 1, bar_w, line_h - 2)
y0 += line_h
y0 += 4
renderer._colour = (0.7, 0.7, 0.7, 1.0)
renderer.draw_text(f"Nodes: {profiler.node_count} Controls: {profiler.control_count}", (x0, y0), scale=scale)
def _draw_event_log(self, renderer, inspector, w, h):
"""Bottom-left: last 10 input events."""
entries = inspector.get_recent_log(10)
if not entries:
return
x0 = 10
y0 = h - 10 - len(entries) * 12
line_h = 12
scale = 1
# Background
renderer._colour = (0.0, 0.0, 0.0, 0.75)
renderer.fill_rect(x0 - 5, y0 - 5, 500, len(entries) * line_h + 10)
for entry in entries:
px, py = entry.position
text = f'{entry.event_type} ({px:.0f},{py:.0f}) -> {entry.target_path} "{entry.outcome}"'
if len(text) > 80:
text = text[:77] + "..."
# Colour by outcome
if entry.outcome == "delivered":
renderer._colour = (0.3, 1.0, 0.3, 0.9)
elif entry.outcome == "blocked" or entry.outcome == "miss":
renderer._colour = (1.0, 0.3, 0.3, 0.9)
else:
renderer._colour = (0.8, 0.8, 0.8, 0.9)
renderer.draw_text(text, (x0, y0), scale=scale)
y0 += line_h
def _draw_frame_graph(self, renderer, profiler, x, y, w, h):
"""Mini bar chart of recent frame times."""
times = profiler.frame_times(min(int(w), 120))
if not times:
return
# Background
renderer._colour = (0.0, 0.0, 0.0, 0.5)
renderer.fill_rect(x, y, w, h)
max_t = max(max(times), 16.67) # At least 60fps baseline
bar_w = w / len(times)
for i, t in enumerate(times):
bar_h = (t / max_t) * h
bx = x + i * bar_w
by = y + h - bar_h
# Green < 16ms, yellow < 33ms, red > 33ms
if t <= 16.67:
renderer._colour = (0.2, 0.8, 0.2, 0.8)
elif t <= 33.33:
renderer._colour = (1.0, 1.0, 0.0, 0.8)
else:
renderer._colour = (1.0, 0.2, 0.2, 0.8)
renderer.fill_rect(bx, by, max(bar_w - 0.5, 1), bar_h)
# 16.67ms reference line
ref_y = y + h - (16.67 / max_t) * h
renderer._colour = (1.0, 1.0, 1.0, 0.3)
renderer.draw_line(x, ref_y, x + w, ref_y)