345 lines
11 KiB
Markdown
345 lines
11 KiB
Markdown
# WASM Widget Tracking Implementation
|
|
|
|
**Date**: 2025-11-24
|
|
**Author**: Claude (AI Agent)
|
|
**Status**: Implemented
|
|
**Related Files**:
|
|
- `/Users/scawful/Code/yaze/src/app/platform/wasm/wasm_control_api.cc`
|
|
- `/Users/scawful/Code/yaze/src/app/gui/automation/widget_id_registry.h`
|
|
- `/Users/scawful/Code/yaze/src/app/gui/automation/widget_measurement.h`
|
|
- `/Users/scawful/Code/yaze/src/app/controller.cc`
|
|
|
|
## Overview
|
|
|
|
This document describes the implementation of actual ImGui widget bounds tracking for GUI automation in the YAZE WASM build. The system replaces placeholder hardcoded bounds with real-time widget position data from the `WidgetIdRegistry`.
|
|
|
|
## Problem Statement
|
|
|
|
The original `WasmControlApi::GetUIElementTree()` and `GetUIElementBounds()` implementations returned hardcoded placeholder bounds:
|
|
|
|
```cpp
|
|
// OLD: Hardcoded placeholder
|
|
elem["bounds"] = {{"x", 0}, {"y", 0}, {"width", 100}, {"height", 30}};
|
|
```
|
|
|
|
This prevented accurate GUI automation, as agents and test frameworks couldn't reliably click on or query widget positions.
|
|
|
|
## Solution Architecture
|
|
|
|
### 1. Existing Infrastructure (Already in Place)
|
|
|
|
YAZE already had a comprehensive widget tracking system:
|
|
|
|
- **`WidgetIdRegistry`** (`src/app/gui/automation/widget_id_registry.h`): Centralized registry that tracks all ImGui widgets with their bounds, visibility, and state
|
|
- **`WidgetMeasurement`** (`src/app/gui/automation/widget_measurement.h`): Measures widget dimensions using `ImGui::GetItemRectMin()` and `ImGui::GetItemRectMax()`
|
|
- **Frame lifecycle hooks**: `BeginFrame()` and `EndFrame()` calls already integrated in `Controller::OnLoad()` (lines 96-98)
|
|
|
|
### 2. Integration with WASM Control API
|
|
|
|
The implementation connects the WASM API to the existing widget registry:
|
|
|
|
#### Updated `GetUIElementTree()`
|
|
|
|
**File**: `/Users/scawful/Code/yaze/src/app/platform/wasm/wasm_control_api.cc` (lines 1386-1433)
|
|
|
|
```cpp
|
|
std::string WasmControlApi::GetUIElementTree() {
|
|
nlohmann::json result;
|
|
|
|
if (!IsReady()) {
|
|
result["error"] = "Control API not initialized";
|
|
result["elements"] = nlohmann::json::array();
|
|
return result.dump();
|
|
}
|
|
|
|
// Query the WidgetIdRegistry for all registered widgets
|
|
auto& registry = gui::WidgetIdRegistry::Instance();
|
|
const auto& all_widgets = registry.GetAllWidgets();
|
|
|
|
nlohmann::json elements = nlohmann::json::array();
|
|
|
|
// Convert WidgetInfo to JSON elements
|
|
for (const auto& [path, info] : all_widgets) {
|
|
nlohmann::json elem;
|
|
elem["id"] = info.full_path;
|
|
elem["type"] = info.type;
|
|
elem["label"] = info.label;
|
|
elem["enabled"] = info.enabled;
|
|
elem["visible"] = info.visible;
|
|
elem["window"] = info.window_name;
|
|
|
|
// Add bounds if available
|
|
if (info.bounds.valid) {
|
|
elem["bounds"] = {
|
|
{"x", info.bounds.min_x},
|
|
{"y", info.bounds.min_y},
|
|
{"width", info.bounds.max_x - info.bounds.min_x},
|
|
{"height", info.bounds.max_y - info.bounds.min_y}
|
|
};
|
|
} else {
|
|
elem["bounds"] = {
|
|
{"x", 0}, {"y", 0}, {"width", 0}, {"height", 0}
|
|
};
|
|
}
|
|
|
|
// Add metadata
|
|
if (!info.description.empty()) {
|
|
elem["description"] = info.description;
|
|
}
|
|
elem["imgui_id"] = static_cast<uint32_t>(info.imgui_id);
|
|
elem["last_seen_frame"] = info.last_seen_frame;
|
|
|
|
elements.push_back(elem);
|
|
}
|
|
|
|
result["elements"] = elements;
|
|
result["count"] = elements.size();
|
|
result["source"] = "WidgetIdRegistry";
|
|
|
|
return result.dump();
|
|
}
|
|
```
|
|
|
|
**Changes**:
|
|
- Removed hardcoded editor-specific element generation
|
|
- Queries `WidgetIdRegistry::GetAllWidgets()` for real widget data
|
|
- Returns actual bounds from `info.bounds` if valid
|
|
- Includes metadata: `imgui_id`, `last_seen_frame`, `description`
|
|
- Adds `source: "WidgetIdRegistry"` to JSON for debugging
|
|
|
|
#### Updated `GetUIElementBounds()`
|
|
|
|
**File**: `/Users/scawful/Code/yaze/src/app/platform/wasm/wasm_control_api.cc` (lines ~1435+)
|
|
|
|
```cpp
|
|
std::string WasmControlApi::GetUIElementBounds(const std::string& element_id) {
|
|
nlohmann::json result;
|
|
|
|
if (!IsReady()) {
|
|
result["error"] = "Control API not initialized";
|
|
return result.dump();
|
|
}
|
|
|
|
// Query the WidgetIdRegistry for the specific widget
|
|
auto& registry = gui::WidgetIdRegistry::Instance();
|
|
const auto* widget_info = registry.GetWidgetInfo(element_id);
|
|
|
|
result["id"] = element_id;
|
|
|
|
if (widget_info == nullptr) {
|
|
result["found"] = false;
|
|
result["error"] = "Element not found: " + element_id;
|
|
return result.dump();
|
|
}
|
|
|
|
result["found"] = true;
|
|
result["visible"] = widget_info->visible;
|
|
result["enabled"] = widget_info->enabled;
|
|
result["type"] = widget_info->type;
|
|
result["label"] = widget_info->label;
|
|
result["window"] = widget_info->window_name;
|
|
|
|
// Add bounds if available
|
|
if (widget_info->bounds.valid) {
|
|
result["x"] = widget_info->bounds.min_x;
|
|
result["y"] = widget_info->bounds.min_y;
|
|
result["width"] = widget_info->bounds.max_x - widget_info->bounds.min_x;
|
|
result["height"] = widget_info->bounds.max_y - widget_info->bounds.min_y;
|
|
result["bounds_valid"] = true;
|
|
} else {
|
|
result["x"] = 0;
|
|
result["y"] = 0;
|
|
result["width"] = 0;
|
|
result["height"] = 0;
|
|
result["bounds_valid"] = false;
|
|
}
|
|
|
|
// Add metadata
|
|
result["imgui_id"] = static_cast<uint32_t>(widget_info->imgui_id);
|
|
result["last_seen_frame"] = widget_info->last_seen_frame;
|
|
|
|
if (!widget_info->description.empty()) {
|
|
result["description"] = widget_info->description;
|
|
}
|
|
|
|
return result.dump();
|
|
}
|
|
```
|
|
|
|
**Changes**:
|
|
- Removed hardcoded element ID pattern matching
|
|
- Queries `WidgetIdRegistry::GetWidgetInfo(element_id)` for specific widget
|
|
- Returns `found: false` if widget doesn't exist
|
|
- Returns actual bounds with `bounds_valid` flag
|
|
- Includes full widget metadata
|
|
|
|
### 3. Frame Lifecycle Integration
|
|
|
|
**File**: `/Users/scawful/Code/yaze/src/app/controller.cc` (lines 96-98)
|
|
|
|
The widget registry is already integrated into the main render loop:
|
|
|
|
```cpp
|
|
absl::Status Controller::OnLoad() {
|
|
// ... ImGui::NewFrame() setup ...
|
|
|
|
gui::WidgetIdRegistry::Instance().BeginFrame();
|
|
absl::Status update_status = editor_manager_.Update();
|
|
gui::WidgetIdRegistry::Instance().EndFrame();
|
|
|
|
RETURN_IF_ERROR(update_status);
|
|
return absl::OkStatus();
|
|
}
|
|
```
|
|
|
|
**Frame Lifecycle**:
|
|
1. `BeginFrame()`: Resets `seen_in_current_frame` flag for all widgets
|
|
2. Widget rendering: Editors register widgets during `editor_manager_.Update()`
|
|
3. `EndFrame()`: Marks unseen widgets as invisible, prunes stale entries
|
|
|
|
### 4. Widget Registration (Future Work)
|
|
|
|
**Current State**: Widget registration infrastructure exists but **editors are not yet registering widgets**.
|
|
|
|
**Registration Pattern** (to be implemented in editors):
|
|
|
|
```cpp
|
|
// Example: Dungeon Editor registering a card
|
|
{
|
|
gui::WidgetIdScope scope("DungeonEditor");
|
|
|
|
if (ImGui::Begin("Room Selector##dungeon")) {
|
|
// Widget now has full path: "DungeonEditor/Room Selector"
|
|
|
|
if (ImGui::Button("Load Room")) {
|
|
// After rendering button, register it
|
|
gui::WidgetIdRegistry::Instance().RegisterWidget(
|
|
scope.GetWidgetPath("button", "Load Room"),
|
|
"button",
|
|
ImGui::GetItemID(),
|
|
"Loads the selected room into the editor"
|
|
);
|
|
}
|
|
}
|
|
ImGui::End();
|
|
}
|
|
```
|
|
|
|
**Macros Available**:
|
|
- `YAZE_WIDGET_SCOPE(name)`: RAII scope for hierarchical widget paths
|
|
- `YAZE_REGISTER_WIDGET(type, name)`: Register widget after rendering
|
|
- `YAZE_REGISTER_CURRENT_WIDGET(type)`: Auto-extract widget name from ImGui
|
|
|
|
## API Usage
|
|
|
|
### JavaScript API
|
|
|
|
**Get All UI Elements**:
|
|
```javascript
|
|
const elements = window.yaze.control.getUIElementTree();
|
|
console.log(elements);
|
|
// Output:
|
|
// {
|
|
// "elements": [
|
|
// {
|
|
// "id": "DungeonEditor/RoomSelector/button:LoadRoom",
|
|
// "type": "button",
|
|
// "label": "Load Room",
|
|
// "visible": true,
|
|
// "enabled": true,
|
|
// "window": "DungeonEditor",
|
|
// "bounds": {"x": 150, "y": 200, "width": 100, "height": 30},
|
|
// "imgui_id": 12345,
|
|
// "last_seen_frame": 4567
|
|
// }
|
|
// ],
|
|
// "count": 1,
|
|
// "source": "WidgetIdRegistry"
|
|
// }
|
|
```
|
|
|
|
**Get Specific Widget Bounds**:
|
|
```javascript
|
|
const bounds = window.yaze.control.getUIElementBounds("DungeonEditor/RoomSelector/button:LoadRoom");
|
|
console.log(bounds);
|
|
// Output:
|
|
// {
|
|
// "id": "DungeonEditor/RoomSelector/button:LoadRoom",
|
|
// "found": true,
|
|
// "visible": true,
|
|
// "enabled": true,
|
|
// "type": "button",
|
|
// "label": "Load Room",
|
|
// "window": "DungeonEditor",
|
|
// "x": 150,
|
|
// "y": 200,
|
|
// "width": 100,
|
|
// "height": 30,
|
|
// "bounds_valid": true,
|
|
// "imgui_id": 12345,
|
|
// "last_seen_frame": 4567
|
|
// }
|
|
```
|
|
|
|
## Performance Considerations
|
|
|
|
1. **Memory**: `WidgetIdRegistry` stores widget metadata in `std::unordered_map`, which grows with UI complexity. Stale widgets are pruned after 600 frames of inactivity.
|
|
|
|
2. **CPU Overhead**:
|
|
- `BeginFrame()`: O(n) iteration to reset flags (n = number of widgets)
|
|
- Widget registration: O(1) hash map lookup/insert
|
|
- `EndFrame()`: O(n) iteration for pruning stale entries
|
|
|
|
3. **Optimization**: Widget measurement can be disabled globally:
|
|
```cpp
|
|
gui::WidgetMeasurement::Instance().SetEnabled(false);
|
|
```
|
|
|
|
## Testing
|
|
|
|
**Manual Test (WASM Build)**:
|
|
```bash
|
|
# Build WASM
|
|
./scripts/build-wasm.sh
|
|
|
|
# Serve locally
|
|
cd build-wasm
|
|
python3 -m http.server 8080
|
|
|
|
# Open browser console
|
|
window.yaze.control.getUIElementTree();
|
|
```
|
|
|
|
**Expected Behavior**:
|
|
- Initially, `elements` array will be empty (no widgets registered yet)
|
|
- After editors implement registration, widgets will appear with real bounds
|
|
- `bounds_valid: false` for widgets not yet rendered in current frame
|
|
|
|
## Next Steps
|
|
|
|
1. **Add widget registration to editors**:
|
|
- `DungeonEditorV2`: Register room tabs, cards, buttons
|
|
- `OverworldEditor`: Register canvas, tile selectors, property panels
|
|
- `GraphicsEditor`: Register graphics sheets, palette pickers
|
|
|
|
2. **Add registration helpers**:
|
|
- Create `AgentUI::RegisterButton()`, `AgentUI::RegisterCard()` wrappers
|
|
- Auto-register common widget patterns (cards with visibility flags)
|
|
|
|
3. **Extend API**:
|
|
- `FindWidgetsByPattern(pattern)`: Search widgets by regex
|
|
- `ClickWidget(element_id)`: Simulate click via automation API
|
|
|
|
## References
|
|
|
|
- Widget ID Registry: `/Users/scawful/Code/yaze/src/app/gui/automation/widget_id_registry.h`
|
|
- Widget Measurement: `/Users/scawful/Code/yaze/src/app/gui/automation/widget_measurement.h`
|
|
- WASM Control API: `/Users/scawful/Code/yaze/src/app/platform/wasm/wasm_control_api.h`
|
|
- Controller Integration: `/Users/scawful/Code/yaze/src/app/controller.cc` (lines 96-98)
|
|
|
|
## Revision History
|
|
|
|
| Date | Author | Changes |
|
|
|------------|--------|--------------------------------------------|
|
|
| 2025-11-24 | Claude | Initial implementation and documentation |
|