GroveEngine/modules/UIModule/README.md
StillHammer a106c78bc8 feat: Retained mode rendering for UIModule
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>
2026-01-06 14:06:28 +07:00

14 KiB

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

  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 🎨