GroveEngine/docs/UI_ARCHITECTURE.md
StillHammer 1b7703f07b feat(IIO)!: BREAKING CHANGE - Callback-based message dispatch
## Breaking Change

IIO API redesigned from manual pull+if-forest to callback dispatch.
All modules must update their subscribe() calls to pass handlers.

### Before (OLD API)
```cpp
io->subscribe("input:mouse");

void process(...) {
    while (io->hasMessages()) {
        auto msg = io->pullMessage();
        if (msg.topic == "input:mouse") {
            handleMouse(msg);
        } else if (msg.topic == "input:keyboard") {
            handleKeyboard(msg);
        }
    }
}
```

### After (NEW API)
```cpp
io->subscribe("input:mouse", [this](const Message& msg) {
    handleMouse(msg);
});

void process(...) {
    while (io->hasMessages()) {
        io->pullAndDispatch();  // Callbacks invoked automatically
    }
}
```

## Changes

**Core API (include/grove/IIO.h)**
- Added: `using MessageHandler = std::function<void(const Message&)>`
- Changed: `subscribe()` now requires `MessageHandler` callback parameter
- Changed: `subscribeLowFreq()` now requires `MessageHandler` callback
- Removed: `pullMessage()`
- Added: `pullAndDispatch()` - pulls and auto-dispatches to handlers

**Implementation (src/IntraIO.cpp)**
- Store callbacks in `Subscription.handler`
- `pullAndDispatch()` matches topic against ALL subscriptions (not just first)
- Fixed: Regex pattern compilation supports both wildcards (*) and regex (.*)
- Performance: ~1000 msg/s throughput (unchanged from before)

**Files Updated**
- 31 test/module files migrated to callback API (via parallel agents)
- 8 documentation files updated (DEVELOPER_GUIDE, USER_GUIDE, module READMEs)

## Bugs Fixed During Migration

1. **pullAndDispatch() early return bug**: Was only calling FIRST matching handler
   - Fix: Loop through ALL subscriptions, invoke all matching handlers

2. **Regex pattern compilation bug**: Pattern "player:.*" failed to match
   - Fix: Detect ".*" in pattern → use as regex, otherwise escape and convert wildcards

## Testing

 test_11_io_system: PASSED (IIO pub/sub, pattern matching, batching)
 test_threaded_module_system: 6/6 PASSED
 test_threaded_stress: 5/5 PASSED (50 modules, 100x reload, concurrent ops)
 test_12_datanode: PASSED
 10 TopicTree scenarios: 10/10 PASSED
 benchmark_e2e: ~1000 msg/s throughput

Total: 23+ tests passing

## Performance Impact

No performance regression from callback dispatch:
- IIO throughput: ~1000 msg/s (same as before)
- ThreadedModuleSystem: Speedup ~1.0x (barrier pattern expected)

## Migration Guide

For all modules using IIO:

1. Update subscribe() calls to include handler lambda
2. Replace pullMessage() loops with pullAndDispatch()
3. Move topic-specific logic from if-forest into callbacks

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 14:19:27 +07:00

9.1 KiB

UIModule - Architecture & Design

Architecture Overview

InputModule → IIO (input:*)
                ↓
            UIModule
         (Widget Tree)
                ↓
        UIRenderer (publishes)
                ↓
       IIO (render:*)
                ↓
          BgfxRenderer

All communication happens via IIO topics - no direct module-to-module calls.

Current Limitations

No Direct Data Binding

UIModule does not have built-in data binding. Updates must flow through the game module:

Slider → ui:value_changed → Game Module → ui:set_text → Label

This is intentional to maintain the IIO-based architecture where all communication goes through topics. The game module acts as the central coordinator.

Example:

// Subscribe to slider value changes (in setConfiguration)
gameIO->subscribe("ui:value_changed", [this](const grove::Message& msg) {
    std::string widgetId = msg.data->getString("widgetId", "");
    if (widgetId == "volume_slider") {
        double value = msg.data->getDouble("value", 0);
        setVolume(value);

        // Update label (must go through game module)
        auto updateMsg = std::make_unique<JsonDataNode>("set_text");
        updateMsg->setString("id", "volume_label");
        updateMsg->setString("text", "Volume: " + std::to_string((int)value) + "%");
        m_io->publish("ui:set_text", std::move(updateMsg));
    }
});

// In process()
while (gameIO->hasMessages() > 0) {
    gameIO->pullAndDispatch();  // Callback invoked automatically
}

Message Latency in Single-Threaded Mode

Current test showcases: ~16ms latency (1 frame @ 60 FPS)

Cause: Messages are queued until the next pullMessage() call in the game loop.

// Single-threaded game loop (test showcase)
while(running) {
    handleInput();         // UIModule publishes event
    processUIEvents();     // Game receives event (next frame!)
    update();
    render();
}

Solution: Run modules in separate threads (production architecture).

Threading Model

Current: Single-Threaded (Tests)

Test showcases run all modules in a single thread for simplicity:

void main() {
    auto uiModule = loadModule("UIModule");
    auto renderer = loadModule("BgfxRenderer");

    while(running) {
        // All in same thread - sequential execution
        processInput();
        uiModule->process(deltaTime);
        renderer->process(deltaTime);
        SDL_GL_SwapWindow(window);
    }
}

Latency: ~16ms (next frame polling)

Production: Multi-Threaded

Each module runs in its own thread:

// UIModule thread @ 60 FPS
void uiThread() {
    while(running) {
        // Receive inputs from queue (filled by InputModule thread)
        // Callbacks registered at subscribe() handle dispatch
        while(io->hasMessages()) {
            io->pullAndDispatch();  // Auto-dispatch to registered callbacks
        }

        update(deltaTime);

        // Publish events (immediately queued to Game thread)
        io->publish("ui:value_changed", msg);

        sleep(16ms);
    }
}

// Game thread @ 60 FPS
void gameThread() {
    while(running) {
        // Pull messages from queue (latency < 1ms)
        // Callbacks registered at subscribe() handle dispatch
        while(io->hasMessages()) {
            io->pullAndDispatch();  // Auto-dispatch, already in queue!
        }

        updateGameLogic(deltaTime);
        sleep(16ms);
    }
}

Latency: < 1ms (just mutex lock + memcpy)

IntraIO Message Delivery

IntraIO uses a queue-based system with push-on-publish, pull-on-consume:

  1. Module A publishes: io->publish("topic", msg)

    • Message immediately delivered to Module B's queue
    • No batching delay (batch thread is for low-freq subscriptions only)
  2. Module B pulls: io->pullMessage()

    • Returns message from queue
    • No network/serialization overhead

With threading: Messages are available in the queue immediately, so the next pullMessage() call retrieves them with minimal latency.

Without threading: All pullMessage() calls happen sequentially in the game loop, so messages wait until the next frame.

Layer Management

UIModule uses layer-based rendering to ensure proper draw order:

  • Game sprites: Layer 0-999
  • UI base layer: 1000 (configurable via baseLayer config)
  • UI widgets: baseLayer + widget index
  • Tooltips: Highest layer (automatic)
config.setInt("baseLayer", 1000);  // UI renders above game

Hot-Reload Support

UIModule fully supports hot-reload with state preservation.

State Preserved

  • Widget properties (position, size, colors)
  • Widget states (checkbox checked, slider values, text input content)
  • Scroll positions
  • Widget hierarchy

State Not Preserved

  • Transient animation states
  • Mouse hover states (recalculated on next mouse move)
  • Focus state (recalculated on next interaction)

How It Works

  1. Extract State:

    nlohmann::json UIModule::extractState() {
        json state;
        // Serialize all widget properties
        return state;
    }
    
  2. Reload Module:

    moduleLoader.reload();  // Unload .dll, recompile, reload
    
  3. Restore State:

    void UIModule::restoreState(const nlohmann::json& state) {
        // Restore widget properties from JSON
    }
    

Performance

Retained Mode Rendering

UIModule uses retained mode to optimize IIO traffic:

Message Reduction:

  • Static UI (20 widgets, 0 changes): 100% reduction (0 messages/frame after registration)
  • Mostly static (20 widgets, 3 changes): 85% reduction (3 vs 20 messages)
  • Fully dynamic (20 widgets, 20 changes): 0% reduction (comparison overhead)

Implementation:

  • Widgets cache render state
  • Compare against previous state each frame
  • Only publish render:*:update if changed

See UI_RENDERING.md for details.

Target Performance

  • UI update: < 1ms per frame
  • Render command generation: < 0.5ms per frame
  • Message routing: < 0.1ms per message
  • Widget count: Up to 100+ widgets without performance issues

Future Enhancements

Planned Features

Data Binding (Optional)

Link widget properties to game variables with automatic sync:

{
  "type": "label",
  "text": "${player.health}",
  "bindTo": "player.health"
}

Note: Will remain optional to preserve IIO architecture for those who prefer explicit control.

Animations

Tweening, fades, transitions:

{
  "type": "panel",
  "animations": {
    "enter": {"type": "fade", "duration": 0.3},
    "exit": {"type": "slide", "direction": "left", "duration": 0.2}
  }
}

Flexible Layout

Anchors, constraints, flex, grid:

{
  "type": "button",
  "anchor": "bottom-right",
  "offset": {"x": -20, "y": -20}
}
{
  "type": "panel",
  "layout": "flex",
  "flexDirection": "column",
  "gap": 10
}

Drag & Drop

{
  "type": "image",
  "draggable": true,
  "dragGroup": "inventory"
}

Rich Text

Markdown/BBCode formatting:

{
  "type": "label",
  "text": "**Bold** *italic* `code`",
  "richText": true
}

Themes

Swappable style sheets:

{
  "theme": "dark",
  "themeFile": "themes/dark.json"
}

9-Slice Sprites

Scalable sprite borders:

{
  "type": "panel",
  "sprite": "panel_border.png",
  "sliceMode": "9-slice",
  "sliceInsets": {"top": 8, "right": 8, "bottom": 8, "left": 8}
}

Input Validation

Regex patterns for text inputs:

{
  "type": "textinput",
  "validation": "^[a-zA-Z0-9]+$",
  "errorMessage": "Alphanumeric only"
}

Not Planned

These features violate core design principles and will never be added:

  • Direct widget-to-widget communication - All communication must go through IIO topics
  • Embedded game logic in widgets - Widgets are pure UI, game logic stays in game modules
  • Direct renderer access - Widgets publish render commands via IIO, never call renderer directly
  • Direct input polling - Widgets consume input:* topics, never poll input devices directly

Design Principles

  1. IIO-First: All communication via topics with callback dispatch, no direct coupling
  2. Callback Dispatch: Subscribe with handlers, no if-forest dispatch in process()
  3. Pull-Based Control: Module controls WHEN to process (pullAndDispatch), callbacks handle HOW
  4. Retained Mode: Cache state, minimize IIO traffic
  5. Hot-Reload Safe: Full state preservation across reloads
  6. Thread-Safe: Designed for multi-threaded production use
  7. Module Independence: UIModule never imports BgfxRenderer or InputModule headers
  8. Game Logic Separation: Widgets are dumb views, game modules handle logic

Integration with Other Modules

With BgfxRenderer

UIModule → render:sprite:*, render:text:* → BgfxRenderer

No direct dependency. UIModule doesn't know BgfxRenderer exists.

With InputModule

InputModule → input:* → UIModule

No direct dependency. UIModule doesn't know InputModule exists.

With Game Module

Bidirectional via IIO:

  • Game → ui:set_text, ui:set_visible → UIModule
  • UIModule → ui:action, ui:value_changed → Game

Game module coordinates all interactions.