Files
yaze/docs/z3ed/IMGUI_ID_MANAGEMENT_REFACTORING.md
2025-10-02 09:49:14 -04:00

16 KiB

ImGui ID Management Refactoring Plan

Date: October 2, 2025
Goal: Improve GUI ID management for better test automation and eliminate duplicate ID issues
Related: z3ed CLI test automation, ImGuiTestHarness integration

Executive Summary

The current YAZE codebase uses ImGui's ## prefix for hidden labels extensively (100+ occurrences), which creates challenges for automated testing and can lead to ID conflicts when widgets share the same label within the same ID scope.

Key Problems:

  1. Difficult to reference widgets - Test automation needs stable, predictable widget IDs
  2. Potential ID conflicts - Multiple ##table or ##canvas widgets in same scope
  3. Inconsistent naming - No convention for widget naming across editors
  4. No centralized registry - Hard to discover available widget IDs for testing

Proposed Solution:

  • Implement hierarchical ID scheme with scoping
  • Create centralized widget ID registry
  • Add systematic PushID/PopID usage
  • Establish naming conventions
  • Build tooling for ID discovery and validation

Current State Analysis

Pattern 1: Unnamed Widgets with ## Prefix (Most Common)

Found 100+ instances across the codebase:

// overworld_editor.cc
if (BeginTable("##BlocksetTable", ...)) { }
if (BeginChild("##RoomsetList")) { }
InputText("##CGXFile", &cgx_file_name_);

// dungeon_editor.cc
gui::InputHexByte("##layout", &room.layout);
gui::InputHexByte("##blockset", &room.blockset);

// palette_editor.cc
ColorPicker4("##picker", (float*)&color, ...);
ColorButton("##palette", current_palette[n], ...);

Problem: Multiple widgets named ##table, ##canvas, ##picker etc. across different editors can conflict when windows overlap or use shared ID stacks.

Pattern 2: Dynamic IDs with String Formatting

// graphics_editor.cc
ImGui::BeginChild(absl::StrFormat("##GfxSheet%02X", key).c_str(), ...);
ImGui::Begin(absl::StrFormat("##GfxEditPaletteChildWindow%d", id).c_str(), ...);

// tile16_editor.cc
ImGui::ColorButton(absl::StrFormat("##c%d", i).c_str(), ...);

Good: Unique IDs, but still hard to reference from test automation without knowing the exact format string.

Pattern 3: Explicit PushID/PopID (Rare)

Only found in third-party ImGuiTestEngine code, rarely used in YAZE:

// imgui_test_engine (example of good practice)
ImGui::PushID(window);
// ... widgets here ...
ImGui::PopID();

Missing: YAZE editors don't systematically use ID scoping.

Proposed Hierarchical ID Scheme

Design Principles

  1. Hierarchical Naming: Editor → Tab → Section → Widget
  2. Stable IDs: Don't change across frames or code refactors
  3. Discoverable: Can be enumerated for test automation
  4. Backwards Compatible: Gradual migration, no breaking changes

ID Format Convention

<Editor>/<Tab>/<Section>/<WidgetType>:<Name>

Examples:

Overworld/Main/Toolset/button:DrawTile
Overworld/Main/Canvas/canvas:OverworldMap
Overworld/MapSettings/Table/input:AreaGfx
Dungeon/Room/Properties/input:Layout
Palette/Main/Picker/button:Color0
Graphics/Sheets/Sheet0x00/canvas:Tiles

Benefits:

  • Unique: Hierarchical structure prevents conflicts
  • Readable: Clear what editor and context each widget belongs to
  • Testable: Test harness can reference by full path or partial match
  • Discoverable: Can build tree of all available widget IDs

Implementation Strategy

Phase 1: Core Infrastructure (2-3 hours)

Create Widget ID Registry System:

// src/app/gui/widget_id_registry.h
namespace yaze::gui {

class WidgetIdScope {
 public:
  explicit WidgetIdScope(const std::string& name);
  ~WidgetIdScope();  // Auto PopID()
  
  std::string GetFullPath() const;
  
 private:
  std::string name_;
  static std::vector<std::string> id_stack_;
};

class WidgetIdRegistry {
 public:
  static WidgetIdRegistry& Instance();
  
  // Register a widget for discovery
  void RegisterWidget(const std::string& full_path, 
                      const std::string& type,
                      ImGuiID imgui_id);
  
  // Query widgets for test automation
  std::vector<std::string> FindWidgets(const std::string& pattern) const;
  ImGuiID GetWidgetId(const std::string& full_path) const;
  
  // Export catalog for z3ed agent describe
  void ExportCatalog(const std::string& output_file) const;
  
 private:
  struct WidgetInfo {
    std::string full_path;
    std::string type;
    ImGuiID imgui_id;
  };
  std::unordered_map<std::string, WidgetInfo> widgets_;
};

// RAII helper macros
#define YAZE_WIDGET_SCOPE(name) \
  yaze::gui::WidgetIdScope _scope##__LINE__(name)

#define YAZE_REGISTER_WIDGET(type, name) \
  yaze::gui::WidgetIdRegistry::Instance().RegisterWidget( \
      _scope##__LINE__.GetFullPath() + "/" #type ":" name, \
      #type, \
      ImGui::GetItemID())

}  // namespace yaze::gui

Implementation:

// src/app/gui/widget_id_registry.cc
namespace yaze::gui {

thread_local std::vector<std::string> WidgetIdScope::id_stack_;

WidgetIdScope::WidgetIdScope(const std::string& name) : name_(name) {
  ImGui::PushID(name.c_str());
  id_stack_.push_back(name);
}

WidgetIdScope::~WidgetIdScope() {
  ImGui::PopID();
  if (!id_stack_.empty()) {
    id_stack_.pop_back();
  }
}

std::string WidgetIdScope::GetFullPath() const {
  std::string path;
  for (const auto& segment : id_stack_) {
    if (!path.empty()) path += "/";
    path += segment;
  }
  return path;
}

void WidgetIdRegistry::RegisterWidget(const std::string& full_path,
                                      const std::string& type,
                                      ImGuiID imgui_id) {
  WidgetInfo info{full_path, type, imgui_id};
  widgets_[full_path] = info;
}

std::vector<std::string> WidgetIdRegistry::FindWidgets(
    const std::string& pattern) const {
  std::vector<std::string> matches;
  for (const auto& [path, info] : widgets_) {
    if (path.find(pattern) != std::string::npos) {
      matches.push_back(path);
    }
  }
  return matches;
}

}  // namespace yaze::gui

Phase 2: Refactor Overworld Editor (3-4 hours)

Before (overworld_editor.cc):

void OverworldEditor::DrawToolset() {
  gui::DrawTable(toolset_table_);
  
  if (show_tile16_editor_) {
    if (ImGui::Begin("Tile16 Editor", &show_tile16_editor_)) {
      tile16_editor_.Update();
    }
    ImGui::End();
  }
}

void OverworldEditor::DrawOverworldCanvas() {
  if (ImGui::BeginChild("##OverworldCanvas", ImVec2(0, 0), true)) {
    // Canvas rendering...
  }
  ImGui::EndChild();
}

After:

void OverworldEditor::DrawToolset() {
  YAZE_WIDGET_SCOPE("Toolset");
  
  gui::DrawTable(toolset_table_);
  
  if (show_tile16_editor_) {
    YAZE_WIDGET_SCOPE("Tile16Editor");
    if (ImGui::Begin("Tile16 Editor", &show_tile16_editor_)) {
      tile16_editor_.Update();
    }
    ImGui::End();
  }
}

void OverworldEditor::DrawOverworldCanvas() {
  YAZE_WIDGET_SCOPE("Canvas");
  if (ImGui::BeginChild("OverworldCanvas", ImVec2(0, 0), true)) {
    YAZE_REGISTER_WIDGET(canvas, "OverworldCanvas");
    // Canvas rendering...
  }
  ImGui::EndChild();
}

void OverworldEditor::DrawOverworldMapSettings() {
  YAZE_WIDGET_SCOPE("MapSettings");
  
  if (BeginTable("SettingsTable", column_count, flags)) {
    YAZE_REGISTER_WIDGET(table, "SettingsTable");
    
    for (int map_id = 0; map_id < 64; ++map_id) {
      YAZE_WIDGET_SCOPE(absl::StrFormat("Map%02X", map_id));
      
      ImGui::TableNextRow();
      ImGui::TableSetColumnIndex(0);
      
      // GfxId input
      uint8_t gfx_id = maps[map_id].gfx_id;
      if (gui::InputHexByte("GfxId", &gfx_id)) {
        YAZE_REGISTER_WIDGET(input, "GfxId");
        maps[map_id].gfx_id = gfx_id;
      }
      
      // ... other inputs
    }
    ImGui::EndTable();
  }
}

Benefits:

  • Each editor gets its own top-level scope
  • Nested scopes create hierarchy automatically
  • Widgets are discoverable via registry
  • Test automation can reference: Overworld/Canvas/canvas:OverworldCanvas

Phase 3: Add Test Harness Integration (1-2 hours)

Enhance ImGuiTestHarness to use Widget Registry:

// imgui_test_harness_service.cc
absl::Status ImGuiTestHarnessServiceImpl::Click(
    const ClickRequest* request, ClickResponse* response) {
  
  const std::string& target = request->target();
  
  // Try hierarchical lookup first
  auto& registry = yaze::gui::WidgetIdRegistry::Instance();
  ImGuiID widget_id = registry.GetWidgetId(target);
  
  if (widget_id != 0) {
    // Found exact match in registry
    test->ItemClick(widget_id);
  } else {
    // Fallback to legacy string-based lookup
    test->ItemClick(target.c_str());
  }
  
  // Check for partial matches if exact fails
  auto matches = registry.FindWidgets(target);
  if (!matches.empty()) {
    response->add_suggestions(matches.begin(), matches.end());
  }
  
  return absl::OkStatus();
}

Widget Discovery Endpoint:

Add to proto:

// imgui_test_harness.proto
message DiscoverWidgetsRequest {
  string pattern = 1;  // e.g. "Overworld/Canvas/*" or "*button*"
}

message DiscoverWidgetsResponse {
  repeated WidgetInfo widgets = 1;
}

message WidgetInfo {
  string full_path = 1;
  string type = 2;
  uint32 imgui_id = 3;
}

service ImGuiTestHarness {
  // ... existing RPCs ...
  rpc DiscoverWidgets(DiscoverWidgetsRequest) returns (DiscoverWidgetsResponse);
}

CLI Integration:

# Discover all widgets in Overworld editor
z3ed agent discover --pattern "Overworld/*"

# Output:
# Overworld/Main/Toolset/button:DrawTile
# Overworld/Main/Toolset/button:Pan
# Overworld/Main/Canvas/canvas:OverworldMap
# Overworld/MapSettings/Table/input:AreaGfx
# ...

# Use in test
z3ed agent test --prompt "Click the DrawTile button in Overworld editor"
# Auto-resolves to: Overworld/Main/Toolset/button:DrawTile

Phase 4: Gradual Migration (Ongoing)

Priority Order:

  1. Overworld Editor (most complex, most tested)
  2. Dungeon Editor
  3. Palette Editor
  4. Graphics Editor
  5. Message Editor
  6. Sprite Editor
  7. Music Editor
  8. Screen Editor

Migration Strategy:

  • Add YAZE_WIDGET_SCOPE at function entry points
  • Replace ##name with meaningful names + register
  • Add registration for interactive widgets (buttons, inputs, canvases)
  • Test with z3ed agent test after each editor

Benefits for z3ed Agent Workflow

1. Stable Widget References

Before:

z3ed agent test --prompt "Click button:Overworld"
# Brittle: depends on exact button label

After:

z3ed agent test --prompt "Click the DrawTile tool in Overworld editor"
# Resolves to: Overworld/Main/Toolset/button:DrawTile
# Or partial match: */Toolset/button:DrawTile

2. Widget Discovery for AI

Command:

z3ed agent describe --widgets --format yaml > docs/api/yaze-widgets.yaml

Output (yaze-widgets.yaml):

widgets:
  - path: Overworld/Main/Toolset/button:DrawTile
    type: button
    context:
      editor: Overworld
      tab: Main
      section: Toolset
    actions: [click]
    
  - path: Overworld/Main/Canvas/canvas:OverworldMap
    type: canvas
    context:
      editor: Overworld
      tab: Main
      section: Canvas
    actions: [click, drag, scroll]
    
  - path: Overworld/MapSettings/Table/input:AreaGfx
    type: input
    context:
      editor: Overworld
      tab: MapSettings
      map_id: "00-3F (per row)"
    actions: [type, clear]

LLM Integration:

  • AI reads widget catalog to understand available UI
  • Generates commands referencing stable widget paths
  • Partial matching allows fuzzy references
  • Hierarchical structure provides context

3. Automated Test Generation

Workflow:

  1. User: "Make soldiers wear red armor"
  2. AI analyzes requirement
  3. AI queries widget catalog: "What widgets are needed?"
  4. AI generates test plan:
    1. Click Palette/Main/ExportButton
    2. Type "sprites_aux1" in FileDialog/input:Filename
    3. Wait for file dialog to close
    4. Assert file exists
    

Implementation Timeline

Phase Task Time Priority
1 Core infrastructure (WidgetIdScope, Registry) 2-3h P0
2 Overworld Editor refactoring 3-4h P0
3 Test Harness integration (Discover RPC) 1-2h P0
4 Dungeon Editor refactoring 2-3h P1
5 Palette Editor refactoring 1-2h P1
6 Graphics Editor refactoring 2-3h P1
7 Remaining editors 4-6h P2
8 Documentation + testing 2-3h P1

Total: 17-26 hours over 2-3 weeks

Immediate Start: Phase 1-3 (6-9 hours) - gets infrastructure working for current E2E validation

Testing Strategy

Unit Tests

TEST(WidgetIdRegistryTest, HierarchicalScopes) {
  {
    WidgetIdScope editor("Overworld");
    EXPECT_EQ(editor.GetFullPath(), "Overworld");
    
    {
      WidgetIdScope tab("Main");
      EXPECT_EQ(tab.GetFullPath(), "Overworld/Main");
      
      {
        WidgetIdScope section("Toolset");
        EXPECT_EQ(section.GetFullPath(), "Overworld/Main/Toolset");
      }
    }
  }
}

TEST(WidgetIdRegistryTest, FindWidgets) {
  auto& registry = WidgetIdRegistry::Instance();
  registry.RegisterWidget("Overworld/Main/Toolset/button:DrawTile", 
                          "button", 12345);
  
  auto matches = registry.FindWidgets("*DrawTile");
  EXPECT_EQ(matches.size(), 1);
  EXPECT_EQ(matches[0], "Overworld/Main/Toolset/button:DrawTile");
}

Integration Tests

# Test widget discovery via gRPC
grpcurl -plaintext -import-path src/app/core/proto -proto imgui_test_harness.proto \
  -d '{"pattern":"Overworld/*"}' \
  127.0.0.1:50052 yaze.test.ImGuiTestHarness/DiscoverWidgets

# Test CLI widget discovery
./build/bin/z3ed agent discover --pattern "*/button:*"

# Test with actual GUI automation
./build-grpc-test/bin/z3ed agent test \
  --prompt "Click the DrawTile button in Overworld editor"

Backwards Compatibility

Fallback Mechanism

absl::Status ImGuiTestHarnessServiceImpl::Click(...) {
  // Try new hierarchical system first
  ImGuiID widget_id = registry.GetWidgetId(target);
  
  if (widget_id != 0) {
    test->ItemClick(widget_id);
    return absl::OkStatus();
  }
  
  // Fallback to legacy string-based lookup
  auto info = test->ItemInfo(target.c_str());
  if (info.ID != 0) {
    test->ItemClick(target.c_str());
    return absl::OkStatus();
  }
  
  // Suggest alternatives from registry
  auto matches = registry.FindWidgets(target);
  std::string suggestions = absl::StrJoin(matches, ", ");
  return absl::NotFoundError(
      absl::StrFormat("Widget not found: %s. Did you mean: %s?",
                      target, suggestions));
}

Migration Path:

  1. New code uses hierarchical IDs
  2. Legacy string lookups still work
  3. Gradual migration editor-by-editor
  4. Eventually deprecate ## patterns

Success Metrics

Technical:

  • Zero duplicate ImGui ID warnings
  • 100% of interactive widgets registered
  • Widget discovery returns complete catalog
  • Test automation can reference any widget by path

UX:

  • Natural language prompts resolve to correct widgets
  • Partial matching finds widgets without exact names
  • Error messages suggest correct widget paths
  • Widget catalog enables LLM-driven automation

Quality:

  • No performance regression (registry overhead minimal)
  • No visual changes to GUI
  • Backwards compatible with existing code
  • Clean separation of concerns (registry in gui/, not editor/)

Next Steps

  1. Immediate (Tonight/Tomorrow):

    • Implement Phase 1 (Core infrastructure)
    • Add to CMake build
    • Write unit tests
  2. This Week:

    • Refactor Overworld Editor (Phase 2)
    • Integrate with test harness (Phase 3)
    • Test with z3ed agent test
  3. Next Week:

    • Migrate remaining editors (Phase 4)
    • Write documentation
    • Update z3ed guides with widget paths

References

ImGui Best Practices:

Related z3ed Documentation:


Last Updated: October 2, 2025
Author: GitHub Copilot (with @scawful)
Status: Proposal - Ready for implementation