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> |
||
|---|---|---|
| .. | ||
| Core | ||
| Rendering | ||
| Widgets | ||
| CMakeLists.txt | ||
| README.md | ||
| UIModule.cpp | ||
| UIModule.h | ||
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
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
#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:
{
"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
// 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 for details on retained mode rendering.
Widget Properties Reference
UIButton
{
"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
{
"type": "UILabel",
"id": "my_label",
"x": 100, "y": 100,
"width": 300, "height": 50,
"text": "Hello World",
"fontSize": 32,
"color": 4294967295
}
UIPanel
{
"type": "UIPanel",
"id": "my_panel",
"x": 0, "y": 0,
"width": 400, "height": 300,
"color": 2155905279
}
UISlider
{
"type": "UISlider",
"id": "volume",
"x": 100, "y": 100,
"width": 300, "height": 30,
"min": 0.0,
"max": 100.0,
"value": 50.0,
"orientation": "horizontal"
}
UICheckbox
{
"type": "UICheckbox",
"id": "enable_vsync",
"x": 100, "y": 100,
"width": 30, "height": 30,
"checked": true
}
UITextInput
{
"type": "UITextInput",
"id": "player_name",
"x": 100, "y": 100,
"width": 300, "height": 40,
"text": "",
"placeholder": "Enter name...",
"fontSize": 20,
"maxLength": 32
}
UIProgressBar
{
"type": "UIProgressBar",
"id": "loading",
"x": 100, "y": 100,
"width": 400, "height": 30,
"value": 0.65,
"bgColor": 2155905279,
"fillColor": 4278255360
}
UIImage
{
"type": "UIImage",
"id": "logo",
"x": 100, "y": 100,
"width": 200, "height": 200,
"textureId": 5
}
UIScrollPanel
{
"type": "UIScrollPanel",
"id": "inventory",
"x": 100, "y": 100,
"width": 400, "height": 600,
"contentHeight": 1200,
"scrollY": 0.0,
"scrollbarWidth": 20,
"bgColor": 2155905279
}
UITooltip
{
"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:
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 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
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)
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
- Create
Widgets/MyCustomWidget.h/.cpp - Inherit from
UIWidgetbase class - Implement
render(),handleInput(), and event handlers - Add to
UILayout::createWidget()factory - Use in JSON layouts with
"type": "MyCustomWidget"
Example:
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 🎨