Compare commits

..

3 Commits

Author SHA1 Message Date
0441a9d648 feat: UIModule - Dynamic text updates, documentation restructure, and IIO improvements
UIModule Enhancements:
- Add ui:set_text topic handler to update widget text dynamically (UILabel support)
- Add example: slider value updates linked label via game module coordination
- Add timestamp logging for IIO latency measurement (T0-T3 tracking)

Documentation Restructure:
- Split UIModule README.md (600+ lines) into focused docs:
  * docs/UI_WIDGETS.md - Widget properties and JSON configuration
  * docs/UI_TOPICS.md - IIO topics reference and usage patterns
  * docs/UI_ARCHITECTURE.md - Threading model, limitations, design principles
- Update CLAUDE.md with clear references to UIModule docs
- Add warning: "READ BEFORE WORKING ON UI" for essential docs

Asset Path Fixes:
- Change test_ui_showcase texture paths from ../../assets to assets/
- Tests now run from project root (./build/tests/test_ui_showcase)
- Add texture loading success/failure logs to TextureLoader and ResourceCache

IIO Performance:
- Re-enable batch flush thread in IntraIOManager (was disabled for debugging)
- Document message latency: ~16ms in single-threaded tests, <1ms with threading
- Clarify intentional architecture: no direct data binding, all via IIO topics

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-14 22:34:36 +07:00
fd508e4a68 fix: UITextInput focus and keyboard input - hitTest + dispatch handlers
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>
2026-01-14 14:36:49 +07:00
63751d6f91 fix: Multi-texture sprite rendering - setState per batch + transient buffers
Fixed two critical bugs preventing multiple textured sprites from rendering correctly:

1. **setState consumed by submit**: Render state was set once at the beginning,
   but bgfx consumes state at each submit(). Batches 2+ had no state → invisible.

   FIX: Call setState() before EACH batch, not once globally.

2. **Buffer overwrite race condition**: updateBuffer() is immediate but submit()
   is deferred. When batch 2 called updateBuffer(), it overwrote batch 1's data
   BEFORE bgfx executed the draw calls. All batches used the last batch's data
   → all sprites rendered at the same position (superimposed).

   FIX: Use transient buffers (one per batch, frame-local) instead of reusing
   the same dynamic buffer. Each batch gets its own isolated memory.

Changes:
- SpritePass: setState before each batch + transient buffer allocation per batch
- UIRenderer: Retained mode rendering (render:sprite:add/update/remove)
- test_ui_showcase: Added 3 textured buttons demo section
- test_3buttons_minimal: Minimal test case for multi-texture debugging

Tested: 3 textured buttons now render at correct positions with correct textures.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-14 14:05:56 +07:00
19 changed files with 1783 additions and 480 deletions

View File

@ -12,7 +12,12 @@ GroveEngine is a C++17 hot-reload module system for game engines. It supports dy
**Module-specific:** **Module-specific:**
- **[BgfxRenderer README](modules/BgfxRenderer/README.md)** - 2D rendering module (sprites, text, tilemap, particles) - **[BgfxRenderer README](modules/BgfxRenderer/README.md)** - 2D rendering module (sprites, text, tilemap, particles)
- **[InputModule README](modules/InputModule/README.md)** - Input handling (mouse, keyboard, gamepad) - **[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 - **[UI Rendering](docs/UI_RENDERING.md)** - Retained mode rendering architecture
## Available Modules ## Available Modules
@ -37,6 +42,10 @@ cmake --build build -j4
# Run all tests (23+ tests) # Run all tests (23+ tests)
cd build && ctest --output-on-failure 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 # Build with ThreadSanitizer
cmake -DGROVE_ENABLE_TSAN=ON -B build-tsan cmake -DGROVE_ENABLE_TSAN=ON -B build-tsan
cmake --build build-tsan -j4 cmake --build build-tsan -j4
@ -100,7 +109,8 @@ std::lock_guard lock2(mutex2); // DEADLOCK RISK
### UIModule ### UIModule
- **UIRenderer**: Publishes render commands to BgfxRenderer via IIO (layer 1000+) - **UIRenderer**: Publishes render commands to BgfxRenderer via IIO (layer 1000+)
- **Widgets**: UIButton, UIPanel, UILabel, UICheckbox, UISlider, UITextInput, UIProgressBar, UIImage, UIScrollPanel, UITooltip - **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 ### InputModule
- **Backends**: SDLBackend (mouse, keyboard, gamepad Phase 2) - **Backends**: SDLBackend (mouse, keyboard, gamepad Phase 2)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

367
docs/UI_ARCHITECTURE.md Normal file
View File

@ -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<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));
}
```
### 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.

125
docs/UI_TOPICS.md Normal file
View File

@ -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<JsonDataNode>("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<JsonDataNode>("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<JsonDataNode>("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<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));
}
```

376
docs/UI_WIDGETS.md Normal file
View File

@ -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<JsonDataNode>("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<JsonDataNode>("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<MyWidget>();
// ... configure from JSON
return widget;
}
```
5. Use in JSON layouts:
```json
{
"type": "mywidget",
"id": "custom1",
...
}
```

View File

@ -59,7 +59,7 @@ void SpritePass::setup(rhi::IRHIDevice& device) {
// Create texture sampler uniform (must match shader: s_texColor) // Create texture sampler uniform (must match shader: s_texColor)
m_textureSampler = device.createUniform("s_texColor", 1); m_textureSampler = device.createUniform("s_texColor", 1);
// Create default white 4x4 texture (used when no texture is bound) // Create default white 4x4 texture (restored to white)
// Some drivers have issues with 1x1 textures // Some drivers have issues with 1x1 textures
uint32_t whitePixels[16]; uint32_t whitePixels[16];
for (int i = 0; i < 16; ++i) whitePixels[i] = 0xFFFFFFFF; // RGBA white for (int i = 0; i < 16; ++i) whitePixels[i] = 0xFFFFFFFF; // RGBA white
@ -98,15 +98,14 @@ void SpritePass::flushBatch(rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd,
void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) { void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) {
if (frame.spriteCount == 0) return; if (frame.spriteCount == 0) return;
// Set render state ONCE (like TextPass does) // Prepare render state (will be set before each batch)
rhi::RenderState state; rhi::RenderState state;
state.blend = rhi::BlendMode::Alpha; state.blend = rhi::BlendMode::Alpha;
state.cull = rhi::CullMode::None; state.cull = rhi::CullMode::None;
state.depthTest = false; state.depthTest = false;
state.depthWrite = false; state.depthWrite = false;
cmd.setState(state);
// Sort sprites by layer for correct draw order // Sort sprites by layer first (for correct draw order), then by texture (for batching)
m_sortedIndices.clear(); m_sortedIndices.clear();
m_sortedIndices.reserve(frame.spriteCount); m_sortedIndices.reserve(frame.spriteCount);
for (size_t i = 0; i < frame.spriteCount; ++i) { for (size_t i = 0; i < frame.spriteCount; ++i) {
@ -114,27 +113,133 @@ void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi:
} }
std::sort(m_sortedIndices.begin(), m_sortedIndices.end(), std::sort(m_sortedIndices.begin(), m_sortedIndices.end(),
[&frame](uint32_t a, uint32_t b) { [&frame](uint32_t a, uint32_t b) {
// Sort by layer first, then by textureId for batching
if (frame.sprites[a].layer != frame.sprites[b].layer) {
return frame.sprites[a].layer < frame.sprites[b].layer; return frame.sprites[a].layer < frame.sprites[b].layer;
}
return frame.sprites[a].textureId < frame.sprites[b].textureId;
}); });
// Copy sorted sprites to temporary buffer (like TextPass does with glyphs) // Batch sprites by texture
std::vector<SpriteInstance> sortedSprites; std::vector<SpriteInstance> batchSprites;
sortedSprites.reserve(frame.spriteCount); batchSprites.reserve(frame.spriteCount);
for (uint32_t idx : m_sortedIndices) {
sortedSprites.push_back(frame.sprites[idx]); uint16_t currentTextureId = 0;
bool firstBatch = true;
static int spriteLogCount = 0;
for (size_t i = 0; i < m_sortedIndices.size(); ++i) {
uint32_t idx = m_sortedIndices[i];
const SpriteInstance& sprite = frame.sprites[idx];
uint16_t spriteTexId = static_cast<uint16_t>(sprite.textureId);
// Log first few textured sprites
if (spriteLogCount < 10 && spriteTexId > 0) {
spdlog::info("🎨 [SpritePass] Processing sprite #{}: textureId={}, pos=({:.1f},{:.1f}), scale={}x{}, layer={}",
spriteLogCount++, spriteTexId, sprite.x, sprite.y, sprite.scaleX, sprite.scaleY, (int)sprite.layer);
} }
// Update dynamic instance buffer with ALL sprites (like TextPass) // Start new batch if texture changes
device.updateBuffer(m_instanceBuffer, sortedSprites.data(), if (!firstBatch && spriteTexId != currentTextureId) {
static_cast<uint32_t>(sortedSprites.size() * sizeof(SpriteInstance))); // Flush previous batch using TRANSIENT BUFFER (one per batch)
uint32_t batchSize = static_cast<uint32_t>(batchSprites.size());
rhi::TransientInstanceBuffer transientBuffer = device.allocTransientInstanceBuffer(batchSize);
// CRITICAL: Set render state before EACH batch (consumed by submit)
cmd.setState(state);
// Get texture handle from ResourceCache
rhi::TextureHandle texHandle = m_defaultTexture;
if (m_resourceCache && currentTextureId > 0) {
auto cachedTex = m_resourceCache->getTextureById(currentTextureId);
if (cachedTex.isValid()) {
texHandle = cachedTex;
static int batchNum = 0;
spdlog::info("[Batch #{}] SpritePass flushing batch: textureId={}, handle={}, size={}",
batchNum++, currentTextureId, texHandle.id, batchSprites.size());
}
}
if (transientBuffer.isValid()) {
// Copy sprite data to transient buffer (frame-local, won't be overwritten)
std::memcpy(transientBuffer.data, batchSprites.data(), batchSize * sizeof(SpriteInstance));
// Set buffers and draw ALL sprites in ONE call (like TextPass)
cmd.setVertexBuffer(m_quadVB); cmd.setVertexBuffer(m_quadVB);
cmd.setIndexBuffer(m_quadIB); cmd.setIndexBuffer(m_quadIB);
cmd.setInstanceBuffer(m_instanceBuffer, 0, static_cast<uint32_t>(sortedSprites.size())); cmd.setTransientInstanceBuffer(transientBuffer, 0, batchSize);
cmd.setTexture(0, m_defaultTexture, m_textureSampler); cmd.setTexture(0, texHandle, m_textureSampler);
cmd.drawInstanced(6, static_cast<uint32_t>(sortedSprites.size())); cmd.drawInstanced(6, batchSize);
cmd.submit(0, m_shader, 0);
} else {
// Fallback to dynamic buffer (single batch limitation - data will be overwritten!)
device.updateBuffer(m_instanceBuffer, batchSprites.data(), batchSize * sizeof(SpriteInstance));
cmd.setVertexBuffer(m_quadVB);
cmd.setIndexBuffer(m_quadIB);
cmd.setInstanceBuffer(m_instanceBuffer, 0, batchSize);
cmd.setTexture(0, texHandle, m_textureSampler);
cmd.drawInstanced(6, batchSize);
cmd.submit(0, m_shader, 0); cmd.submit(0, m_shader, 0);
} }
// Start new batch
batchSprites.clear();
}
batchSprites.push_back(sprite);
currentTextureId = spriteTexId;
firstBatch = false;
}
// Flush final batch
if (!batchSprites.empty()) {
// Use TRANSIENT BUFFER for final batch too
uint32_t batchSize = static_cast<uint32_t>(batchSprites.size());
rhi::TransientInstanceBuffer transientBuffer = device.allocTransientInstanceBuffer(batchSize);
// CRITICAL: Set render state before EACH batch (consumed by submit)
cmd.setState(state);
// Get texture handle from ResourceCache
rhi::TextureHandle texHandle = m_defaultTexture;
if (m_resourceCache && currentTextureId > 0) {
auto cachedTex = m_resourceCache->getTextureById(currentTextureId);
if (cachedTex.isValid()) {
texHandle = cachedTex;
static int finalBatchNum = 0;
spdlog::info("[Final Batch #{}] SpritePass flushing final batch: textureId={}, handle={}, size={}",
finalBatchNum++, currentTextureId, texHandle.id, batchSprites.size());
} else {
static bool warnLogged = false;
if (!warnLogged) {
spdlog::warn("SpritePass: Texture ID {} not found in cache, using default", currentTextureId);
warnLogged = true;
}
}
}
if (transientBuffer.isValid()) {
// Copy sprite data to transient buffer (frame-local, won't be overwritten)
std::memcpy(transientBuffer.data, batchSprites.data(), batchSize * sizeof(SpriteInstance));
cmd.setVertexBuffer(m_quadVB);
cmd.setIndexBuffer(m_quadIB);
cmd.setTransientInstanceBuffer(transientBuffer, 0, batchSize);
cmd.setTexture(0, texHandle, m_textureSampler);
cmd.drawInstanced(6, batchSize);
cmd.submit(0, m_shader, 0);
} else {
// Fallback to dynamic buffer (single batch limitation - data will be overwritten!)
device.updateBuffer(m_instanceBuffer, batchSprites.data(), batchSize * sizeof(SpriteInstance));
cmd.setVertexBuffer(m_quadVB);
cmd.setIndexBuffer(m_quadIB);
cmd.setInstanceBuffer(m_instanceBuffer, 0, batchSize);
cmd.setTexture(0, texHandle, m_textureSampler);
cmd.drawInstanced(6, batchSize);
cmd.submit(0, m_shader, 0);
}
}
}
} // namespace grove } // namespace grove

View File

@ -3,6 +3,7 @@
#include "../RHI/RHIDevice.h" #include "../RHI/RHIDevice.h"
#include <mutex> #include <mutex>
#include <shared_mutex> #include <shared_mutex>
#include <spdlog/spdlog.h>
namespace grove { namespace grove {
@ -26,8 +27,19 @@ rhi::ShaderHandle ResourceCache::getShader(const std::string& name) const {
rhi::TextureHandle ResourceCache::getTextureById(uint16_t id) const { rhi::TextureHandle ResourceCache::getTextureById(uint16_t id) const {
std::shared_lock lock(m_mutex); 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()) { 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 return rhi::TextureHandle{}; // Invalid handle
} }
@ -41,12 +53,37 @@ uint16_t ResourceCache::getTextureId(const std::string& path) const {
return 0; // Invalid ID 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<uint16_t>(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) { uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::string& path) {
// Check if already loaded // Check if already loaded
{ {
std::shared_lock lock(m_mutex); std::shared_lock lock(m_mutex);
auto it = m_pathToTextureId.find(path); auto it = m_pathToTextureId.find(path);
if (it != m_pathToTextureId.end()) { if (it != m_pathToTextureId.end()) {
spdlog::info("📋 ResourceCache: Texture '{}' already loaded with ID {}", path, it->second);
return 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); auto result = TextureLoader::loadFromFile(device, path);
if (!result.success) { if (!result.success) {
spdlog::error("❌ ResourceCache: FAILED to load texture '{}': {}", path, result.error);
return 0; // Invalid ID return 0; // Invalid ID
} }
@ -82,6 +120,8 @@ uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::st
m_pathToTextureId[path] = newId; m_pathToTextureId[path] = newId;
m_textures[path] = result.handle; m_textures[path] = result.handle;
spdlog::info("✅ ResourceCache: Texture '{}' registered with ID {} (handle={})", path, newId, result.handle.id);
return newId; return newId;
} }
} }

View File

@ -5,16 +5,20 @@
#include <stb/stb_image.h> #include <stb/stb_image.h>
#include <fstream> #include <fstream>
#include <spdlog/spdlog.h>
namespace grove { namespace grove {
TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, const std::string& path) { TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, const std::string& path) {
LoadResult result; LoadResult result;
spdlog::info("📂 TextureLoader: Loading texture from '{}'", path);
// Read file into memory // Read file into memory
std::ifstream file(path, std::ios::binary | std::ios::ate); std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file.is_open()) { if (!file.is_open()) {
result.error = "Failed to open file: " + path; result.error = "Failed to open file: " + path;
spdlog::error("❌ TextureLoader: FAILED to open file '{}'", path);
return result; return result;
} }
@ -24,10 +28,21 @@ TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, c
std::vector<uint8_t> buffer(static_cast<size_t>(size)); std::vector<uint8_t> buffer(static_cast<size_t>(size));
if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) { if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) {
result.error = "Failed to read file: " + path; result.error = "Failed to read file: " + path;
spdlog::error("❌ TextureLoader: FAILED to read file '{}'", path);
return result; 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) { 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; 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 // Create texture via RHI
rhi::TextureDesc desc; rhi::TextureDesc desc;
desc.width = static_cast<uint16_t>(width); desc.width = static_cast<uint16_t>(width);
@ -57,8 +83,11 @@ TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device,
result.height = desc.height; result.height = desc.height;
result.success = result.handle.isValid(); 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"; result.error = "Failed to create GPU texture";
spdlog::error("❌ TextureLoader: FAILED to create GPU texture (handle invalid)");
} }
// Free stb_image memory // Free stb_image memory

View File

@ -28,9 +28,11 @@ void SceneCollector::collect(IIO* io, float deltaTime) {
// Route message based on topic // Route message based on topic
// Retained mode (new) - sprites // Retained mode (new) - sprites
if (msg.topic == "render:sprite:add") { if (msg.topic == "render:sprite:add") {
spdlog::info("✅ RETAINED MODE: render:sprite:add received");
parseSpriteAdd(*msg.data); parseSpriteAdd(*msg.data);
} }
else if (msg.topic == "render:sprite:update") { else if (msg.topic == "render:sprite:update") {
spdlog::info("✅ RETAINED MODE: render:sprite:update received");
parseSpriteUpdate(*msg.data); parseSpriteUpdate(*msg.data);
} }
else if (msg.topic == "render:sprite:remove") { else if (msg.topic == "render:sprite:remove") {
@ -48,6 +50,7 @@ void SceneCollector::collect(IIO* io, float deltaTime) {
} }
// Ephemeral mode (legacy) // Ephemeral mode (legacy)
else if (msg.topic == "render:sprite") { else if (msg.topic == "render:sprite") {
spdlog::info("⚠️ EPHEMERAL MODE: render:sprite received (should not happen in retained mode!)");
parseSprite(*msg.data); parseSprite(*msg.data);
} }
else if (msg.topic == "render:sprite:batch") { else if (msg.topic == "render:sprite:batch") {
@ -487,6 +490,9 @@ void SceneCollector::parseSpriteAdd(const IDataNode& data) {
sprite.a = static_cast<float>(color & 0xFF) / 255.0f; sprite.a = static_cast<float>(color & 0xFF) / 255.0f;
m_retainedSprites[renderId] = sprite; m_retainedSprites[renderId] = sprite;
spdlog::info("📥 [SceneCollector] Stored SPRITE renderId={}, pos=({:.1f},{:.1f}), scale={}x{}, textureId={}, layer={}, color=({:.2f},{:.2f},{:.2f},{:.2f})",
renderId, sprite.x, sprite.y, sprite.scaleX, sprite.scaleY, (int)sprite.textureId, (int)sprite.layer,
sprite.r, sprite.g, sprite.b, sprite.a);
} }
void SceneCollector::parseSpriteUpdate(const IDataNode& data) { void SceneCollector::parseSpriteUpdate(const IDataNode& data) {

View File

@ -3,6 +3,7 @@
#include "../Widgets/UIButton.h" #include "../Widgets/UIButton.h"
#include "../Widgets/UISlider.h" #include "../Widgets/UISlider.h"
#include "../Widgets/UICheckbox.h" #include "../Widgets/UICheckbox.h"
#include "../Widgets/UITextInput.h"
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
namespace grove { namespace grove {
@ -52,6 +53,12 @@ UIWidget* hitTest(UIWidget* widget, float x, float y) {
return widget; return widget;
} }
} }
else if (type == "textinput") {
UITextInput* textInput = static_cast<UITextInput*>(widget);
if (textInput->containsPoint(x, y)) {
return widget;
}
}
return nullptr; return nullptr;
} }
@ -135,6 +142,14 @@ UIWidget* dispatchMouseButton(UIWidget* widget, UIContext& ctx, int button, bool
return target; // Return for value_changed publishing return target; // Return for value_changed publishing
} }
} }
else if (type == "textinput") {
UITextInput* textInput = static_cast<UITextInput*>(target);
handled = textInput->onMouseButton(button, pressed, ctx.mouseX, ctx.mouseY);
if (handled) {
return target; // Return for focus handling in UIModule
}
}
return handled ? target : nullptr; return handled ? target : nullptr;
} }

View File

@ -2,439 +2,117 @@
Complete UI widget system for GroveEngine with layout, scrolling, tooltips, and automatic input handling. 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 ## Features
- **10 Widget Types**: Buttons, Labels, Panels, Checkboxes, Sliders, Text Inputs, Progress Bars, Images, Scroll Panels, Tooltips - **10 Widget Types**: Button, Label, Panel, Checkbox, Slider, TextInput, ProgressBar, Image, ScrollPanel, Tooltip
- **Flexible Layout**: JSON-based UI definition with hierarchical widget trees - **JSON-Based Layouts**: Define UI hierarchies in JSON files
- **Automatic Input**: Consumes `input:*` topics from InputModule automatically - **Automatic Input Handling**: Consumes `input:*` topics from InputModule
- **Retained Mode Rendering**: Widgets cache render state and only publish IIO messages when visual properties change, reducing message traffic for static UIs - **Retained Mode Rendering**: Widgets cache state, reducing IIO traffic by 85%+
- **Layer Management**: UI renders on top of game content (layer 1000+) - **Layer Management**: UI renders on top of game content (layer 1000+)
- **Hot-Reload Support**: Full state preservation across module reloads - **Hot-Reload Support**: Full state preservation across module reloads
- **Thread-Safe**: Designed for multi-threaded production architecture
## Architecture ## Quick Start
```
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
```cpp ```cpp
#include <grove/ModuleLoader.h> #include <grove/ModuleLoader.h>
#include <grove/IntraIOManager.h> #include <grove/IntraIOManager.h>
// Create IIO instances
auto& ioManager = IntraIOManager::getInstance(); auto& ioManager = IntraIOManager::getInstance();
auto uiIO = ioManager.createInstance("ui_module"); auto uiIO = ioManager.createInstance("ui_module");
auto gameIO = ioManager.createInstance("game_logic"); auto gameIO = ioManager.createInstance("game");
// Load UIModule
ModuleLoader uiLoader; ModuleLoader uiLoader;
auto uiModule = uiLoader.load("./modules/UIModule.dll", "ui_module"); auto uiModule = uiLoader.load("./modules/UIModule.dll", "ui_module");
// Configure
JsonDataNode config("config"); JsonDataNode config("config");
config.setInt("windowWidth", 1920);
config.setInt("windowHeight", 1080);
config.setString("layoutFile", "./ui/menu.json"); config.setString("layoutFile", "./ui/menu.json");
config.setInt("baseLayer", 1000);
uiModule->setConfiguration(config, uiIO.get(), nullptr); 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`: `ui/menu.json`:
```json ```json
{ {
"widgets": [ "widgets": [
{ {
"type": "UIPanel", "type": "panel",
"id": "background", "id": "background",
"x": 0, "x": 0, "y": 0,
"y": 0, "width": 1920, "height": 1080,
"width": 1920, "style": {"bgColor": "0x2d3436FF"}
"height": 1080,
"color": 2155905279
}, },
{ {
"type": "UIButton", "type": "button",
"id": "play_button", "id": "play_button",
"x": 860, "x": 860, "y": 500,
"y": 500, "width": 200, "height": 60,
"width": 200, "text": "Play Game",
"height": 60, "onClick": "start_game",
"text": "Play", "style": {
"fontSize": 24, "normal": {"bgColor": "0x0984e3FF"},
"action": "start_game" "hover": {"bgColor": "0x74b9ffFF"}
}
}, },
{ {
"type": "UILabel", "type": "slider",
"id": "title",
"x": 760,
"y": 300,
"width": 400,
"height": 100,
"text": "My Awesome Game",
"fontSize": 48,
"color": 4294967295
},
{
"type": "UISlider",
"id": "volume_slider", "id": "volume_slider",
"x": 800, "x": 800, "y": 650,
"y": 650, "width": 320, "height": 40,
"width": 320, "min": 0.0, "max": 100.0, "value": 75.0
"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 See [Widget Reference](../../docs/UI_WIDGETS.md) for all widget properties.
```cpp ## Building
// 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() ```bash
void GameModule::process(const IDataNode& input) { cmake -DGROVE_BUILD_UI_MODULE=ON -B build
while (m_io->hasMessages() > 0) { cmake --build build -j4
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](../../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 ## Testing
### Visual Test
```bash ```bash
cmake -DGROVE_BUILD_UI_MODULE=ON -B build # Visual showcase (run from project root for correct asset paths)
cmake --build build --target test_ui_widgets ./build/tests/test_ui_showcase
./build/tests/test_ui_widgets
```
### Integration Test (with InputModule + BgfxRenderer) # Integration test
```bash
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 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 - **GroveEngine Core**: IModule, IIO, IDataNode
- **BgfxRenderer**: For rendering (via IIO, not direct dependency) - **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 - **nlohmann/json**: JSON parsing
- **spdlog**: Logging - **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/ modules/UIModule/
├── README.md # This file ├── README.md # This file
├── CMakeLists.txt # Build configuration ├── CMakeLists.txt # Build configuration
├── UIModule.h # Main module ├── UIModule.h/.cpp # Main module
├── UIModule.cpp
├── Core/ ├── Core/
│ ├── UIContext.h # Global UI state │ ├── UIContext.h/.cpp # Global UI state
│ ├── UIContext.cpp │ ├── UILayout.h/.cpp # Layout management
│ ├── UILayout.h # Layout management │ ├── UITooltip.h/.cpp # Tooltip system
│ ├── UILayout.cpp │ ├── UITree.h/.cpp # Widget hierarchy
│ ├── UIStyle.h # Widget styling
│ ├── UIStyle.cpp
│ ├── UITooltip.h # Tooltip system
│ ├── UITooltip.cpp
│ ├── UITree.h # Widget hierarchy
│ ├── UITree.cpp
│ └── UIWidget.h # Base widget interface │ └── UIWidget.h # Base widget interface
├── Widgets/ ├── Widgets/
│ ├── UIButton.h/.cpp │ ├── UIButton.h/.cpp
@ -477,45 +156,7 @@ modules/UIModule/
│ ├── UIImage.h/.cpp │ ├── UIImage.h/.cpp
│ └── UIScrollPanel.h/.cpp │ └── UIScrollPanel.h/.cpp
└── Rendering/ └── Rendering/
├── UIRenderer.h # Publishes render commands ├── UIRenderer.h/.cpp # 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<JsonDataNode>("event");
event->setString("widgetId", m_id);
m_io->publish("ui:custom_event", std::move(event));
}
};
``` ```
## License ## License

View File

@ -155,12 +155,20 @@ bool UIRenderer::updateSprite(uint32_t renderId, float x, float y, float w, floa
} }
void UIRenderer::publishSpriteAdd(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer) { void UIRenderer::publishSpriteAdd(uint32_t renderId, float x, float y, float w, float h, int textureId, uint32_t color, int layer) {
spdlog::info("📤 [UIRenderer] Publishing render:sprite:add - renderId={}, center=({:.1f},{:.1f}), scale={}x{}, textureId={}, layer={}",
renderId, x + w * 0.5f, y + h * 0.5f, w, h, textureId, layer);
auto sprite = std::make_unique<JsonDataNode>("sprite"); auto sprite = std::make_unique<JsonDataNode>("sprite");
sprite->setInt("renderId", static_cast<int>(renderId)); sprite->setInt("renderId", static_cast<int>(renderId));
sprite->setDouble("x", static_cast<double>(x + w * 0.5f)); sprite->setDouble("x", static_cast<double>(x + w * 0.5f));
sprite->setDouble("y", static_cast<double>(y + h * 0.5f)); sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
sprite->setDouble("scaleX", static_cast<double>(w)); sprite->setDouble("scaleX", static_cast<double>(w));
sprite->setDouble("scaleY", static_cast<double>(h)); sprite->setDouble("scaleY", static_cast<double>(h));
sprite->setDouble("rotation", 0.0);
sprite->setDouble("u0", 0.0);
sprite->setDouble("v0", 0.0);
sprite->setDouble("u1", 1.0);
sprite->setDouble("v1", 1.0);
sprite->setInt("color", static_cast<int>(color)); sprite->setInt("color", static_cast<int>(color));
sprite->setInt("textureId", textureId); sprite->setInt("textureId", textureId);
sprite->setInt("layer", layer); sprite->setInt("layer", layer);
@ -174,6 +182,11 @@ void UIRenderer::publishSpriteUpdate(uint32_t renderId, float x, float y, float
sprite->setDouble("y", static_cast<double>(y + h * 0.5f)); sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
sprite->setDouble("scaleX", static_cast<double>(w)); sprite->setDouble("scaleX", static_cast<double>(w));
sprite->setDouble("scaleY", static_cast<double>(h)); sprite->setDouble("scaleY", static_cast<double>(h));
sprite->setDouble("rotation", 0.0);
sprite->setDouble("u0", 0.0);
sprite->setDouble("v0", 0.0);
sprite->setDouble("u1", 1.0);
sprite->setDouble("v1", 1.0);
sprite->setInt("color", static_cast<int>(color)); sprite->setInt("color", static_cast<int>(color));
sprite->setInt("textureId", textureId); sprite->setInt("textureId", textureId);
sprite->setInt("layer", layer); sprite->setInt("layer", layer);

View File

@ -9,10 +9,12 @@
#include "Widgets/UICheckbox.h" #include "Widgets/UICheckbox.h"
#include "Widgets/UITextInput.h" #include "Widgets/UITextInput.h"
#include "Widgets/UIScrollPanel.h" #include "Widgets/UIScrollPanel.h"
#include "Widgets/UILabel.h"
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <spdlog/sinks/stdout_color_sinks.h> #include <spdlog/sinks/stdout_color_sinks.h>
#include <chrono>
#include <fstream> #include <fstream>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
@ -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:load"); // Load new layout
m_io->subscribe("ui:set_value"); // Set widget value m_io->subscribe("ui:set_value"); // Set widget value
m_io->subscribe("ui:set_visible"); // Show/hide widget 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"); 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<std::chrono::microseconds>(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<UILabel*>(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);
}
}
}
}
} }
} }
@ -208,6 +241,9 @@ void UIModule::updateUI(float deltaTime) {
// Publish type-specific events // Publish type-specific events
std::string widgetType = clickedWidget->getType(); std::string widgetType = clickedWidget->getType();
m_logger->info("🖱️ Widget clicked: id='{}', type='{}', mousePressed={}",
clickedWidget->id, widgetType, m_context->mousePressed);
// Handle focus for text inputs // Handle focus for text inputs
if (widgetType == "textinput" && m_context->mousePressed) { if (widgetType == "textinput" && m_context->mousePressed) {
UITextInput* textInput = static_cast<UITextInput*>(clickedWidget); UITextInput* textInput = static_cast<UITextInput*>(clickedWidget);
@ -254,6 +290,13 @@ void UIModule::updateUI(float deltaTime) {
valueEvent->setDouble("value", slider->getValue()); valueEvent->setDouble("value", slider->getValue());
valueEvent->setDouble("min", slider->minValue); valueEvent->setDouble("min", slider->minValue);
valueEvent->setDouble("max", slider->maxValue); valueEvent->setDouble("max", slider->maxValue);
// Add timestamp for latency measurement
auto now = std::chrono::high_resolution_clock::now();
auto micros = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()).count();
valueEvent->setDouble("_timestamp_publish", static_cast<double>(micros));
m_logger->info("⏱️ [T0] UIModule publishing ui:value_changed at {} µs", micros);
m_io->publish("ui:value_changed", std::move(valueEvent)); m_io->publish("ui:value_changed", std::move(valueEvent));
// Publish onChange action if specified // Publish onChange action if specified

View File

@ -44,11 +44,21 @@ void UIButton::render(UIRenderer& renderer) {
const ButtonStyle& style = getCurrentStyle(); const ButtonStyle& style = getCurrentStyle();
static int logCount = 0;
if (logCount < 10) { // Log first 10 buttons to see all textured ones
spdlog::info("UIButton[{}]::render() id='{}', state={}, normalStyle.textureId={}, useTexture={}",
logCount, id, (int)state, normalStyle.textureId, normalStyle.useTexture);
spdlog::info(" current style: textureId={}, useTexture={}", style.textureId, style.useTexture);
logCount++;
}
// Retained mode: only publish if changed // Retained mode: only publish if changed
int bgLayer = renderer.nextLayer(); int bgLayer = renderer.nextLayer();
// Render background (texture or solid color) // Render background (texture or solid color)
if (style.useTexture && style.textureId > 0) { if (style.useTexture && style.textureId > 0) {
spdlog::info("🎨 [UIButton '{}'] Rendering SPRITE: renderId={}, pos=({},{}), size={}x{}, textureId={}, color=0x{:08X}, layer={}",
id, m_renderId, absX, absY, width, height, style.textureId, style.bgColor, bgLayer);
renderer.updateSprite(m_renderId, absX, absY, width, height, style.textureId, style.bgColor, bgLayer); renderer.updateSprite(m_renderId, absX, absY, width, height, style.textureId, style.bgColor, bgLayer);
} else { } else {
renderer.updateRect(m_renderId, absX, absY, width, height, style.bgColor, bgLayer); renderer.updateRect(m_renderId, absX, absY, width, height, style.bgColor, bgLayer);

View File

@ -3,6 +3,7 @@
#include "../Rendering/UIRenderer.h" #include "../Rendering/UIRenderer.h"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <spdlog/spdlog.h>
namespace grove { namespace grove {
@ -63,6 +64,14 @@ void UITextInput::render(UIRenderer& renderer) {
// Render border // Render border
int borderLayer = renderer.nextLayer(); int borderLayer = renderer.nextLayer();
uint32_t borderColor = isFocused ? style.focusBorderColor : style.borderColor; uint32_t borderColor = isFocused ? style.focusBorderColor : style.borderColor;
static int renderCount = 0;
if (renderCount < 5) {
spdlog::info("🎨 UITextInput '{}' render: isFocused={}, borderColor=0x{:08X} (focus=0x{:08X}, normal=0x{:08X})",
id, isFocused, borderColor, style.focusBorderColor, style.borderColor);
renderCount++;
}
renderer.updateRect(m_borderRenderId, absX, absY + height - style.borderWidth, renderer.updateRect(m_borderRenderId, absX, absY + height - style.borderWidth,
width, style.borderWidth, borderColor, borderLayer); width, style.borderWidth, borderColor, borderLayer);
@ -199,6 +208,7 @@ void UITextInput::gainFocus() {
isFocused = true; isFocused = true;
cursorBlinkTimer = 0.0f; cursorBlinkTimer = 0.0f;
cursorVisible = true; cursorVisible = true;
spdlog::info("🎯 UITextInput '{}' gainFocus() called - isFocused={}", id, isFocused);
} }
} }

View File

@ -13,10 +13,10 @@ IntraIOManager::IntraIOManager() {
logger = stillhammer::createDomainLogger("IntraIOManager", "io", config); logger = stillhammer::createDomainLogger("IntraIOManager", "io", config);
logger->info("🌐🔗 IntraIOManager created - Central message router initialized"); logger->info("🌐🔗 IntraIOManager created - Central message router initialized");
// TEMPORARY: Disable batch thread to debug Windows crash // Start batch flush thread for low-latency message delivery
batchThreadRunning = false; batchThreadRunning = true;
// batchThread = std::thread(&IntraIOManager::batchFlushLoop, this); batchThread = std::thread(&IntraIOManager::batchFlushLoop, this);
logger->info("⚠️ Batch flush thread DISABLED (debugging Windows crash)"); logger->info("✅ Batch flush thread started for push-based message delivery");
} }
IntraIOManager::~IntraIOManager() { IntraIOManager::~IntraIOManager() {

View File

@ -1451,3 +1451,180 @@ if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILA
) )
message(STATUS "Single button test 'test_single_button' enabled") message(STATUS "Single button test 'test_single_button' enabled")
endif() endif()
# UI Texture Support Test - headless test demonstrating texture properties
if(GROVE_BUILD_UI_MODULE)
add_executable(test_ui_textures
visual/test_ui_textures.cpp
)
target_include_directories(test_ui_textures PRIVATE
${CMAKE_SOURCE_DIR}/modules
${CMAKE_SOURCE_DIR}/modules/UIModule
)
target_link_libraries(test_ui_textures PRIVATE
GroveEngine::impl
UIModule_static
spdlog::spdlog
)
message(STATUS "UI texture support test 'test_ui_textures' enabled")
endif()
# Textured UI Visual Demo - shows widgets with custom textures
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
add_executable(test_ui_textured_demo WIN32
visual/test_ui_textured_demo.cpp
)
target_include_directories(test_ui_textured_demo PRIVATE
${CMAKE_SOURCE_DIR}/modules
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
${CMAKE_SOURCE_DIR}/modules/UIModule
)
target_link_libraries(test_ui_textured_demo PRIVATE
SDL2::SDL2main
SDL2::SDL2
GroveEngine::impl
BgfxRenderer_static
UIModule_static
spdlog::spdlog
)
message(STATUS "Textured UI demo 'test_ui_textured_demo' enabled")
endif()
# Simple textured UI demo - shows widget properties (no rendering)
if(GROVE_BUILD_UI_MODULE)
add_executable(test_ui_textured_simple
visual/test_ui_textured_simple.cpp
)
target_include_directories(test_ui_textured_simple PRIVATE
${CMAKE_SOURCE_DIR}/modules
${CMAKE_SOURCE_DIR}/modules/UIModule
)
target_link_libraries(test_ui_textured_simple PRIVATE
GroveEngine::impl
UIModule_static
spdlog::spdlog
)
message(STATUS "Simple textured UI demo 'test_ui_textured_simple' enabled")
endif()
# Textured Button Visual Test - shows REAL textures on buttons
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
add_executable(test_textured_button WIN32
visual/test_textured_button.cpp
)
target_include_directories(test_textured_button PRIVATE
${CMAKE_SOURCE_DIR}/modules
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
${CMAKE_SOURCE_DIR}/modules/UIModule
)
target_link_libraries(test_textured_button PRIVATE
SDL2::SDL2main
SDL2::SDL2
GroveEngine::impl
BgfxRenderer_static
UIModule_static
spdlog::spdlog
)
message(STATUS "Textured button test 'test_textured_button' enabled")
endif()
# Minimal Textured Demo - Direct sprite rendering with textures
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND SDL2_AVAILABLE)
add_executable(test_textured_demo_minimal WIN32
visual/test_textured_demo_minimal.cpp
)
target_include_directories(test_textured_demo_minimal PRIVATE
${CMAKE_SOURCE_DIR}/modules
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
)
target_link_libraries(test_textured_demo_minimal PRIVATE
SDL2::SDL2main
SDL2::SDL2
GroveEngine::impl
BgfxRenderer_static
spdlog::spdlog
)
message(STATUS "Minimal textured demo 'test_textured_demo_minimal' enabled")
endif()
# Button with PNG texture - Load real PNG file and apply to button
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
add_executable(test_button_with_png WIN32
visual/test_button_with_png.cpp
)
target_include_directories(test_button_with_png PRIVATE
${CMAKE_SOURCE_DIR}/modules
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
${CMAKE_SOURCE_DIR}/modules/UIModule
)
target_link_libraries(test_button_with_png PRIVATE
SDL2::SDL2main
SDL2::SDL2
GroveEngine::impl
BgfxRenderer_static
UIModule_static
spdlog::spdlog
)
message(STATUS "PNG button test 'test_button_with_png' enabled")
endif()
# 3 Buttons Minimal Test - 3 textured buttons in minimal layout
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
add_executable(test_3buttons_minimal WIN32
visual/test_3buttons_minimal.cpp
)
target_include_directories(test_3buttons_minimal PRIVATE
${CMAKE_SOURCE_DIR}/modules
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
${CMAKE_SOURCE_DIR}/modules/UIModule
)
target_link_libraries(test_3buttons_minimal PRIVATE
SDL2::SDL2main
SDL2::SDL2
GroveEngine::impl
BgfxRenderer_static
UIModule_static
spdlog::spdlog
)
message(STATUS "3 buttons minimal test 'test_3buttons_minimal' enabled")
endif()
# 1 Button Texture 2 Test - diagnostic to see if only texture 1 works
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILABLE)
add_executable(test_1button_texture2 WIN32
visual/test_1button_texture2.cpp
)
target_include_directories(test_1button_texture2 PRIVATE
${CMAKE_SOURCE_DIR}/modules
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
${CMAKE_SOURCE_DIR}/modules/UIModule
)
target_link_libraries(test_1button_texture2 PRIVATE
SDL2::SDL2main
SDL2::SDL2
GroveEngine::impl
BgfxRenderer_static
UIModule_static
spdlog::spdlog
)
message(STATUS "1 button texture 2 test 'test_1button_texture2' enabled")
endif()
# Direct sprite texture test - bypasses UIModule, uses renderer directly
if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND SDL2_AVAILABLE)
add_executable(test_direct_sprite_texture WIN32
visual/test_direct_sprite_texture.cpp
)
target_include_directories(test_direct_sprite_texture PRIVATE
${CMAKE_SOURCE_DIR}/modules
${CMAKE_SOURCE_DIR}/modules/BgfxRenderer
)
target_link_libraries(test_direct_sprite_texture PRIVATE
SDL2::SDL2main
SDL2::SDL2
GroveEngine::impl
BgfxRenderer_static
spdlog::spdlog
)
message(STATUS "Direct sprite texture test 'test_direct_sprite_texture' enabled")
endif()

View File

@ -0,0 +1,211 @@
/**
* Test: UIButton avec texture PNG chargée depuis un fichier
* Ce test montre qu'on peut mettre une VRAIE image sur un bouton
*/
#include <SDL.h>
#include <SDL_syswm.h>
#include <iostream>
#include <memory>
#include <fstream>
#include "BgfxRendererModule.h"
#include "UIModule/UIModule.h"
#include "../modules/BgfxRenderer/Resources/ResourceCache.h"
#include "../modules/BgfxRenderer/RHI/RHITypes.h"
#include "../modules/BgfxRenderer/RHI/RHIDevice.h"
#include <grove/JsonDataNode.h>
#include <grove/IntraIOManager.h>
#include <grove/IntraIO.h>
#include <spdlog/spdlog.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <bgfx/bgfx.h>
#include <vector>
using namespace grove;
int main(int argc, char* argv[]) {
spdlog::set_level(spdlog::level::info);
auto logger = spdlog::stdout_color_mt("TexturedButtonTest");
if (SDL_Init(SDL_INIT_VIDEO) < 0) {
std::cerr << "SDL_Init failed" << std::endl;
return 1;
}
SDL_Window* window = SDL_CreateWindow(
"Textured Button Test - Gradient",
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
800, 600, SDL_WINDOW_SHOWN
);
SDL_SysWMinfo wmi;
SDL_VERSION(&wmi.version);
SDL_GetWindowWMInfo(window, &wmi);
// Create IIO instances - IMPORTANT: game publishes input, ui subscribes and publishes render commands
auto gameIO = IntraIOManager::getInstance().createInstance("game");
auto uiIO = IntraIOManager::getInstance().createInstance("ui");
auto rendererIO = IntraIOManager::getInstance().createInstance("renderer");
gameIO->subscribe("ui:hover");
gameIO->subscribe("ui:click");
gameIO->subscribe("ui:action");
// Initialize BgfxRenderer WITH 3 TEXTURES loaded via config
auto renderer = std::make_unique<BgfxRendererModule>();
{
JsonDataNode config("config");
config.setDouble("nativeWindowHandle",
static_cast<double>(reinterpret_cast<uintptr_t>(wmi.info.win.window)));
config.setInt("windowWidth", 800);
config.setInt("windowHeight", 600);
// Load 3 textures
config.setString("texture1", "../../assets/textures/5oxaxt1vo2f91.jpg"); // Car
config.setString("texture2", "../../assets/textures/1f440.png"); // Eyes
config.setString("texture3", "../../assets/textures/IconDesigner.png"); // Icon
renderer->setConfiguration(config, rendererIO.get(), nullptr);
}
logger->info("✓ Loaded 3 textures (IDs: 1, 2, 3)");
// Initialize UIModule with 3 TEXTURED BUTTONS
auto ui = std::make_unique<UIModule>();
{
JsonDataNode config("config");
config.setInt("windowWidth", 800);
config.setInt("windowHeight", 600);
nlohmann::json layoutJson = {
{"id", "root"},
{"type", "panel"},
{"x", 0}, {"y", 0},
{"width", 800}, {"height", 600}, // Full screen invisible panel (just container)
{"style", {
{"bgColor", "0x00000000"} // Fully transparent - just a container
}},
{"children", {
{
{"id", "btn_car"},
{"type", "button"},
{"x", 50},
{"y", 50},
{"width", 400},
{"height", 200},
{"text", ""},
{"onClick", "car_action"},
{"style", {
{"normal", {{"textureId", 1}, {"bgColor", "0xFFFFFFFF"}}},
{"hover", {{"textureId", 1}, {"bgColor", "0xFFFF00FF"}}},
{"pressed", {{"textureId", 1}, {"bgColor", "0x888888FF"}}}
}}
},
{
{"id", "btn_eyes"},
{"type", "button"},
{"x", 50},
{"y", 270},
{"width", 250},
{"height", 200},
{"text", ""},
{"onClick", "eyes_action"},
{"style", {
{"normal", {{"textureId", 2}, {"bgColor", "0xFFFFFFFF"}}},
{"hover", {{"textureId", 2}, {"bgColor", "0x00FFFFFF"}}},
{"pressed", {{"textureId", 2}, {"bgColor", "0x888888FF"}}}
}}
},
{
{"id", "btn_icon"},
{"type", "button"},
{"x", 320},
{"y", 270},
{"width", 250},
{"height", 200},
{"text", ""},
{"onClick", "icon_action"},
{"style", {
{"normal", {{"textureId", 3}, {"bgColor", "0xFFFFFFFF"}}},
{"hover", {{"textureId", 3}, {"bgColor", "0xFF00FFFF"}}},
{"pressed", {{"textureId", 3}, {"bgColor", "0x888888FF"}}}
}}
}
}}
};
auto layoutNode = std::make_unique<JsonDataNode>("layout", layoutJson);
config.setChild("layout", std::move(layoutNode));
ui->setConfiguration(config, uiIO.get(), nullptr);
logger->info("✓ UIModule configured with 3 textured buttons!");
}
logger->info("\n╔════════════════════════════════════════╗");
logger->info("║ 3 BOUTONS AVEC TEXTURES ║");
logger->info("╠════════════════════════════════════════╣");
logger->info("║ Button 1: Car (textureId=1) ║");
logger->info("║ Button 2: Eyes (textureId=2) ║");
logger->info("║ Button 3: Icon (textureId=3) ║");
logger->info("║ Press ESC to exit ║");
logger->info("╚════════════════════════════════════════╝\n");
bool running = true;
while (running) {
SDL_Event e;
while (SDL_PollEvent(&e)) {
if (e.type == SDL_QUIT ||
(e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE)) {
running = false;
}
// Forward mouse events
if (e.type == SDL_MOUSEMOTION) {
auto mouseMsg = std::make_unique<JsonDataNode>("mouse");
mouseMsg->setDouble("x", static_cast<double>(e.motion.x));
mouseMsg->setDouble("y", static_cast<double>(e.motion.y));
gameIO->publish("input:mouse:move", std::move(mouseMsg));
}
else if (e.type == SDL_MOUSEBUTTONDOWN || e.type == SDL_MOUSEBUTTONUP) {
auto mouseMsg = std::make_unique<JsonDataNode>("mouse");
mouseMsg->setInt("button", e.button.button);
mouseMsg->setBool("pressed", e.type == SDL_MOUSEBUTTONDOWN);
mouseMsg->setDouble("x", static_cast<double>(e.button.x));
mouseMsg->setDouble("y", static_cast<double>(e.button.y));
gameIO->publish("input:mouse:button", std::move(mouseMsg));
}
}
// Check for UI events
while (gameIO->hasMessages() > 0) {
auto msg = gameIO->pullMessage();
if (msg.topic == "ui:action") {
logger->info("🖱️ BOUTON CLICKÉ!");
}
}
// Update modules
JsonDataNode input("input");
input.setDouble("deltaTime", 0.016);
ui->process(input);
renderer->process(input);
SDL_Delay(16);
}
logger->info("Cleaning up...");
// Textures are managed by ResourceCache, will be cleaned up in renderer->shutdown()
ui->shutdown();
renderer->shutdown();
IntraIOManager::getInstance().removeInstance("game");
IntraIOManager::getInstance().removeInstance("ui");
IntraIOManager::getInstance().removeInstance("renderer");
SDL_DestroyWindow(window);
SDL_Quit();
logger->info("Test complete!");
return 0;
}

View File

@ -25,6 +25,7 @@
#include <cmath> #include <cmath>
#include <memory> #include <memory>
#include <string> #include <string>
#include <chrono>
#include "BgfxRendererModule.h" #include "BgfxRendererModule.h"
#include "UIModule.h" #include "UIModule.h"
@ -37,20 +38,20 @@
using namespace grove; using namespace grove;
// Create the UI layout JSON - must be a single root widget with children // Create the UI layout JSON - MINIMAL VERSION WITH ONLY TEXTURED BUTTONS
static nlohmann::json createUILayout() { static nlohmann::json createUILayout() {
nlohmann::json root; nlohmann::json root;
// Root panel (full screen background) // Root panel (TRANSPARENT like test_button_with_png!)
root["type"] = "panel"; root["type"] = "panel";
root["id"] = "root"; root["id"] = "root";
root["x"] = 0; root["x"] = 0;
root["y"] = 0; root["y"] = 0;
root["width"] = 1024; root["width"] = 1024;
root["height"] = 768; root["height"] = 768;
root["style"] = {{"bgColor", "0x1a1a2eFF"}}; root["style"] = {{"bgColor", "0x00000000"}}; // TRANSPARENT!
// Children array (like test_single_button) // Children array - ONLY TEXTURED BUTTONS
nlohmann::json children = nlohmann::json::array(); nlohmann::json children = nlohmann::json::array();
// Title label // Title label
@ -151,6 +152,78 @@ static nlohmann::json createUILayout() {
}} }}
}); });
// === TEXTURED BUTTONS PANEL === (TRANSPARENT FOR TESTING)
children.push_back({
{"type", "panel"},
{"id", "textured_buttons_panel"},
{"x", 350}, {"y", 120},
{"width", 300}, {"height", 280},
{"style", {{"bgColor", "0x00000000"}}} // TRANSPARENT!
});
children.push_back({
{"type", "label"},
{"id", "textured_buttons_title"},
{"x", 365}, {"y", 130},
{"width", 250}, {"height", 30},
{"text", "Sprite Buttons"},
{"style", {{"fontSize", 20}, {"color", "0xFFFFFFFF"}}}
});
// Textured Button 1 - Car (HUGE LIKE WORKING TEST!)
children.push_back({
{"type", "button"},
{"id", "btn_car"},
{"x", 50}, {"y", 120},
{"width", 400}, {"height", 200}, // Same as test_button_with_png!
{"text", ""},
{"onClick", "sprite_car"},
{"style", {
{"normal", {{"textureId", 1}, {"bgColor", "0xFFFFFFFF"}, {"textColor", "0x000000FF"}}},
{"hover", {{"textureId", 1}, {"bgColor", "0xFFFF00FF"}, {"textColor", "0x000000FF"}}}, // Yellow tint
{"pressed", {{"textureId", 1}, {"bgColor", "0x888888FF"}, {"textColor", "0x000000FF"}}} // Dark tint
}}
});
// Textured Button 2 - Eyes (HUGE!)
children.push_back({
{"type", "button"},
{"id", "btn_eyes"},
{"x", 470}, {"y", 120},
{"width", 250}, {"height", 200}, // Much bigger!
{"text", ""},
{"onClick", "sprite_eyes"},
{"style", {
{"normal", {{"textureId", 2}, {"bgColor", "0xFFFFFFFF"}, {"textColor", "0x000000FF"}}},
{"hover", {{"textureId", 2}, {"bgColor", "0x00FFFFFF"}, {"textColor", "0x000000FF"}}}, // Cyan tint
{"pressed", {{"textureId", 2}, {"bgColor", "0x888888FF"}, {"textColor", "0x000000FF"}}}
}}
});
// Textured Button 3 - Icon (HUGE!)
children.push_back({
{"type", "button"},
{"id", "btn_icon"},
{"x", 50}, {"y", 340},
{"width", 250}, {"height", 200}, // Much bigger!
{"text", ""},
{"onClick", "sprite_icon"},
{"style", {
{"normal", {{"textureId", 3}, {"bgColor", "0xFFFFFFFF"}, {"textColor", "0x000000FF"}}},
{"hover", {{"textureId", 3}, {"bgColor", "0xFF00FFFF"}, {"textColor", "0x000000FF"}}}, // Magenta tint
{"pressed", {{"textureId", 3}, {"bgColor", "0x888888FF"}, {"textColor", "0x000000FF"}}}
}}
});
// Info label for textured buttons
children.push_back({
{"type", "label"},
{"x", 370}, {"y", 340},
{"width", 260}, {"height", 50},
{"text", "Retained mode:\nTextures only sent once!"},
{"style", {{"fontSize", 12}, {"color", "0xAAAAAAFF"}}}
});
// === MIDDLE COLUMN: Inputs Panel === // === MIDDLE COLUMN: Inputs Panel ===
children.push_back({ children.push_back({
{"type", "panel"}, {"type", "panel"},
@ -351,7 +424,7 @@ public:
m_inputIO = m_inputIOPtr.get(); m_inputIO = m_inputIOPtr.get();
m_gameIO = m_gameIOPtr.get(); m_gameIO = m_gameIOPtr.get();
// Create and configure BgfxRenderer // Create and configure BgfxRenderer with textures
m_renderer = std::make_unique<BgfxRendererModule>(); m_renderer = std::make_unique<BgfxRendererModule>();
{ {
JsonDataNode config("config"); JsonDataNode config("config");
@ -359,10 +432,15 @@ public:
static_cast<double>(reinterpret_cast<uintptr_t>(wmi.info.win.window))); static_cast<double>(reinterpret_cast<uintptr_t>(wmi.info.win.window)));
config.setInt("windowWidth", 1024); config.setInt("windowWidth", 1024);
config.setInt("windowHeight", 768); config.setInt("windowHeight", 768);
config.setString("backend", "d3d11"); // config.setString("backend", "d3d11"); // LET BGFX CHOOSE LIKE test_button_with_png!
config.setBool("vsync", true); config.setBool("vsync", true);
// 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_renderer->setConfiguration(config, m_rendererIO, nullptr);
} }
m_logger->info("✓ Loaded 3 textures for sprite buttons (IDs: 1, 2, 3)");
// Create and configure UIModule with inline layout // Create and configure UIModule with inline layout
m_uiModule = std::make_unique<UIModule>(); m_uiModule = std::make_unique<UIModule>();
@ -417,14 +495,24 @@ public:
m_gameIO->publish("input:mouse:wheel", std::move(msg)); m_gameIO->publish("input:mouse:wheel", std::move(msg));
} }
else if (e.type == SDL_KEYDOWN) { else if (e.type == SDL_KEYDOWN) {
// Only publish special keys (non-printable), printable chars come from SDL_TEXTINPUT
int keyCode = e.key.keysym.sym;
bool isSpecialKey = (keyCode == SDLK_BACKSPACE || keyCode == SDLK_DELETE ||
keyCode == SDLK_RETURN || keyCode == SDLK_LEFT ||
keyCode == SDLK_RIGHT || keyCode == SDLK_HOME ||
keyCode == SDLK_END || keyCode == SDLK_UP ||
keyCode == SDLK_DOWN || keyCode == SDLK_TAB);
if (isSpecialKey) {
auto msg = std::make_unique<JsonDataNode>("key"); auto msg = std::make_unique<JsonDataNode>("key");
msg->setInt("keyCode", e.key.keysym.sym); msg->setInt("keyCode", keyCode);
msg->setInt("scancode", e.key.keysym.scancode);
msg->setBool("pressed", true); msg->setBool("pressed", true);
msg->setInt("char", 0); msg->setInt("char", 0);
m_gameIO->publish("input:keyboard", std::move(msg)); m_gameIO->publish("input:keyboard", std::move(msg));
} }
}
else if (e.type == SDL_TEXTINPUT) { else if (e.type == SDL_TEXTINPUT) {
// Printable characters come here
auto msg = std::make_unique<JsonDataNode>("key"); auto msg = std::make_unique<JsonDataNode>("key");
msg->setInt("keyCode", 0); msg->setInt("keyCode", 0);
msg->setBool("pressed", true); msg->setBool("pressed", true);
@ -500,6 +588,15 @@ private:
else if (action == "action_danger") { else if (action == "action_danger") {
m_logger->warn("Danger button clicked!"); m_logger->warn("Danger button clicked!");
} }
else if (action == "sprite_car") {
m_logger->info("🚗 Car sprite button clicked! (Texture ID: 1)");
}
else if (action == "sprite_eyes") {
m_logger->info("👀 Eyes sprite button clicked! (Texture ID: 2)");
}
else if (action == "sprite_icon") {
m_logger->info("🎨 Icon sprite button clicked! (Texture ID: 3)");
}
} }
else if (msg.topic == "ui:click") { else if (msg.topic == "ui:click") {
std::string widgetId = msg.data->getString("widgetId", ""); std::string widgetId = msg.data->getString("widgetId", "");
@ -510,10 +607,38 @@ private:
std::to_string(static_cast<int>(y)) + ")"; std::to_string(static_cast<int>(y)) + ")";
} }
else if (msg.topic == "ui:value_changed") { else if (msg.topic == "ui:value_changed") {
// Timestamp on receive
auto now = std::chrono::high_resolution_clock::now();
auto micros = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()).count();
std::string widgetId = msg.data->getString("widgetId", ""); std::string widgetId = msg.data->getString("widgetId", "");
if (widgetId == "volume_slider") { if (widgetId == "volume_slider") {
double value = msg.data->getDouble("value", 0); double value = msg.data->getDouble("value", 0);
logEntry = "Volume: " + std::to_string(static_cast<int>(value)) + "%"; logEntry = "Volume: " + std::to_string(static_cast<int>(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<JsonDataNode>("set_text");
updateMsg->setString("id", "slider_label");
updateMsg->setString("text", "Volume: " + std::to_string(static_cast<int>(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<std::chrono::microseconds>(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) { else if (widgetId.find("chk_") == 0) {
bool checked = msg.data->getBool("checked", false); bool checked = msg.data->getBool("checked", false);