diff --git a/CLAUDE.md b/CLAUDE.md index 44232e7..28dcad6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,12 @@ GroveEngine is a C++17 hot-reload module system for game engines. It supports dy **Module-specific:** - **[BgfxRenderer README](modules/BgfxRenderer/README.md)** - 2D rendering module (sprites, text, tilemap, particles) - **[InputModule README](modules/InputModule/README.md)** - Input handling (mouse, keyboard, gamepad) -- **[UIModule README](modules/UIModule/README.md)** - User interface system (buttons, panels, scrolling, tooltips) +- **[UIModule README](modules/UIModule/README.md)** - User interface system overview + +**UIModule Documentation (⚠️ READ BEFORE WORKING ON UI):** +- **[UI Widgets](docs/UI_WIDGETS.md)** - Widget properties, JSON configuration, custom widgets +- **[UI Topics](docs/UI_TOPICS.md)** - IIO topics reference and usage patterns +- **[UI Architecture](docs/UI_ARCHITECTURE.md)** - Threading model, limitations, design principles - **[UI Rendering](docs/UI_RENDERING.md)** - Retained mode rendering architecture ## Available Modules @@ -37,6 +42,10 @@ cmake --build build -j4 # Run all tests (23+ tests) cd build && ctest --output-on-failure +# Run visual tests (IMPORTANT: always run from project root for correct asset paths) +./build/tests/test_ui_showcase # UI showcase with all widgets +./build/tests/test_renderer_showcase # Renderer showcase (sprites, text, particles) + # Build with ThreadSanitizer cmake -DGROVE_ENABLE_TSAN=ON -B build-tsan cmake --build build-tsan -j4 @@ -100,7 +109,8 @@ std::lock_guard lock2(mutex2); // DEADLOCK RISK ### UIModule - **UIRenderer**: Publishes render commands to BgfxRenderer via IIO (layer 1000+) - **Widgets**: UIButton, UIPanel, UILabel, UICheckbox, UISlider, UITextInput, UIProgressBar, UIImage, UIScrollPanel, UITooltip -- **IIO Topics**: Consumes `input:*`, publishes `ui:click`, `ui:action`, `ui:value_changed`, etc. +- **IIO Topics**: Consumes `input:*`, `ui:set_text`, `ui:set_visible`; publishes `ui:click`, `ui:action`, `ui:value_changed`, etc. +- **⚠️ Before modifying UI code:** Read [UI Architecture](docs/UI_ARCHITECTURE.md) for threading model, [UI Widgets](docs/UI_WIDGETS.md) for widget properties, [UI Topics](docs/UI_TOPICS.md) for IIO patterns ### InputModule - **Backends**: SDLBackend (mouse, keyboard, gamepad Phase 2) diff --git a/docs/UI_ARCHITECTURE.md b/docs/UI_ARCHITECTURE.md new file mode 100644 index 0000000..257eabe --- /dev/null +++ b/docs/UI_ARCHITECTURE.md @@ -0,0 +1,367 @@ +# 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:** +```cpp +// Slider value changed +if (msg.topic == "ui:value_changed" && widgetId == "volume_slider") { + double value = msg.data->getDouble("value", 0); + setVolume(value); + + // Update label (must go through game module) + auto updateMsg = std::make_unique("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)); +} +``` + +### 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. + +```cpp +// 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: + +```cpp +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: + +```cpp +// UIModule thread @ 60 FPS +void uiThread() { + while(running) { + // Receive inputs from queue (filled by InputModule thread) + while(io->hasMessages()) { + handleMessage(io->pullMessage()); + } + + 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) + while(io->hasMessages()) { + handleMessage(io->pullMessage()); // 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) + +```cpp +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:** + ```cpp + nlohmann::json UIModule::extractState() { + json state; + // Serialize all widget properties + return state; + } + ``` + +2. **Reload Module:** + ```cpp + moduleLoader.reload(); // Unload .dll, recompile, reload + ``` + +3. **Restore State:** + ```cpp + 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](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: + +```json +{ + "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: + +```json +{ + "type": "panel", + "animations": { + "enter": {"type": "fade", "duration": 0.3}, + "exit": {"type": "slide", "direction": "left", "duration": 0.2} + } +} +``` + +#### Flexible Layout + +Anchors, constraints, flex, grid: + +```json +{ + "type": "button", + "anchor": "bottom-right", + "offset": {"x": -20, "y": -20} +} +``` + +```json +{ + "type": "panel", + "layout": "flex", + "flexDirection": "column", + "gap": 10 +} +``` + +#### Drag & Drop + +```json +{ + "type": "image", + "draggable": true, + "dragGroup": "inventory" +} +``` + +#### Rich Text + +Markdown/BBCode formatting: + +```json +{ + "type": "label", + "text": "**Bold** *italic* `code`", + "richText": true +} +``` + +#### Themes + +Swappable style sheets: + +```json +{ + "theme": "dark", + "themeFile": "themes/dark.json" +} +``` + +#### 9-Slice Sprites + +Scalable sprite borders: + +```json +{ + "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: + +```json +{ + "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, no direct coupling +2. **Retained Mode:** Cache state, minimize IIO traffic +3. **Hot-Reload Safe:** Full state preservation across reloads +4. **Thread-Safe:** Designed for multi-threaded production use +5. **Module Independence:** UIModule never imports BgfxRenderer or InputModule headers +6. **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. diff --git a/docs/UI_TOPICS.md b/docs/UI_TOPICS.md new file mode 100644 index 0000000..fc26e4f --- /dev/null +++ b/docs/UI_TOPICS.md @@ -0,0 +1,125 @@ +# UIModule - IIO Topics Reference + +Complete reference of all IIO topics consumed and published by UIModule. + +## 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` | `{keyCode, pressed, char}` | Keyboard event | + +### UI Control Commands + +| Topic | Payload | Description | +|-------|---------|-------------| +| `ui:set_text` | `{id, text}` | Update label text dynamically | +| `ui:set_visible` | `{id, visible}` | Show/hide widget | +| `ui:set_value` | `{id, value}` | Set slider/progressbar value | +| `ui:load` | `{layoutPath}` | Load new UI layout from file | + +## 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 | + +### Rendering (Retained Mode) + +| 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 | + +### Rendering (Immediate Mode - Legacy) + +| 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](UI_RENDERING.md) for details on retained vs immediate mode. + +## Usage Examples + +### Handling UI Events + +```cpp +// Subscribe to UI events +gameIO->subscribe("ui:action"); +gameIO->subscribe("ui:value_changed"); + +// In game loop +while (m_io->hasMessages() > 0) { + auto msg = m_io->pullMessage(); + + if (msg.topic == "ui:action") { + std::string action = msg.data->getString("action", ""); + 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); + } + } +} +``` + +### Updating UI Dynamically + +```cpp +// Update label text +auto msg = std::make_unique("set_text"); +msg->setString("id", "score_label"); +msg->setString("text", "Score: " + std::to_string(score)); +m_io->publish("ui:set_text", std::move(msg)); + +// Hide/show widget +auto msg = std::make_unique("set_visible"); +msg->setString("id", "loading_panel"); +msg->setBool("visible", false); +m_io->publish("ui:set_visible", std::move(msg)); + +// Update progress bar +auto msg = std::make_unique("set_value"); +msg->setString("id", "health_bar"); +msg->setDouble("value", 0.75); // 75% +m_io->publish("ui:set_value", std::move(msg)); +``` + +### Slider + Label Pattern + +Common pattern: update a label when a slider changes. + +```cpp +if (msg.topic == "ui:value_changed" && widgetId == "volume_slider") { + double value = msg.data->getDouble("value", 50.0); + setVolume(value); + + // Update label to show current value + auto updateMsg = std::make_unique("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)); +} +``` diff --git a/docs/UI_WIDGETS.md b/docs/UI_WIDGETS.md new file mode 100644 index 0000000..105e532 --- /dev/null +++ b/docs/UI_WIDGETS.md @@ -0,0 +1,376 @@ +# UIModule - Widget Reference + +Complete reference for all available widgets and their properties. + +## Widget Overview + +| Widget | Purpose | Events | +|--------|---------|--------| +| **UIButton** | Clickable button | `ui:click`, `ui:action` | +| **UILabel** | Static/dynamic text | - | +| **UIPanel** | Container widget | - | +| **UICheckbox** | Toggle checkbox | `ui:value_changed` | +| **UISlider** | Value slider | `ui:value_changed` | +| **UITextInput** | Text entry field | `ui:value_changed`, `ui:text_submitted` | +| **UIProgressBar** | Progress indicator | - | +| **UIImage** | Sprite/texture display | - | +| **UIScrollPanel** | Scrollable container | `ui:scroll` | +| **UITooltip** | Hover tooltip | - | + +## Common Properties + +All widgets support these base properties: + +```json +{ + "type": "WidgetType", + "id": "unique_id", + "x": 0, + "y": 0, + "width": 100, + "height": 100, + "visible": true, + "tooltip": "Optional tooltip text" +} +``` + +## UIButton + +Clickable button with hover/press states. + +```json +{ + "type": "button", + "id": "my_button", + "x": 100, + "y": 100, + "width": 200, + "height": 50, + "text": "Click Me", + "onClick": "button_action", + "style": { + "normal": { + "bgColor": "0x0984e3FF", + "textColor": "0xFFFFFFFF", + "textureId": 0 + }, + "hover": { + "bgColor": "0x74b9ffFF", + "textColor": "0xFFFFFFFF" + }, + "pressed": { + "bgColor": "0x0652a1FF", + "textColor": "0xFFFFFFFF" + } + } +} +``` + +**Properties:** +- `text` - Button label text +- `onClick` - Action name published to `ui:action` +- `style` - Visual states (normal, hover, pressed) + - `bgColor` - Background color (hex RGBA) + - `textColor` - Text color (hex RGBA) + - `textureId` - Sprite texture ID (0 = solid color) + +**Events:** +- `ui:click` - `{widgetId, x, y}` +- `ui:action` - `{widgetId, action}` where action = onClick value + +## UILabel + +Static or dynamic text display. + +```json +{ + "type": "label", + "id": "my_label", + "x": 100, + "y": 100, + "width": 300, + "height": 50, + "text": "Hello World", + "style": { + "fontSize": 24, + "color": "0xFFFFFFFF" + } +} +``` + +**Properties:** +- `text` - Label text (can be updated via `ui:set_text`) +- `style.fontSize` - Font size in pixels +- `style.color` - Text color (hex RGBA) + +**Dynamic Updates:** +```cpp +auto msg = std::make_unique("set_text"); +msg->setString("id", "my_label"); +msg->setString("text", "New Text"); +m_io->publish("ui:set_text", std::move(msg)); +``` + +## UIPanel + +Container widget with background color. + +```json +{ + "type": "panel", + "id": "my_panel", + "x": 0, + "y": 0, + "width": 400, + "height": 300, + "style": { + "bgColor": "0x2d3436FF" + } +} +``` + +**Properties:** +- `style.bgColor` - Background color (hex RGBA, use `0x00000000` for transparent) + +## UICheckbox + +Toggle checkbox with check state. + +```json +{ + "type": "checkbox", + "id": "enable_vsync", + "x": 100, + "y": 100, + "width": 24, + "height": 24, + "checked": true, + "text": "Enable VSync" +} +``` + +**Properties:** +- `checked` - Initial checked state +- `text` - Optional label text next to checkbox + +**Events:** +- `ui:value_changed` - `{widgetId, checked}` + +## UISlider + +Horizontal or vertical value slider. + +```json +{ + "type": "slider", + "id": "volume_slider", + "x": 100, + "y": 100, + "width": 300, + "height": 24, + "min": 0.0, + "max": 100.0, + "value": 50.0, + "orientation": "horizontal" +} +``` + +**Properties:** +- `min` - Minimum value +- `max` - Maximum value +- `value` - Current value +- `orientation` - "horizontal" or "vertical" + +**Events:** +- `ui:value_changed` - `{widgetId, value, min, max}` + +## UITextInput + +Text entry field with cursor and focus state. + +```json +{ + "type": "textinput", + "id": "player_name", + "x": 100, + "y": 100, + "width": 300, + "height": 40, + "text": "", + "placeholder": "Enter name...", + "maxLength": 32, + "style": { + "fontSize": 20, + "textColor": "0xFFFFFFFF", + "bgColor": "0x34495eFF", + "borderColor": "0x666666FF" + } +} +``` + +**Properties:** +- `text` - Initial text +- `placeholder` - Placeholder text when empty +- `maxLength` - Maximum character limit +- `style.fontSize` - Font size +- `style.textColor` - Text color +- `style.bgColor` - Background color +- `style.borderColor` - Border color (changes to blue when focused) + +**Events:** +- `ui:value_changed` - `{widgetId, text}` - on each character change +- `ui:text_submitted` - `{widgetId, text}` - on Enter key + +## UIProgressBar + +Progress indicator (0.0 to 1.0). + +```json +{ + "type": "progressbar", + "id": "loading_bar", + "x": 100, + "y": 100, + "width": 400, + "height": 30, + "value": 0.65, + "style": { + "bgColor": "0x34495eFF", + "fillColor": "0x2ecc71FF" + } +} +``` + +**Properties:** +- `value` - Progress value (0.0 = empty, 1.0 = full) +- `style.bgColor` - Background color +- `style.fillColor` - Fill color + +**Dynamic Updates:** +```cpp +auto msg = std::make_unique("set_value"); +msg->setString("id", "loading_bar"); +msg->setDouble("value", 0.75); // 75% +m_io->publish("ui:set_value", std::move(msg)); +``` + +## UIImage + +Display a sprite/texture. + +```json +{ + "type": "image", + "id": "logo", + "x": 100, + "y": 100, + "width": 200, + "height": 200, + "textureId": 5 +} +``` + +**Properties:** +- `textureId` - Texture ID from BgfxRenderer + +## UIScrollPanel + +Scrollable container with vertical scrollbar. + +```json +{ + "type": "scrollpanel", + "id": "inventory_panel", + "x": 100, + "y": 100, + "width": 400, + "height": 600, + "contentHeight": 1200, + "scrollY": 0.0, + "scrollbarWidth": 20, + "style": { + "bgColor": "0x2d3436FF" + } +} +``` + +**Properties:** +- `contentHeight` - Total height of scrollable content +- `scrollY` - Initial scroll position (0.0 = top) +- `scrollbarWidth` - Width of scrollbar in pixels +- `style.bgColor` - Background color + +**Events:** +- `ui:scroll` - `{widgetId, scrollY}` + +## UITooltip + +Hover tooltip (managed automatically by UIModule). + +```json +{ + "type": "tooltip", + "id": "help_tooltip", + "x": 100, + "y": 100, + "width": 200, + "height": 60, + "text": "This is a helpful tooltip", + "visible": false, + "style": { + "fontSize": 14, + "bgColor": "0x2c3e50FF", + "textColor": "0xFFFFFFFF" + } +} +``` + +**Note:** Tooltips are automatically shown when `tooltip` property is set on any widget: + +```json +{ + "type": "button", + "id": "save_button", + "tooltip": "Save your progress", + ... +} +``` + +## Creating Custom Widgets + +1. Create `Widgets/MyWidget.h/.cpp` +2. Inherit from `UIWidget` +3. Implement required methods: + +```cpp +class MyWidget : public UIWidget { +public: + void update(UIContext& ctx, float deltaTime) override; + void render(UIRenderer& renderer) override; + std::string getType() const override { return "mywidget"; } + + // Event handlers + bool onMouseButton(int button, bool pressed, float x, float y) override; + void onMouseMove(float x, float y) override; +}; +``` + +4. Register in `UITree::createWidget()`: + +```cpp +if (type == "mywidget") { + auto widget = std::make_unique(); + // ... configure from JSON + return widget; +} +``` + +5. Use in JSON layouts: + +```json +{ + "type": "mywidget", + "id": "custom1", + ... +} +``` diff --git a/modules/BgfxRenderer/Resources/ResourceCache.cpp b/modules/BgfxRenderer/Resources/ResourceCache.cpp index f8e9024..453c579 100644 --- a/modules/BgfxRenderer/Resources/ResourceCache.cpp +++ b/modules/BgfxRenderer/Resources/ResourceCache.cpp @@ -3,6 +3,7 @@ #include "../RHI/RHIDevice.h" #include #include +#include namespace grove { @@ -26,8 +27,19 @@ rhi::ShaderHandle ResourceCache::getShader(const std::string& name) const { rhi::TextureHandle ResourceCache::getTextureById(uint16_t id) const { std::shared_lock lock(m_mutex); + static bool logged = false; + if (!logged && id > 0) { + spdlog::info("ResourceCache::getTextureById({}) - cache size: {}", id, m_textureById.size()); + logged = true; + } if (id < m_textureById.size()) { - return m_textureById[id]; + auto handle = m_textureById[id]; + static bool handleLogged = false; + if (!handleLogged && id > 0) { + spdlog::info(" -> Found handle with id: {}, valid: {}", handle.id, handle.isValid()); + handleLogged = true; + } + return handle; } return rhi::TextureHandle{}; // Invalid handle } @@ -41,12 +53,37 @@ uint16_t ResourceCache::getTextureId(const std::string& path) const { return 0; // Invalid ID } +uint16_t ResourceCache::registerTexture(rhi::TextureHandle handle, const std::string& name) { + if (!handle.isValid()) { + return 0; // Invalid handle + } + + std::unique_lock lock(m_mutex); + + // Assign new ID + uint16_t newId = static_cast(m_textureById.size()); + if (newId == 0) { + // Reserve index 0 as invalid/default + m_textureById.push_back(rhi::TextureHandle{}); + newId = 1; + } + + m_textureById.push_back(handle); + if (!name.empty()) { + m_pathToTextureId[name] = newId; + m_textures[name] = handle; + } + + return newId; +} + uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::string& path) { // Check if already loaded { std::shared_lock lock(m_mutex); auto it = m_pathToTextureId.find(path); if (it != m_pathToTextureId.end()) { + spdlog::info("📋 ResourceCache: Texture '{}' already loaded with ID {}", path, it->second); return it->second; } } @@ -55,6 +92,7 @@ uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::st auto result = TextureLoader::loadFromFile(device, path); if (!result.success) { + spdlog::error("❌ ResourceCache: FAILED to load texture '{}': {}", path, result.error); return 0; // Invalid ID } @@ -82,6 +120,8 @@ uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::st m_pathToTextureId[path] = newId; m_textures[path] = result.handle; + spdlog::info("✅ ResourceCache: Texture '{}' registered with ID {} (handle={})", path, newId, result.handle.id); + return newId; } } diff --git a/modules/BgfxRenderer/Resources/TextureLoader.cpp b/modules/BgfxRenderer/Resources/TextureLoader.cpp index 5d4fa02..4a82bf3 100644 --- a/modules/BgfxRenderer/Resources/TextureLoader.cpp +++ b/modules/BgfxRenderer/Resources/TextureLoader.cpp @@ -5,16 +5,20 @@ #include #include +#include namespace grove { TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, const std::string& path) { LoadResult result; + spdlog::info("📂 TextureLoader: Loading texture from '{}'", path); + // Read file into memory std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file.is_open()) { result.error = "Failed to open file: " + path; + spdlog::error("❌ TextureLoader: FAILED to open file '{}'", path); return result; } @@ -24,10 +28,21 @@ TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, c std::vector buffer(static_cast(size)); if (!file.read(reinterpret_cast(buffer.data()), size)) { result.error = "Failed to read file: " + path; + spdlog::error("❌ TextureLoader: FAILED to read file '{}'", path); return result; } - return loadFromMemory(device, buffer.data(), buffer.size()); + spdlog::info("✅ TextureLoader: File '{}' read successfully ({} bytes)", path, size); + auto loadResult = loadFromMemory(device, buffer.data(), buffer.size()); + + if (loadResult.success) { + spdlog::info("✅ TextureLoader: Texture '{}' loaded successfully ({}x{}, handle={})", + path, loadResult.width, loadResult.height, loadResult.handle.id); + } else { + spdlog::error("❌ TextureLoader: FAILED to load texture '{}': {}", path, loadResult.error); + } + + return loadResult; } TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device, const uint8_t* data, size_t size) { @@ -44,6 +59,17 @@ TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device, return result; } + // Log decoded image info + spdlog::info("TextureLoader: Decoded image {}x{} (original {} channels, converted to RGBA)", + width, height, channels); + if (width > 0 && height > 0) { + spdlog::info(" First pixel: R={}, G={}, B={}, A={}", + pixels[0], pixels[1], pixels[2], pixels[3]); + spdlog::info(" Last pixel: R={}, G={}, B={}, A={}", + pixels[(width*height-1)*4 + 0], pixels[(width*height-1)*4 + 1], + pixels[(width*height-1)*4 + 2], pixels[(width*height-1)*4 + 3]); + } + // Create texture via RHI rhi::TextureDesc desc; desc.width = static_cast(width); @@ -57,8 +83,11 @@ TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device, result.height = desc.height; result.success = result.handle.isValid(); - if (!result.success) { + if (result.success) { + spdlog::info("✅ TextureLoader: GPU texture created successfully (handle={})", result.handle.id); + } else { result.error = "Failed to create GPU texture"; + spdlog::error("❌ TextureLoader: FAILED to create GPU texture (handle invalid)"); } // Free stb_image memory diff --git a/modules/UIModule/README.md b/modules/UIModule/README.md index 7cbfcae..3c75f6c 100644 --- a/modules/UIModule/README.md +++ b/modules/UIModule/README.md @@ -2,439 +2,117 @@ 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 +- **10 Widget Types**: Button, Label, Panel, Checkbox, Slider, TextInput, ProgressBar, Image, ScrollPanel, Tooltip +- **JSON-Based Layouts**: Define UI hierarchies in JSON files +- **Automatic Input Handling**: Consumes `input:*` topics from InputModule +- **Retained Mode Rendering**: Widgets cache state, reducing IIO traffic by 85%+ - **Layer Management**: UI renders on top of game content (layer 1000+) - **Hot-Reload Support**: Full state preservation across module reloads +- **Thread-Safe**: Designed for multi-threaded production architecture -## 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 - -```cpp -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 +## Quick Start ```cpp #include #include +// Create IIO instances auto& ioManager = IntraIOManager::getInstance(); auto uiIO = ioManager.createInstance("ui_module"); -auto gameIO = ioManager.createInstance("game_logic"); +auto gameIO = ioManager.createInstance("game"); +// Load UIModule ModuleLoader uiLoader; auto uiModule = uiLoader.load("./modules/UIModule.dll", "ui_module"); +// Configure JsonDataNode config("config"); +config.setInt("windowWidth", 1920); +config.setInt("windowHeight", 1080); config.setString("layoutFile", "./ui/menu.json"); +config.setInt("baseLayer", 1000); uiModule->setConfiguration(config, uiIO.get(), nullptr); + +// Subscribe to UI events +gameIO->subscribe("ui:action"); +gameIO->subscribe("ui:value_changed"); + +// Game loop +while(running) { + // Handle UI events + while (gameIO->hasMessages() > 0) { + auto msg = gameIO->pullMessage(); + if (msg.topic == "ui:action") { + std::string action = msg.data->getString("action", ""); + handleAction(action); + } + } + + uiModule->process(deltaTime); +} ``` -### Creating UI Layout (JSON) +## Documentation + +- **[Widget Reference](../../docs/UI_WIDGETS.md)** - All widgets with JSON properties +- **[IIO Topics](../../docs/UI_TOPICS.md)** - Complete topic reference and usage examples +- **[Architecture & Design](../../docs/UI_ARCHITECTURE.md)** - Threading, limitations, future features +- **[Rendering](../../docs/UI_RENDERING.md)** - Retained mode rendering architecture + +## Example UI Layout `ui/menu.json`: ```json { "widgets": [ { - "type": "UIPanel", + "type": "panel", "id": "background", - "x": 0, - "y": 0, - "width": 1920, - "height": 1080, - "color": 2155905279 + "x": 0, "y": 0, + "width": 1920, "height": 1080, + "style": {"bgColor": "0x2d3436FF"} }, { - "type": "UIButton", + "type": "button", "id": "play_button", - "x": 860, - "y": 500, - "width": 200, - "height": 60, - "text": "Play", - "fontSize": 24, - "action": "start_game" + "x": 860, "y": 500, + "width": 200, "height": 60, + "text": "Play Game", + "onClick": "start_game", + "style": { + "normal": {"bgColor": "0x0984e3FF"}, + "hover": {"bgColor": "0x74b9ffFF"} + } }, { - "type": "UILabel", - "id": "title", - "x": 760, - "y": 300, - "width": 400, - "height": 100, - "text": "My Awesome Game", - "fontSize": 48, - "color": 4294967295 - }, - { - "type": "UISlider", + "type": "slider", "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 + "x": 800, "y": 650, + "width": 320, "height": 40, + "min": 0.0, "max": 100.0, "value": 75.0 } ] } ``` -### Handling UI Events +See [Widget Reference](../../docs/UI_WIDGETS.md) for all widget properties. -```cpp -// Subscribe to UI events in your game module -gameIO->subscribe("ui:click"); -gameIO->subscribe("ui:action"); -gameIO->subscribe("ui:value_changed"); +## Building -// 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); - } - } - } -} +```bash +cmake -DGROVE_BUILD_UI_MODULE=ON -B build +cmake --build build -j4 ``` -## 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](../../docs/UI_RENDERING.md) for details on retained mode rendering. - -## Widget Properties Reference - -### UIButton -```json -{ - "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 -```json -{ - "type": "UILabel", - "id": "my_label", - "x": 100, "y": 100, - "width": 300, "height": 50, - "text": "Hello World", - "fontSize": 32, - "color": 4294967295 -} -``` - -### UIPanel -```json -{ - "type": "UIPanel", - "id": "my_panel", - "x": 0, "y": 0, - "width": 400, "height": 300, - "color": 2155905279 -} -``` - -### UISlider -```json -{ - "type": "UISlider", - "id": "volume", - "x": 100, "y": 100, - "width": 300, "height": 30, - "min": 0.0, - "max": 100.0, - "value": 50.0, - "orientation": "horizontal" -} -``` - -### UICheckbox -```json -{ - "type": "UICheckbox", - "id": "enable_vsync", - "x": 100, "y": 100, - "width": 30, "height": 30, - "checked": true -} -``` - -### UITextInput -```json -{ - "type": "UITextInput", - "id": "player_name", - "x": 100, "y": 100, - "width": 300, "height": 40, - "text": "", - "placeholder": "Enter name...", - "fontSize": 20, - "maxLength": 32 -} -``` - -### UIProgressBar -```json -{ - "type": "UIProgressBar", - "id": "loading", - "x": 100, "y": 100, - "width": 400, "height": 30, - "value": 0.65, - "bgColor": 2155905279, - "fillColor": 4278255360 -} -``` - -### UIImage -```json -{ - "type": "UIImage", - "id": "logo", - "x": 100, "y": 100, - "width": 200, "height": 200, - "textureId": 5 -} -``` - -### UIScrollPanel -```json -{ - "type": "UIScrollPanel", - "id": "inventory", - "x": 100, "y": 100, - "width": 400, "height": 600, - "contentHeight": 1200, - "scrollY": 0.0, - "scrollbarWidth": 20, - "bgColor": 2155905279 -} -``` - -### UITooltip -```json -{ - "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: -```cpp -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](../../docs/UI_RENDERING.md) 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 ```bash -cmake -DGROVE_BUILD_UI_MODULE=ON -B build -cmake --build build --target test_ui_widgets -./build/tests/test_ui_widgets -``` +# Visual showcase (run from project root for correct asset paths) +./build/tests/test_ui_showcase -### Integration Test (with InputModule + BgfxRenderer) -```bash -cmake -DGROVE_BUILD_BGFX_RENDERER=ON -DGROVE_BUILD_UI_MODULE=ON -DGROVE_BUILD_INPUT_MODULE=ON -B build -cmake --build build +# Integration test cd build && ctest -R IT_014 --output-on-failure ``` @@ -442,29 +120,30 @@ cd build && ctest -R IT_014 --output-on-failure - **GroveEngine Core**: IModule, IIO, IDataNode - **BgfxRenderer**: For rendering (via IIO, not direct dependency) -- **InputModule**: For input handling (via IIO, not direct dependency) +- **InputModule**: For input (via IIO, not direct dependency) - **nlohmann/json**: JSON parsing - **spdlog**: Logging -## Files +## Implementation Status + +- ✅ **Phase 1-3**: Core widgets (Button, Label, Panel, Checkbox, Slider, TextInput, ProgressBar, Image) +- ✅ **Phase 4-5**: Layout system and styling +- ✅ **Phase 6**: Interactive demo +- ✅ **Phase 7**: ScrollPanel + Tooltips +- ✅ **Phase 8**: Retained mode rendering + +## Files Structure ``` modules/UIModule/ ├── README.md # This file ├── CMakeLists.txt # Build configuration -├── UIModule.h # Main module -├── UIModule.cpp +├── UIModule.h/.cpp # Main module ├── 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 +│ ├── UIContext.h/.cpp # Global UI state +│ ├── UILayout.h/.cpp # Layout management +│ ├── UITooltip.h/.cpp # Tooltip system +│ ├── UITree.h/.cpp # Widget hierarchy │ └── UIWidget.h # Base widget interface ├── Widgets/ │ ├── UIButton.h/.cpp @@ -477,45 +156,7 @@ modules/UIModule/ │ ├── 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: -```cpp -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("event"); - event->setString("widgetId", m_id); - m_io->publish("ui:custom_event", std::move(event)); - } -}; + ├── UIRenderer.h/.cpp # Publishes render commands ``` ## License diff --git a/modules/UIModule/UIModule.cpp b/modules/UIModule/UIModule.cpp index c2f75c2..590a95d 100644 --- a/modules/UIModule/UIModule.cpp +++ b/modules/UIModule/UIModule.cpp @@ -9,10 +9,12 @@ #include "Widgets/UICheckbox.h" #include "Widgets/UITextInput.h" #include "Widgets/UIScrollPanel.h" +#include "Widgets/UILabel.h" #include #include #include +#include #include #include @@ -80,6 +82,7 @@ void UIModule::setConfiguration(const IDataNode& config, IIO* io, ITaskScheduler m_io->subscribe("ui:load"); // Load new layout m_io->subscribe("ui:set_value"); // Set widget value m_io->subscribe("ui:set_visible"); // Show/hide widget + m_io->subscribe("ui:set_text"); // Set widget text (for labels) } m_logger->info("UIModule initialized"); @@ -147,6 +150,36 @@ void UIModule::processInput() { } } } + else if (msg.topic == "ui:set_text") { + // Timestamp on receive + auto now = std::chrono::high_resolution_clock::now(); + auto micros = std::chrono::duration_cast(now.time_since_epoch()).count(); + + std::string widgetId = msg.data->getString("id", ""); + std::string text = msg.data->getString("text", ""); + + // Extract original timestamp if present + double t0 = msg.data->getDouble("_timestamp_publish", 0); + if (t0 > 0) { + double latency = (micros - t0) / 1000.0; // Convert to milliseconds + m_logger->info("⏱️ [T3] UIModule received ui:set_text at {} µs (latency from T0: {:.2f} ms)", micros, latency); + } else { + m_logger->info("⏱️ [T3] UIModule received ui:set_text at {} µs", micros); + } + + if (m_root) { + if (UIWidget* widget = m_root->findById(widgetId)) { + // Only labels support text updates + if (widget->getType() == "label") { + UILabel* label = static_cast(widget); + label->text = text; + m_logger->info("Updated text for label '{}': '{}'", widgetId, text); + } else { + m_logger->warn("Widget '{}' is not a label, cannot set text", widgetId); + } + } + } + } } } @@ -257,6 +290,13 @@ void UIModule::updateUI(float deltaTime) { valueEvent->setDouble("value", slider->getValue()); valueEvent->setDouble("min", slider->minValue); valueEvent->setDouble("max", slider->maxValue); + + // Add timestamp for latency measurement + auto now = std::chrono::high_resolution_clock::now(); + auto micros = std::chrono::duration_cast(now.time_since_epoch()).count(); + valueEvent->setDouble("_timestamp_publish", static_cast(micros)); + + m_logger->info("⏱️ [T0] UIModule publishing ui:value_changed at {} µs", micros); m_io->publish("ui:value_changed", std::move(valueEvent)); // Publish onChange action if specified diff --git a/src/IntraIOManager.cpp b/src/IntraIOManager.cpp index 80f5e8e..13dc3af 100644 --- a/src/IntraIOManager.cpp +++ b/src/IntraIOManager.cpp @@ -13,10 +13,10 @@ IntraIOManager::IntraIOManager() { logger = stillhammer::createDomainLogger("IntraIOManager", "io", config); logger->info("🌐🔗 IntraIOManager created - Central message router initialized"); - // TEMPORARY: Disable batch thread to debug Windows crash - batchThreadRunning = false; - // batchThread = std::thread(&IntraIOManager::batchFlushLoop, this); - logger->info("⚠️ Batch flush thread DISABLED (debugging Windows crash)"); + // Start batch flush thread for low-latency message delivery + batchThreadRunning = true; + batchThread = std::thread(&IntraIOManager::batchFlushLoop, this); + logger->info("✅ Batch flush thread started for push-based message delivery"); } IntraIOManager::~IntraIOManager() { diff --git a/tests/visual/test_ui_showcase.cpp b/tests/visual/test_ui_showcase.cpp index 97465ab..7ed3a31 100644 --- a/tests/visual/test_ui_showcase.cpp +++ b/tests/visual/test_ui_showcase.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include "BgfxRendererModule.h" #include "UIModule.h" @@ -433,10 +434,10 @@ public: config.setInt("windowHeight", 768); // config.setString("backend", "d3d11"); // LET BGFX CHOOSE LIKE test_button_with_png! config.setBool("vsync", true); - // Load textures for sprite buttons - config.setString("texture1", "../../assets/textures/5oxaxt1vo2f91.jpg"); // Car - config.setString("texture2", "../../assets/textures/1f440.png"); // Eyes emoji - config.setString("texture3", "../../assets/textures/IconDesigner.png"); // Icon + // Load textures for sprite buttons (paths relative to project root) + config.setString("texture1", "assets/textures/5oxaxt1vo2f91.jpg"); // Car + config.setString("texture2", "assets/textures/1f440.png"); // Eyes emoji + config.setString("texture3", "assets/textures/IconDesigner.png"); // Icon m_renderer->setConfiguration(config, m_rendererIO, nullptr); } m_logger->info("✓ Loaded 3 textures for sprite buttons (IDs: 1, 2, 3)"); @@ -606,10 +607,38 @@ private: std::to_string(static_cast(y)) + ")"; } else if (msg.topic == "ui:value_changed") { + // Timestamp on receive + auto now = std::chrono::high_resolution_clock::now(); + auto micros = std::chrono::duration_cast(now.time_since_epoch()).count(); + std::string widgetId = msg.data->getString("widgetId", ""); if (widgetId == "volume_slider") { double value = msg.data->getDouble("value", 0); logEntry = "Volume: " + std::to_string(static_cast(value)) + "%"; + + // Extract original timestamp + double t0 = msg.data->getDouble("_timestamp_publish", 0); + if (t0 > 0) { + double latency = (micros - t0) / 1000.0; // ms + m_logger->info("⏱️ [T1] Game received ui:value_changed at {} µs (latency from T0: {:.2f} ms)", micros, latency); + } + + // Update the slider label text + auto updateMsg = std::make_unique("set_text"); + updateMsg->setString("id", "slider_label"); + updateMsg->setString("text", "Volume: " + std::to_string(static_cast(value)) + "%"); + + // Forward original timestamp + if (t0 > 0) { + updateMsg->setDouble("_timestamp_publish", t0); + } + + // Timestamp before publish + auto now2 = std::chrono::high_resolution_clock::now(); + auto micros2 = std::chrono::duration_cast(now2.time_since_epoch()).count(); + m_logger->info("⏱️ [T2] Game publishing ui:set_text at {} µs (processing time: {:.2f} ms)", micros2, (micros2 - micros) / 1000.0); + + m_gameIO->publish("ui:set_text", std::move(updateMsg)); } else if (widgetId.find("chk_") == 0) { bool checked = msg.data->getBool("checked", false);