GroveEngine/modules/UIModule
StillHammer 5cef0e25b0 fix: UIModule button interaction + JsonDataNode array children support
- Fix JsonDataNode::getChildReadOnly() to handle JSON array access by numeric index
- Fix test_ui_showcase to use JSON array for children (matching test_single_button pattern)
- Add visual test files: test_single_button, test_ui_showcase, test_sprite_debug
- Clean up debug logging from SpritePass, SceneCollector, UIButton, BgfxDevice

The root cause was that UITree couldn't access array children in JSON layouts.
UIButton hover/click now works correctly in both test files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-05 18:23:16 +07:00
..
Core fix: UIModule button interaction + JsonDataNode array children support 2026-01-05 18:23:16 +07:00
Rendering fix: UIModule button interaction + JsonDataNode array children support 2026-01-05 18:23:16 +07:00
Widgets fix: UIModule button interaction + JsonDataNode array children support 2026-01-05 18:23:16 +07:00
CMakeLists.txt fix: UIModule button interaction + JsonDataNode array children support 2026-01-05 18:23:16 +07:00
README.md fix: Resolve bgfx Frame 1 crash on Windows DLL + MinGW GCC 15 compatibility 2025-12-30 11:03:06 +07:00
UIModule.cpp fix: UIModule button interaction + JsonDataNode array children support 2026-01-05 18:23:16 +07:00
UIModule.h feat: Complete UIModule Phase 7 - ScrollPanel & Tooltips 2025-11-29 07:13:13 +08:00

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
  • Rendering Integration: Publishes render:* topics to BgfxRenderer
  • 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)

Topic Payload Description
render:sprite {x, y, w, h, color, layer, ...} UI rectangles/images
render:text {x, y, text, fontSize, color, layer} UI text

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)

Performance

  • Target: < 1ms per frame for UI updates
  • Batching: Multiple UI rectangles batched into single render commands
  • 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

  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:

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 🎨