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>
This commit is contained in:
parent
fd508e4a68
commit
0441a9d648
14
CLAUDE.md
14
CLAUDE.md
@ -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)
|
||||||
|
|||||||
367
docs/UI_ARCHITECTURE.md
Normal file
367
docs/UI_ARCHITECTURE.md
Normal 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
125
docs/UI_TOPICS.md
Normal 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
376
docs/UI_WIDGETS.md
Normal 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",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -257,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
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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"
|
||||||
@ -433,10 +434,10 @@ public:
|
|||||||
config.setInt("windowHeight", 768);
|
config.setInt("windowHeight", 768);
|
||||||
// config.setString("backend", "d3d11"); // LET BGFX CHOOSE LIKE test_button_with_png!
|
// 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
|
// Load textures for sprite buttons (paths relative to project root)
|
||||||
config.setString("texture1", "../../assets/textures/5oxaxt1vo2f91.jpg"); // Car
|
config.setString("texture1", "assets/textures/5oxaxt1vo2f91.jpg"); // Car
|
||||||
config.setString("texture2", "../../assets/textures/1f440.png"); // Eyes emoji
|
config.setString("texture2", "assets/textures/1f440.png"); // Eyes emoji
|
||||||
config.setString("texture3", "../../assets/textures/IconDesigner.png"); // Icon
|
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)");
|
m_logger->info("✓ Loaded 3 textures for sprite buttons (IDs: 1, 2, 3)");
|
||||||
@ -606,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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user