Implement retained mode rendering system to reduce IIO message traffic. Widgets now register render entries that persist across frames and only publish updates when visual state changes. Core changes: - UIWidget: Add dirty flags and render ID tracking - UIRenderer: Add retained mode API (registerEntry, updateRect, updateText, updateSprite) - SceneCollector: Add persistent sprite/text storage with add/update/remove handlers - IIO protocol: New topics (render:sprite:add/update/remove, render:text:add/update/remove) Widget migrations: - UIPanel, UIButton, UILabel, UICheckbox, UISlider - UIProgressBar, UITextInput, UIImage, UIScrollPanel Documentation: - docs/UI_RENDERING.md: Retained mode architecture - modules/UIModule/README.md: Rendering modes section - docs/DEVELOPER_GUIDE.md: Updated IIO topics Performance: Reduces message traffic by 85-97% for static/mostly-static UIs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
528 lines
14 KiB
Markdown
528 lines
14 KiB
Markdown
# UIModule
|
|
|
|
Complete UI widget system for GroveEngine with layout, scrolling, tooltips, and automatic input handling.
|
|
|
|
## Overview
|
|
|
|
UIModule provides a full-featured UI system that integrates with BgfxRenderer for rendering and InputModule for input. All communication happens via IIO topics, ensuring complete decoupling.
|
|
|
|
## Features
|
|
|
|
- **10 Widget Types**: Buttons, Labels, Panels, Checkboxes, Sliders, Text Inputs, Progress Bars, Images, Scroll Panels, Tooltips
|
|
- **Flexible Layout**: JSON-based UI definition with hierarchical widget trees
|
|
- **Automatic Input**: Consumes `input:*` topics from InputModule automatically
|
|
- **Retained Mode Rendering**: Widgets cache render state and only publish IIO messages when visual properties change, reducing message traffic for static UIs
|
|
- **Layer Management**: UI renders on top of game content (layer 1000+)
|
|
- **Hot-Reload Support**: Full state preservation across module reloads
|
|
|
|
## Architecture
|
|
|
|
```
|
|
InputModule → IIO (input:mouse:*, input:keyboard:*)
|
|
↓
|
|
UIModule
|
|
(Widget Tree)
|
|
↓
|
|
UIRenderer (publishes)
|
|
↓
|
|
IIO (render:sprite, render:text)
|
|
↓
|
|
BgfxRenderer
|
|
```
|
|
|
|
## Available Widgets
|
|
|
|
| Widget | Purpose | Events Published |
|
|
|--------|---------|------------------|
|
|
| **UIButton** | Clickable button | `ui:click`, `ui:action` |
|
|
| **UILabel** | Static text display | - |
|
|
| **UIPanel** | Container widget | - |
|
|
| **UICheckbox** | Toggle checkbox | `ui:value_changed` |
|
|
| **UISlider** | Value slider (horizontal/vertical) | `ui:value_changed` |
|
|
| **UITextInput** | Text input field | `ui:value_changed`, `ui:text_submitted` |
|
|
| **UIProgressBar** | Progress indicator | - |
|
|
| **UIImage** | Sprite/image display | - |
|
|
| **UIScrollPanel** | Scrollable container | `ui:scroll` |
|
|
| **UITooltip** | Hover tooltip | - |
|
|
|
|
## Configuration
|
|
|
|
```cpp
|
|
JsonDataNode config("config");
|
|
config.setInt("windowWidth", 1920);
|
|
config.setInt("windowHeight", 1080);
|
|
config.setString("layoutFile", "./assets/ui/main_menu.json");
|
|
config.setInt("baseLayer", 1000); // UI renders above game content
|
|
|
|
uiModule->setConfiguration(config, uiIO.get(), nullptr);
|
|
```
|
|
|
|
## Usage
|
|
|
|
### Loading UIModule
|
|
|
|
```cpp
|
|
#include <grove/ModuleLoader.h>
|
|
#include <grove/IntraIOManager.h>
|
|
|
|
auto& ioManager = IntraIOManager::getInstance();
|
|
auto uiIO = ioManager.createInstance("ui_module");
|
|
auto gameIO = ioManager.createInstance("game_logic");
|
|
|
|
ModuleLoader uiLoader;
|
|
auto uiModule = uiLoader.load("./modules/UIModule.dll", "ui_module");
|
|
|
|
JsonDataNode config("config");
|
|
config.setString("layoutFile", "./ui/menu.json");
|
|
uiModule->setConfiguration(config, uiIO.get(), nullptr);
|
|
```
|
|
|
|
### Creating UI Layout (JSON)
|
|
|
|
`ui/menu.json`:
|
|
```json
|
|
{
|
|
"widgets": [
|
|
{
|
|
"type": "UIPanel",
|
|
"id": "background",
|
|
"x": 0,
|
|
"y": 0,
|
|
"width": 1920,
|
|
"height": 1080,
|
|
"color": 2155905279
|
|
},
|
|
{
|
|
"type": "UIButton",
|
|
"id": "play_button",
|
|
"x": 860,
|
|
"y": 500,
|
|
"width": 200,
|
|
"height": 60,
|
|
"text": "Play",
|
|
"fontSize": 24,
|
|
"action": "start_game"
|
|
},
|
|
{
|
|
"type": "UILabel",
|
|
"id": "title",
|
|
"x": 760,
|
|
"y": 300,
|
|
"width": 400,
|
|
"height": 100,
|
|
"text": "My Awesome Game",
|
|
"fontSize": 48,
|
|
"color": 4294967295
|
|
},
|
|
{
|
|
"type": "UISlider",
|
|
"id": "volume_slider",
|
|
"x": 800,
|
|
"y": 650,
|
|
"width": 320,
|
|
"height": 40,
|
|
"min": 0.0,
|
|
"max": 100.0,
|
|
"value": 75.0,
|
|
"orientation": "horizontal"
|
|
},
|
|
{
|
|
"type": "UICheckbox",
|
|
"id": "fullscreen_toggle",
|
|
"x": 800,
|
|
"y": 720,
|
|
"width": 30,
|
|
"height": 30,
|
|
"checked": false
|
|
},
|
|
{
|
|
"type": "UIScrollPanel",
|
|
"id": "settings_panel",
|
|
"x": 100,
|
|
"y": 100,
|
|
"width": 400,
|
|
"height": 600,
|
|
"contentHeight": 1200,
|
|
"scrollbarWidth": 20
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Handling UI Events
|
|
|
|
```cpp
|
|
// Subscribe to UI events in your game module
|
|
gameIO->subscribe("ui:click");
|
|
gameIO->subscribe("ui:action");
|
|
gameIO->subscribe("ui:value_changed");
|
|
|
|
// In your game module's process()
|
|
void GameModule::process(const IDataNode& input) {
|
|
while (m_io->hasMessages() > 0) {
|
|
auto msg = m_io->pullMessage();
|
|
|
|
if (msg.topic == "ui:action") {
|
|
std::string action = msg.data->getString("action", "");
|
|
std::string widgetId = msg.data->getString("widgetId", "");
|
|
|
|
if (action == "start_game") {
|
|
startGame();
|
|
}
|
|
}
|
|
|
|
if (msg.topic == "ui:value_changed") {
|
|
std::string widgetId = msg.data->getString("widgetId", "");
|
|
|
|
if (widgetId == "volume_slider") {
|
|
double value = msg.data->getDouble("value", 50.0);
|
|
setVolume(value);
|
|
}
|
|
|
|
if (widgetId == "fullscreen_toggle") {
|
|
bool checked = msg.data->getBool("value", false);
|
|
setFullscreen(checked);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## IIO Topics
|
|
|
|
### Topics Consumed (from InputModule)
|
|
|
|
| Topic | Payload | Description |
|
|
|-------|---------|-------------|
|
|
| `input:mouse:move` | `{x, y}` | Mouse position |
|
|
| `input:mouse:button` | `{button, pressed, x, y}` | Mouse click |
|
|
| `input:mouse:wheel` | `{delta}` | Mouse wheel |
|
|
| `input:keyboard:key` | `{scancode, pressed, ...}` | Key event |
|
|
| `input:keyboard:text` | `{text}` | Text input (for UITextInput) |
|
|
|
|
### Topics Published (UI Events)
|
|
|
|
| Topic | Payload | Description |
|
|
|-------|---------|-------------|
|
|
| `ui:click` | `{widgetId, x, y}` | Widget clicked |
|
|
| `ui:action` | `{widgetId, action}` | Button action triggered |
|
|
| `ui:value_changed` | `{widgetId, value}` | Slider/checkbox/input changed |
|
|
| `ui:text_submitted` | `{widgetId, text}` | Text input submitted (Enter) |
|
|
| `ui:hover` | `{widgetId, enter}` | Mouse entered/left widget |
|
|
| `ui:scroll` | `{widgetId, scrollX, scrollY}` | Scroll panel scrolled |
|
|
|
|
### Topics Published (Rendering)
|
|
|
|
**Retained Mode (current):**
|
|
|
|
| Topic | Payload | Description |
|
|
|-------|---------|-------------|
|
|
| `render:sprite:add` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Register new sprite |
|
|
| `render:sprite:update` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Update existing sprite |
|
|
| `render:sprite:remove` | `{renderId}` | Unregister sprite |
|
|
| `render:text:add` | `{renderId, x, y, text, fontSize, color, layer}` | Register new text |
|
|
| `render:text:update` | `{renderId, x, y, text, fontSize, color, layer}` | Update existing text |
|
|
| `render:text:remove` | `{renderId}` | Unregister text |
|
|
|
|
**Immediate Mode (legacy, still supported):**
|
|
|
|
| Topic | Payload | Description |
|
|
|-------|---------|-------------|
|
|
| `render:sprite` | `{x, y, w, h, color, layer, ...}` | Ephemeral sprite (1 frame) |
|
|
| `render:text` | `{x, y, text, fontSize, color, layer}` | Ephemeral text (1 frame) |
|
|
|
|
See [UI Rendering Documentation](../../docs/UI_RENDERING.md) for details on retained mode rendering.
|
|
|
|
## Widget Properties Reference
|
|
|
|
### UIButton
|
|
```json
|
|
{
|
|
"type": "UIButton",
|
|
"id": "my_button",
|
|
"x": 100, "y": 100,
|
|
"width": 200, "height": 50,
|
|
"text": "Click Me",
|
|
"fontSize": 24,
|
|
"textColor": 4294967295,
|
|
"bgColor": 3435973836,
|
|
"hoverColor": 4286611711,
|
|
"action": "button_clicked"
|
|
}
|
|
```
|
|
|
|
### UILabel
|
|
```json
|
|
{
|
|
"type": "UILabel",
|
|
"id": "my_label",
|
|
"x": 100, "y": 100,
|
|
"width": 300, "height": 50,
|
|
"text": "Hello World",
|
|
"fontSize": 32,
|
|
"color": 4294967295
|
|
}
|
|
```
|
|
|
|
### UIPanel
|
|
```json
|
|
{
|
|
"type": "UIPanel",
|
|
"id": "my_panel",
|
|
"x": 0, "y": 0,
|
|
"width": 400, "height": 300,
|
|
"color": 2155905279
|
|
}
|
|
```
|
|
|
|
### UISlider
|
|
```json
|
|
{
|
|
"type": "UISlider",
|
|
"id": "volume",
|
|
"x": 100, "y": 100,
|
|
"width": 300, "height": 30,
|
|
"min": 0.0,
|
|
"max": 100.0,
|
|
"value": 50.0,
|
|
"orientation": "horizontal"
|
|
}
|
|
```
|
|
|
|
### UICheckbox
|
|
```json
|
|
{
|
|
"type": "UICheckbox",
|
|
"id": "enable_vsync",
|
|
"x": 100, "y": 100,
|
|
"width": 30, "height": 30,
|
|
"checked": true
|
|
}
|
|
```
|
|
|
|
### UITextInput
|
|
```json
|
|
{
|
|
"type": "UITextInput",
|
|
"id": "player_name",
|
|
"x": 100, "y": 100,
|
|
"width": 300, "height": 40,
|
|
"text": "",
|
|
"placeholder": "Enter name...",
|
|
"fontSize": 20,
|
|
"maxLength": 32
|
|
}
|
|
```
|
|
|
|
### UIProgressBar
|
|
```json
|
|
{
|
|
"type": "UIProgressBar",
|
|
"id": "loading",
|
|
"x": 100, "y": 100,
|
|
"width": 400, "height": 30,
|
|
"value": 0.65,
|
|
"bgColor": 2155905279,
|
|
"fillColor": 4278255360
|
|
}
|
|
```
|
|
|
|
### UIImage
|
|
```json
|
|
{
|
|
"type": "UIImage",
|
|
"id": "logo",
|
|
"x": 100, "y": 100,
|
|
"width": 200, "height": 200,
|
|
"textureId": 5
|
|
}
|
|
```
|
|
|
|
### UIScrollPanel
|
|
```json
|
|
{
|
|
"type": "UIScrollPanel",
|
|
"id": "inventory",
|
|
"x": 100, "y": 100,
|
|
"width": 400, "height": 600,
|
|
"contentHeight": 1200,
|
|
"scrollY": 0.0,
|
|
"scrollbarWidth": 20,
|
|
"bgColor": 2155905279
|
|
}
|
|
```
|
|
|
|
### UITooltip
|
|
```json
|
|
{
|
|
"type": "UITooltip",
|
|
"id": "help_tooltip",
|
|
"x": 100, "y": 100,
|
|
"width": 200, "height": 80,
|
|
"text": "This is a helpful tooltip",
|
|
"fontSize": 16,
|
|
"visible": false
|
|
}
|
|
```
|
|
|
|
## Layer Management
|
|
|
|
UIModule uses **layer-based rendering** to ensure UI elements render correctly:
|
|
|
|
- **Game sprites**: Layer 0-999
|
|
- **UI elements**: Layer 1000+ (default baseLayer)
|
|
- **Tooltips**: Automatically use highest layer
|
|
|
|
Configure base layer in UIModule configuration:
|
|
```cpp
|
|
config.setInt("baseLayer", 1000);
|
|
```
|
|
|
|
## Hot-Reload Support
|
|
|
|
UIModule fully supports hot-reload with state preservation:
|
|
|
|
### State Preserved
|
|
- All widget properties (position, size, colors)
|
|
- Widget states (button hover, slider values, checkbox checked)
|
|
- Scroll positions
|
|
- Text input content
|
|
|
|
### State Not Preserved
|
|
- Transient animation states
|
|
- Mouse hover states (recalculated on next mouse move)
|
|
|
|
## Rendering Modes
|
|
|
|
UIModule uses **retained mode rendering** to optimize IIO message traffic. Widgets register render entries once and only publish updates when visual properties change.
|
|
|
|
### Retained Mode
|
|
|
|
Widgets cache their render state and compare against previous values each frame. Only changed properties trigger IIO messages.
|
|
|
|
**Message Reduction:**
|
|
- Static UI (20 widgets, 0 changes/frame): 100% reduction (0 messages after initial registration)
|
|
- Mostly static UI (20 widgets, 3 changes/frame): 85% reduction (3 messages vs 20)
|
|
- Fully dynamic UI (20 widgets, 20 changes/frame): 0% reduction (retained mode has comparison overhead)
|
|
|
|
**Topics:** `render:sprite:add/update/remove`, `render:text:add/update/remove`
|
|
|
|
### Immediate Mode (Legacy)
|
|
|
|
Widgets publish render commands every frame regardless of changes. Still supported for compatibility and ephemeral content (debug overlays, particles).
|
|
|
|
**Topics:** `render:sprite`, `render:text`
|
|
|
|
See [UI Rendering Documentation](../../docs/UI_RENDERING.md) for implementation details and migration guide.
|
|
|
|
## Performance
|
|
|
|
- **Target**: < 1ms per frame for UI updates
|
|
- **Retained mode**: Reduces IIO traffic by 85%+ for typical UIs (static menus, HUDs)
|
|
- **Event filtering**: Only processes mouse events within widget bounds
|
|
- **Layout caching**: Widget tree built once from JSON, not every frame
|
|
|
|
## Testing
|
|
|
|
### Visual Test
|
|
```bash
|
|
cmake -DGROVE_BUILD_UI_MODULE=ON -B build
|
|
cmake --build build --target test_ui_widgets
|
|
./build/tests/test_ui_widgets
|
|
```
|
|
|
|
### Integration Test (with InputModule + BgfxRenderer)
|
|
```bash
|
|
cmake -DGROVE_BUILD_BGFX_RENDERER=ON -DGROVE_BUILD_UI_MODULE=ON -DGROVE_BUILD_INPUT_MODULE=ON -B build
|
|
cmake --build build
|
|
cd build && ctest -R IT_014 --output-on-failure
|
|
```
|
|
|
|
## Dependencies
|
|
|
|
- **GroveEngine Core**: IModule, IIO, IDataNode
|
|
- **BgfxRenderer**: For rendering (via IIO, not direct dependency)
|
|
- **InputModule**: For input handling (via IIO, not direct dependency)
|
|
- **nlohmann/json**: JSON parsing
|
|
- **spdlog**: Logging
|
|
|
|
## Files
|
|
|
|
```
|
|
modules/UIModule/
|
|
├── README.md # This file
|
|
├── CMakeLists.txt # Build configuration
|
|
├── UIModule.h # Main module
|
|
├── UIModule.cpp
|
|
├── Core/
|
|
│ ├── UIContext.h # Global UI state
|
|
│ ├── UIContext.cpp
|
|
│ ├── UILayout.h # Layout management
|
|
│ ├── UILayout.cpp
|
|
│ ├── UIStyle.h # Widget styling
|
|
│ ├── UIStyle.cpp
|
|
│ ├── UITooltip.h # Tooltip system
|
|
│ ├── UITooltip.cpp
|
|
│ ├── UITree.h # Widget hierarchy
|
|
│ ├── UITree.cpp
|
|
│ └── UIWidget.h # Base widget interface
|
|
├── Widgets/
|
|
│ ├── UIButton.h/.cpp
|
|
│ ├── UILabel.h/.cpp
|
|
│ ├── UIPanel.h/.cpp
|
|
│ ├── UICheckbox.h/.cpp
|
|
│ ├── UISlider.h/.cpp
|
|
│ ├── UITextInput.h/.cpp
|
|
│ ├── UIProgressBar.h/.cpp
|
|
│ ├── UIImage.h/.cpp
|
|
│ └── UIScrollPanel.h/.cpp
|
|
└── Rendering/
|
|
├── UIRenderer.h # Publishes render commands
|
|
└── UIRenderer.cpp
|
|
```
|
|
|
|
## Implementation Phases
|
|
|
|
- ✅ **Phase 1**: Core widgets (Button, Label, Panel)
|
|
- ✅ **Phase 2**: Input widgets (Checkbox, Slider, TextInput)
|
|
- ✅ **Phase 3**: Advanced widgets (ProgressBar, Image)
|
|
- ✅ **Phase 4-5**: Layout system and styling
|
|
- ✅ **Phase 6**: Interactive demo
|
|
- ✅ **Phase 7**: ScrollPanel + Tooltips
|
|
|
|
## Extensibility
|
|
|
|
### Adding a Custom Widget
|
|
|
|
1. Create `Widgets/MyCustomWidget.h/.cpp`
|
|
2. Inherit from `UIWidget` base class
|
|
3. Implement `render()`, `handleInput()`, and event handlers
|
|
4. Add to `UILayout::createWidget()` factory
|
|
5. Use in JSON layouts with `"type": "MyCustomWidget"`
|
|
|
|
Example:
|
|
```cpp
|
|
class MyCustomWidget : public UIWidget {
|
|
public:
|
|
void render(UIRenderer& renderer) override {
|
|
// Publish render commands via renderer
|
|
renderer.drawRect(m_x, m_y, m_width, m_height, m_color);
|
|
}
|
|
|
|
void onMouseDown(int button, double x, double y) override {
|
|
// Handle click
|
|
auto event = std::make_unique<JsonDataNode>("event");
|
|
event->setString("widgetId", m_id);
|
|
m_io->publish("ui:custom_event", std::move(event));
|
|
}
|
|
};
|
|
```
|
|
|
|
## License
|
|
|
|
See LICENSE at project root.
|
|
|
|
---
|
|
|
|
**UIModule - Complete UI system for GroveEngine** 🎨
|