Fixed three critical bugs preventing UITextInput from working: 1. **hitTest() missing textinput handler**: The hit test function only checked for button, slider, and checkbox types. Clicks on text input fields were never detected. FIX: Added textinput case to hitTest() in UIContext.cpp 2. **dispatchMouseButton() missing textinput handler**: Even if hit test worked, mouse button events were not dispatched to text input widgets. FIX: Added textinput case to dispatchMouseButton() in UIContext.cpp 3. **Keyboard event collision**: SDL_KEYDOWN was publishing events for printable characters with char=0, which were rejected by UITextInput. Printable chars should only come from SDL_TEXTINPUT. FIX: Only publish SDL_KEYDOWN for special keys (Backspace, Delete, arrows, etc.) Printable characters come exclusively from SDL_TEXTINPUT events. Changes: - UIContext.cpp: Added textinput handlers to hitTest() and dispatchMouseButton() - UITextInput.cpp: Added debug logging for gainFocus() and render() - UIModule.cpp: Added debug logging for widget clicks - test_ui_showcase.cpp: Fixed keyboard event handling (KEYDOWN vs TEXTINPUT) Tested: Text input now gains focus (border turns blue), accepts keyboard input, and displays typed text correctly. 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 🎨