From 5e45ab015c09afe02a2f403c62b7c0801a9ee90e Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 21 Jan 2026 23:26:33 -0500 Subject: [PATCH] Add ImGui Scene Explorer (F4) for runtime object inspection (#136) New Features: - Scene Explorer window (F4) displays hierarchical tree of all scenes - Shows UIDrawable hierarchy with type, name, and visibility status - Click scene name to switch active scene - Double-click drawables to toggle visibility - Displays Python repr() for cached objects, enabling custom class debugging - Entity display within Grid nodes Bug Fixes: - Fix PythonObjectCache re-registration: when retrieving objects from collections, newly created Python wrappers are now re-registered in the cache. Previously, inline-created objects (e.g., scene.children.append(Frame(...))) would lose their cache entry when the temporary Python object was GC'd, causing repeated wrapper allocation on each access. - Fix console focus stealing: removed aggressive focus reclaim that caused title bar flashing when clicking in Scene Explorer Infrastructure: - Add GameEngine::getSceneNames() to expose scene list for explorer - Scene Explorer uses same enabled flag as console (ImGuiConsole::isEnabled()) Co-Authored-By: Claude Opus 4.5 --- src/GameEngine.cpp | 19 ++- src/GameEngine.h | 3 + src/ImGuiConsole.cpp | 4 +- src/ImGuiSceneExplorer.cpp | 285 +++++++++++++++++++++++++++++++++++++ src/ImGuiSceneExplorer.h | 46 ++++++ src/UICollection.cpp | 12 ++ src/UIEntityCollection.cpp | 12 ++ 7 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 src/ImGuiSceneExplorer.cpp create mode 100644 src/ImGuiSceneExplorer.h diff --git a/src/GameEngine.cpp b/src/GameEngine.cpp index 55a1ca1..9ccf273 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -202,6 +202,16 @@ Scene* GameEngine::getScene(const std::string& name) { auto it = scenes.find(name); return (it != scenes.end()) ? it->second : nullptr; } + +std::vector GameEngine::getSceneNames() const { + std::vector names; + names.reserve(scenes.size()); + for (const auto& [name, scene] : scenes) { + names.push_back(name); + } + return names; +} + void GameEngine::changeScene(std::string s) { changeScene(s, TransitionType::None, 0.0f); @@ -346,9 +356,10 @@ void GameEngine::run() profilerOverlay->render(*render_target); } - // Render ImGui console overlay + // Render ImGui overlays (console and scene explorer) if (imguiInitialized && !headless) { console.render(); + sceneExplorer.render(*this); ImGui::SFML::Render(*window); } @@ -550,6 +561,12 @@ void GameEngine::sUserInput() continue; // Don't pass grave key to game } + // Handle F4 for scene explorer toggle + if (event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::F4) { + sceneExplorer.toggle(); + continue; // Don't pass F4 to game + } + // If console wants keyboard, don't pass keyboard events to game if (console.wantsKeyboardInput()) { // Still process non-keyboard events (mouse, window close, etc.) diff --git a/src/GameEngine.h b/src/GameEngine.h index f6cb27e..512726d 100644 --- a/src/GameEngine.h +++ b/src/GameEngine.h @@ -11,6 +11,7 @@ #include "SceneTransition.h" #include "Profiler.h" #include "ImGuiConsole.h" +#include "ImGuiSceneExplorer.h" #include #include #include @@ -191,6 +192,7 @@ private: // ImGui console overlay ImGuiConsole console; + ImGuiSceneExplorer sceneExplorer; bool imguiInitialized = false; // #219 - Thread synchronization for background Python threads @@ -214,6 +216,7 @@ public: ~GameEngine(); Scene* currentScene(); Scene* getScene(const std::string& name); // #118: Get scene by name + std::vector getSceneNames() const; // #136: Get all scene names for explorer void changeScene(std::string); void changeScene(std::string sceneName, TransitionType transitionType, float duration); void createScene(std::string); diff --git a/src/ImGuiConsole.cpp b/src/ImGuiConsole.cpp index 0d02be1..d0363bf 100644 --- a/src/ImGuiConsole.cpp +++ b/src/ImGuiConsole.cpp @@ -381,9 +381,9 @@ void ImGuiConsole::render() { } ImGui::PopItemWidth(); - // Keep focus on input + // Keep focus on input only after executing a command ImGui::SetItemDefaultFocus(); - if (reclaimFocus || (visible && !ImGui::IsAnyItemActive())) { + if (reclaimFocus) { ImGui::SetKeyboardFocusHere(-1); } diff --git a/src/ImGuiSceneExplorer.cpp b/src/ImGuiSceneExplorer.cpp new file mode 100644 index 0000000..f12d1d9 --- /dev/null +++ b/src/ImGuiSceneExplorer.cpp @@ -0,0 +1,285 @@ +#include "ImGuiSceneExplorer.h" +#include "imgui.h" +#include "GameEngine.h" +#include "Scene.h" +#include "UIDrawable.h" +#include "UIFrame.h" +#include "UICaption.h" +#include "UISprite.h" +#include "UIGrid.h" +#include "UIEntity.h" +#include "ImGuiConsole.h" +#include "PythonObjectCache.h" +#include +#include +#include + +bool ImGuiSceneExplorer::isEnabled() { + // Use the same enabled flag as the console + return ImGuiConsole::isEnabled(); +} + +void ImGuiSceneExplorer::toggle() { + if (isEnabled()) { + visible = !visible; + } +} + +void ImGuiSceneExplorer::render(GameEngine& engine) { + if (!visible || !isEnabled()) return; + + ImGuiIO& io = ImGui::GetIO(); + + // Position on the right side of the screen + ImGui::SetNextWindowSize(ImVec2(350, io.DisplaySize.y * 0.6f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x - 360, 10), ImGuiCond_FirstUseEver); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + + if (!ImGui::Begin("Scene Explorer", &visible, flags)) { + ImGui::End(); + return; + } + + // Scene tree header + ImGui::Text("Scenes (%zu):", engine.getSceneNames().size()); + ImGui::Separator(); + + // Scrollable tree region + ImGui::BeginChild("SceneTree", ImVec2(0, 0), false, ImGuiWindowFlags_HorizontalScrollbar); + + // Get all scene names and render each + std::string currentSceneName = engine.scene; + std::vector sceneNames = engine.getSceneNames(); + + for (const auto& sceneName : sceneNames) { + bool isActive = (sceneName == currentSceneName); + renderSceneNode(engine, sceneName, isActive); + } + + ImGui::EndChild(); + + ImGui::End(); +} + +void ImGuiSceneExplorer::renderSceneNode(GameEngine& engine, const std::string& sceneName, bool isActive) { + ImGuiTreeNodeFlags sceneFlags = ImGuiTreeNodeFlags_OpenOnArrow + | ImGuiTreeNodeFlags_OpenOnDoubleClick + | ImGuiTreeNodeFlags_DefaultOpen; + + if (isActive) { + sceneFlags |= ImGuiTreeNodeFlags_Selected; + } + + // Build label with active indicator + std::string label = sceneName; + if (isActive) { + label += " [active]"; + } + + // Scene icon/indicator + bool sceneOpen = ImGui::TreeNodeEx(("##scene_" + sceneName).c_str(), sceneFlags, "%s %s", + isActive ? ">" : " ", label.c_str()); + + // Click to activate scene (if not already active) + if (ImGui::IsItemClicked() && !isActive) { + engine.changeScene(sceneName); + } + + if (sceneOpen) { + // Get scene's UI elements + auto ui_elements = engine.scene_ui(sceneName); + if (ui_elements && !ui_elements->empty()) { + for (auto& drawable : *ui_elements) { + if (drawable) { + renderDrawableNode(drawable, 0); + } + } + } else { + ImGui::TextDisabled(" (empty)"); + } + ImGui::TreePop(); + } +} + +void ImGuiSceneExplorer::renderDrawableNode(std::shared_ptr drawable, int depth) { + if (!drawable) return; + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow + | ImGuiTreeNodeFlags_OpenOnDoubleClick + | ImGuiTreeNodeFlags_SpanAvailWidth; + + // Check if this node has children + bool hasChildren = false; + UIFrame* frame = nullptr; + UIGrid* grid = nullptr; + + switch (drawable->derived_type()) { + case PyObjectsEnum::UIFRAME: + frame = static_cast(drawable.get()); + hasChildren = frame->children && !frame->children->empty(); + break; + case PyObjectsEnum::UIGRID: + grid = static_cast(drawable.get()); + hasChildren = (grid->entities && !grid->entities->empty()) || + (grid->children && !grid->children->empty()); + break; + default: + break; + } + + if (!hasChildren) { + flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; + } + + // Visibility indicator + const char* visIcon = drawable->visible ? "[v]" : "[h]"; + + // Build display string + std::string displayName = getDisplayName(drawable.get()); + std::string nodeLabel = std::string(visIcon) + " " + getTypeName(drawable.get()) + ": " + displayName; + + // Use pointer as unique ID + bool nodeOpen = ImGui::TreeNodeEx((void*)(intptr_t)drawable.get(), flags, "%s", nodeLabel.c_str()); + + // Double-click to toggle visibility + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { + drawable->visible = !drawable->visible; + } + + // Handle leaf nodes (NoTreePushOnOpen means TreePop not needed) + if (!hasChildren) { + return; + } + + if (nodeOpen) { + // Render children based on type + if (frame && frame->children) { + for (auto& child : *frame->children) { + if (child) { + renderDrawableNode(child, depth + 1); + } + } + } + + if (grid) { + // Render entities + if (grid->entities && !grid->entities->empty()) { + ImGuiTreeNodeFlags entityGroupFlags = ImGuiTreeNodeFlags_OpenOnArrow; + bool entitiesOpen = ImGui::TreeNodeEx("Entities", entityGroupFlags, "Entities (%zu)", + grid->entities->size()); + if (entitiesOpen) { + for (auto& entity : *grid->entities) { + if (entity) { + renderEntityNode(entity); + } + } + ImGui::TreePop(); + } + } + + // Render grid's drawable children (overlays) + if (grid->children && !grid->children->empty()) { + ImGuiTreeNodeFlags overlayGroupFlags = ImGuiTreeNodeFlags_OpenOnArrow; + bool overlaysOpen = ImGui::TreeNodeEx("Overlays", overlayGroupFlags, "Overlays (%zu)", + grid->children->size()); + if (overlaysOpen) { + for (auto& child : *grid->children) { + if (child) { + renderDrawableNode(child, depth + 1); + } + } + ImGui::TreePop(); + } + } + } + + ImGui::TreePop(); + } +} + +void ImGuiSceneExplorer::renderEntityNode(std::shared_ptr entity) { + if (!entity) return; + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_Leaf + | ImGuiTreeNodeFlags_NoTreePushOnOpen + | ImGuiTreeNodeFlags_SpanAvailWidth; + + std::string displayName = getEntityDisplayName(entity.get()); + std::string nodeLabel = "Entity: " + displayName; + + ImGui::TreeNodeEx((void*)(intptr_t)entity.get(), flags, "%s", nodeLabel.c_str()); +} + +std::string ImGuiSceneExplorer::getDisplayName(UIDrawable* drawable) { + if (!drawable) return "(null)"; + + // Try to get Python object repr from cache + if (drawable->serial_number != 0) { + PyObject* pyObj = PythonObjectCache::getInstance().lookup(drawable->serial_number); + if (pyObj) { + PyObject* repr = PyObject_Repr(pyObj); + if (repr) { + const char* repr_str = PyUnicode_AsUTF8(repr); + if (repr_str) { + std::string result(repr_str); + Py_DECREF(repr); + Py_DECREF(pyObj); + return result; + } + Py_DECREF(repr); + } + Py_DECREF(pyObj); + } + } + + // Use name if available + if (!drawable->name.empty()) { + return "\"" + drawable->name + "\""; + } + + // Fall back to address + std::ostringstream oss; + oss << "@" << std::hex << std::setw(8) << std::setfill('0') << (uintptr_t)drawable; + return oss.str(); +} + +std::string ImGuiSceneExplorer::getEntityDisplayName(UIEntity* entity) { + if (!entity) return "(null)"; + + // Try to get Python object repr from cache + if (entity->serial_number != 0) { + PyObject* pyObj = PythonObjectCache::getInstance().lookup(entity->serial_number); + if (pyObj) { + PyObject* repr = PyObject_Repr(pyObj); + if (repr) { + const char* repr_str = PyUnicode_AsUTF8(repr); + if (repr_str) { + std::string result(repr_str); + Py_DECREF(repr); + Py_DECREF(pyObj); + return result; + } + Py_DECREF(repr); + } + Py_DECREF(pyObj); + } + } + + // Fall back to position + std::ostringstream oss; + oss << "(" << entity->position.x << ", " << entity->position.y << ")"; + return oss.str(); +} + +const char* ImGuiSceneExplorer::getTypeName(UIDrawable* drawable) { + if (!drawable) return "null"; + + switch (drawable->derived_type()) { + case PyObjectsEnum::UIFRAME: return "Frame"; + case PyObjectsEnum::UICAPTION: return "Caption"; + case PyObjectsEnum::UISPRITE: return "Sprite"; + case PyObjectsEnum::UIGRID: return "Grid"; + default: return "Unknown"; + } +} diff --git a/src/ImGuiSceneExplorer.h b/src/ImGuiSceneExplorer.h new file mode 100644 index 0000000..d41163a --- /dev/null +++ b/src/ImGuiSceneExplorer.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +class GameEngine; +class UIDrawable; +class UIEntity; +class UIFrame; +class UIGrid; + +/** + * @brief ImGui-based scene tree explorer for debugging + * + * Displays hierarchical view of all scenes and their UI elements. + * Allows switching between scenes and collapsing/expanding the tree. + * Activated by F4 key. Mutually exclusive with the console (grave key). + */ +class ImGuiSceneExplorer { +public: + ImGuiSceneExplorer() = default; + + // Core functionality + void render(GameEngine& engine); + void toggle(); + bool isVisible() const { return visible; } + void setVisible(bool v) { visible = v; } + + // Configuration - uses same enabled flag as console + static bool isEnabled(); + +private: + bool visible = false; + + // Tree rendering helpers + void renderSceneNode(GameEngine& engine, const std::string& sceneName, bool isActive); + void renderDrawableNode(std::shared_ptr drawable, int depth = 0); + void renderEntityNode(std::shared_ptr entity); + + // Get display name for a drawable (name or type + address) + std::string getDisplayName(UIDrawable* drawable); + std::string getEntityDisplayName(UIEntity* entity); + + // Get type name string + const char* getTypeName(UIDrawable* drawable); +}; diff --git a/src/UICollection.cpp b/src/UICollection.cpp index 6512dfd..fe7fe52 100644 --- a/src/UICollection.cpp +++ b/src/UICollection.cpp @@ -126,6 +126,18 @@ static PyObject* convertDrawableToPython(std::shared_ptr drawable) { if (type) { Py_DECREF(type); } + + // Re-register in cache if the object has a serial number + // This handles the case where the original Python wrapper was GC'd + // but the C++ object persists (e.g., inline-created objects added to collections) + if (obj && drawable->serial_number != 0) { + PyObject* weakref = PyWeakref_NewRef(obj, NULL); + if (weakref) { + PythonObjectCache::getInstance().registerObject(drawable->serial_number, weakref); + Py_DECREF(weakref); + } + } + return obj; } diff --git a/src/UIEntityCollection.cpp b/src/UIEntityCollection.cpp index cd11181..a51d1b7 100644 --- a/src/UIEntityCollection.cpp +++ b/src/UIEntityCollection.cpp @@ -130,6 +130,18 @@ PyObject* UIEntityCollection::getitem(PyUIEntityCollectionObject* self, Py_ssize o->data = std::static_pointer_cast(target); o->weakreflist = NULL; + + // Re-register in cache if the entity has a serial number + // This handles the case where the original Python wrapper was GC'd + // but the C++ object persists (e.g., inline-created objects added to collections) + if (target->serial_number != 0) { + PyObject* weakref = PyWeakref_NewRef((PyObject*)o, NULL); + if (weakref) { + PythonObjectCache::getInstance().registerObject(target->serial_number, weakref); + Py_DECREF(weakref); + } + } + return (PyObject*)o; }